diff --git a/.Rbuildignore b/.Rbuildignore
index 68067501..9e630fb7 100644
--- a/.Rbuildignore
+++ b/.Rbuildignore
@@ -5,6 +5,7 @@
^pkgdown$
^revdep$
^bench$
+^data-raw$
^CODE_OF_CONDUCT\.md$
^CONTRIBUTING\.md$
^LICENSE\.md$
@@ -17,3 +18,5 @@
^hexlogo\.R$
^\.todo$
^vignettes/.*\.html$
+^vignettes/*_files$
+^man/figures-raw$
diff --git a/.github/workflows/R-CMD-check.yaml b/.github/workflows/R-CMD-check.yaml
index 99989ebc..a3a3de21 100644
--- a/.github/workflows/R-CMD-check.yaml
+++ b/.github/workflows/R-CMD-check.yaml
@@ -1,25 +1,33 @@
-# Workflow derived from https://github.com/r-lib/actions/tree/master/examples
+# Workflow derived from https://github.com/r-lib/actions/tree/v2/examples
# Need help debugging build failures? Start at https://github.com/r-lib/actions#where-to-find-help
on:
push:
- branches: [main, master, develop]
+ branches: [main, master]
pull_request:
- branches: [main, master, develop]
+ branches: [main, master]
-name: R-CMD-check
+name: R-CMD-check.yaml
+
+permissions: read-all
jobs:
R-CMD-check:
runs-on: ubuntu-latest
+
strategy:
+ fail-fast: false
matrix:
R: [ 'release' ]
+
env:
GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }}
R_KEEP_PKG_SOURCE: yes
+
steps:
- uses: actions/checkout@v4
+ - uses: r-lib/actions/setup-pandoc@v2
+
- uses: r-lib/actions/setup-r@v2
with:
r-version: ${{ matrix.R }}
@@ -27,18 +35,11 @@ jobs:
- uses: r-lib/actions/setup-r-dependencies@v2
with:
- extra-packages: rcmdcheck
+ extra-packages: any::rcmdcheck
+ needs: check
- uses: r-lib/actions/check-r-package@v2
-
- - name: Show testthat output
- if: always()
- run: find check -name 'testthat.Rout*' -exec cat '{}' \; || true
- shell: bash
-
- - name: Upload check results
- if: failure()
- uses: actions/upload-artifact@main
with:
- name: ${{ runner.os }}-r${{ matrix.config.r }}-results
- path: check
+ error-on: '"error"'
+ upload-snapshots: true
+ build_args: 'c("--no-manual","--compact-vignettes=gs+qpdf")'
\ No newline at end of file
diff --git a/.github/workflows/pkgdown.yaml b/.github/workflows/pkgdown.yaml
index 90a13231..998b5448 100644
--- a/.github/workflows/pkgdown.yaml
+++ b/.github/workflows/pkgdown.yaml
@@ -13,7 +13,7 @@ jobs:
env:
GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }}
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
- uses: r-lib/actions/setup-pandoc@v2
@@ -26,8 +26,14 @@ jobs:
extra-packages: any::pkgdown, local::.
needs: website
- - name: Deploy package
- run: |
- git config --local user.name "$GITHUB_ACTOR"
- git config --local user.email "$GITHUB_ACTOR@users.noreply.github.com"
- Rscript -e 'pkgdown::deploy_to_branch(new_process = FALSE)'
+ - name: Build site
+ run: pkgdown::build_site_github_pages(new_process = FALSE, install = FALSE)
+ shell: Rscript {0}
+
+ - name: Deploy to GitHub pages 🚀
+ if: github.event_name != 'pull_request'
+ uses: JamesIves/github-pages-deploy-action@v4.5.0
+ with:
+ clean: false
+ branch: gh-pages
+ folder: docs
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index af908791..6fdf3a8a 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -27,8 +27,9 @@ The type should be one of the defined types listed below. If you feel artistic,
- **feat**: Implementation of a new feature. `:gift:` :gift:
- **fix**: A bug fix. `:wrench:` :wrench:
- **style**: Changes to code formatting. No change to program logic. `:art:` :art:
-- **refactor**: Changes to code which do not change behaviour, e.g. renaming variables or splitting functions. `:construction:` :construction:
-- **docs**: Adding, removing or updating user documentation or to code comments. `:books:` :books:
+- **refactor**: Changes to existing functionality that do not change behaviour. `:construction:` :construction:
+- **breaking**: Changes to existing functionality that are not backwards compatible. `:warning:` :warning:
+- **docs**: Adding, removing or updating user documentation. `:books:` :books:
- **logs**: Adding, removing or updating log messages. `:sound:` :sound:
- **test**: Adding, removing or updating tests. No changes to user code. `:test_tube:` :test_tube:
- **cicd**: Adding, removing or updating CI/CD workflows. No changes to user code. `:robot:` :robot:
diff --git a/DESCRIPTION b/DESCRIPTION
index 549742f2..112b2d68 100644
--- a/DESCRIPTION
+++ b/DESCRIPTION
@@ -1,6 +1,6 @@
Package: sfnetworks
Title: Tidy Geospatial Networks
-Version: 0.6.4
+Version: 1.0.0
Authors@R:
c(person(given = "Lucas",
family = "van der Meer",
@@ -34,33 +34,45 @@ BugReports: https://github.com/luukvdmeer/sfnetworks/issues/
Depends:
R (>= 3.6)
Imports:
- crayon,
- dplyr,
+ cli (>= 3.0.0),
+ dplyr (>= 1.1.0),
graphics,
- igraph,
+ igraph (>= 2.1.0),
+ lifecycle (>= 1.0.0),
lwgeom,
- rlang,
- sf,
- sfheaders,
+ methods,
+ pillar,
+ rlang (>= 1.0.0),
+ sf (>= 1.0-11),
+ sfheaders (>= 0.2.2),
+ stats,
tibble,
- tidygraph,
- units,
+ tidygraph (>= 1.3.0),
+ tidyselect (>= 1.0.0),
+ units (>= 0.4.6),
utils
Suggests:
dbscan,
+ dodgr,
fansi,
+ geodist,
ggplot2 (>= 3.0.0),
+ ggraph (>= 2.2.0),
knitr,
+ osmdata,
purrr,
+ quarto,
rmarkdown,
s2 (>= 1.0.1),
spatstat.geom,
spatstat.linnet,
+ spdep,
testthat,
TSP
VignetteBuilder:
- knitr
+ knitr,
+ quarto
ByteCompile: true
Encoding: UTF-8
LazyData: true
-RoxygenNote: 7.3.1
+RoxygenNote: 7.3.2
diff --git a/NAMESPACE b/NAMESPACE
index 551dc8eb..2057b4b9 100644
--- a/NAMESPACE
+++ b/NAMESPACE
@@ -2,14 +2,17 @@
S3method("st_agr<-",sfnetwork)
S3method("st_crs<-",sfnetwork)
+S3method("st_geometry<-",igraph)
S3method("st_geometry<-",sfnetwork)
+S3method("st_geometry<-",tbl_graph)
S3method(as_sfnetwork,default)
+S3method(as_sfnetwork,dodgr_streetnet)
+S3method(as_sfnetwork,focused_tbl_graph)
S3method(as_sfnetwork,linnet)
S3method(as_sfnetwork,psp)
S3method(as_sfnetwork,sf)
S3method(as_sfnetwork,sfNetwork)
S3method(as_sfnetwork,sfc)
-S3method(as_sfnetwork,sfnetwork)
S3method(as_sfnetwork,tbl_graph)
S3method(as_tbl_graph,sfnetwork)
S3method(as_tibble,sfnetwork)
@@ -17,6 +20,7 @@ S3method(morph,sfnetwork)
S3method(plot,sfnetwork)
S3method(print,morphed_sfnetwork)
S3method(print,sfnetwork)
+S3method(reroute,sfnetwork)
S3method(st_agr,sfnetwork)
S3method(st_area,sfnetwork)
S3method(st_as_s2,sfnetwork)
@@ -44,12 +48,18 @@ S3method(st_nearest_points,sfnetwork)
S3method(st_network_bbox,sfnetwork)
S3method(st_network_blend,sfnetwork)
S3method(st_network_cost,sfnetwork)
+S3method(st_network_faces,sfnetwork)
+S3method(st_network_iso,sfnetwork)
S3method(st_network_join,sfnetwork)
S3method(st_network_paths,sfnetwork)
+S3method(st_network_travel,sfnetwork)
S3method(st_normalize,sfnetwork)
S3method(st_precision,sfnetwork)
+S3method(st_project_on_network,sf)
+S3method(st_project_on_network,sfc)
S3method(st_reverse,sfnetwork)
S3method(st_sample,sfnetwork)
+S3method(st_segmentize,sfnetwork)
S3method(st_set_precision,sfnetwork)
S3method(st_shift_longitude,sfnetwork)
S3method(st_simplify,sfnetwork)
@@ -57,74 +67,140 @@ S3method(st_transform,sfnetwork)
S3method(st_wrap_dateline,sfnetwork)
S3method(st_z_range,sfnetwork)
S3method(st_zm,sfnetwork)
+S3method(unfocus,sfnetwork)
S3method(unmorph,morphed_sfnetwork)
export("%>%")
export(activate)
export(active)
export(as_sfnetwork)
+export(bind_spatial_edges)
+export(bind_spatial_nodes)
+export(centrality_straightness)
+export(contract_nodes)
+export(convert)
+export(create_from_spatial_lines)
+export(create_from_spatial_points)
+export(crystallise)
+export(crystallize)
+export(dodgr_to_sfnetwork)
+export(dual_weights)
export(edge_azimuth)
export(edge_circuity)
export(edge_contains)
export(edge_contains_properly)
export(edge_covers)
export(edge_crosses)
+export(edge_data)
export(edge_displacement)
export(edge_equals)
+export(edge_ids)
export(edge_intersects)
export(edge_is_covered_by)
export(edge_is_disjoint)
+export(edge_is_nearest)
export(edge_is_within)
export(edge_is_within_distance)
export(edge_length)
export(edge_overlaps)
+export(edge_segment_count)
export(edge_touches)
+export(evaluate_edge_query)
+export(evaluate_node_query)
+export(evaluate_weight_spec)
+export(group_spatial_dbscan)
export(is.sfnetwork)
+export(is_sfnetwork)
+export(make_edges_directed)
+export(make_edges_explicit)
+export(make_edges_follow_indices)
+export(make_edges_implicit)
+export(make_edges_mixed)
+export(make_edges_valid)
+export(morph)
+export(n_edges)
+export(n_nodes)
+export(nb_to_sfnetwork)
+export(nearest_edge_ids)
+export(nearest_edges)
+export(nearest_node_ids)
+export(nearest_nodes)
export(node_M)
export(node_X)
export(node_Y)
export(node_Z)
+export(node_data)
export(node_equals)
+export(node_ids)
export(node_intersects)
export(node_is_covered_by)
+export(node_is_dangling)
export(node_is_disjoint)
+export(node_is_nearest)
+export(node_is_pseudo)
export(node_is_within)
export(node_is_within_distance)
export(node_touches)
+export(play_geometric)
export(sf_attr)
export(sfnetwork)
+export(sfnetwork_to_dodgr)
+export(sfnetwork_to_nb)
+export(simplify_network)
+export(smooth_pseudo_nodes)
+export(st_duplicated)
+export(st_match)
export(st_network_bbox)
export(st_network_blend)
export(st_network_cost)
+export(st_network_distance)
+export(st_network_faces)
+export(st_network_iso)
export(st_network_join)
export(st_network_paths)
+export(st_network_travel)
+export(st_project_on_network)
+export(st_round)
+export(subdivide_edges)
export(to_spatial_contracted)
export(to_spatial_directed)
export(to_spatial_explicit)
+export(to_spatial_implicit)
+export(to_spatial_mixed)
export(to_spatial_neighborhood)
+export(to_spatial_reversed)
export(to_spatial_shortest_paths)
export(to_spatial_simple)
export(to_spatial_smooth)
export(to_spatial_subdivision)
export(to_spatial_subset)
export(to_spatial_transformed)
-importFrom(crayon,silver)
+export(to_spatial_unique)
+export(unmorph)
+export(validate_network)
+export(with_graph)
+export(wrap_igraph)
+importFrom(cli,cli_abort)
+importFrom(cli,cli_alert)
+importFrom(cli,cli_alert_success)
+importFrom(cli,cli_warn)
importFrom(dplyr,across)
+importFrom(dplyr,arrange)
importFrom(dplyr,bind_rows)
-importFrom(dplyr,full_join)
+importFrom(dplyr,distinct)
importFrom(dplyr,group_by)
importFrom(dplyr,group_indices)
-importFrom(dplyr,group_size)
-importFrom(dplyr,group_split)
+importFrom(dplyr,join_by)
+importFrom(dplyr,left_join)
importFrom(dplyr,mutate)
+importFrom(dplyr,slice)
importFrom(graphics,plot)
importFrom(igraph,"edge_attr<-")
importFrom(igraph,"graph_attr<-")
importFrom(igraph,"vertex_attr<-")
-importFrom(igraph,E)
-importFrom(igraph,V)
importFrom(igraph,adjacent_vertices)
importFrom(igraph,all_shortest_paths)
-importFrom(igraph,all_simple_paths)
+importFrom(igraph,as_adj_list)
+importFrom(igraph,as_edgelist)
importFrom(igraph,contract)
importFrom(igraph,count_components)
importFrom(igraph,decompose)
@@ -135,13 +211,13 @@ importFrom(igraph,delete_vertex_attr)
importFrom(igraph,delete_vertices)
importFrom(igraph,distances)
importFrom(igraph,ecount)
-importFrom(igraph,edge.attributes)
importFrom(igraph,edge_attr)
importFrom(igraph,edge_attr_names)
importFrom(igraph,ends)
-importFrom(igraph,get.edge.ids)
+importFrom(igraph,get_edge_ids)
importFrom(igraph,gorder)
importFrom(igraph,graph_attr)
+importFrom(igraph,graph_from_adjacency_matrix)
importFrom(igraph,gsize)
importFrom(igraph,igraph_opt)
importFrom(igraph,igraph_options)
@@ -152,6 +228,9 @@ importFrom(igraph,is_connected)
importFrom(igraph,is_dag)
importFrom(igraph,is_directed)
importFrom(igraph,is_simple)
+importFrom(igraph,k_shortest_paths)
+importFrom(igraph,mst)
+importFrom(igraph,reverse_edges)
importFrom(igraph,shortest_paths)
importFrom(igraph,simplify)
importFrom(igraph,vcount)
@@ -159,9 +238,22 @@ importFrom(igraph,vertex_attr)
importFrom(igraph,vertex_attr_names)
importFrom(igraph,which_loop)
importFrom(igraph,which_multiple)
+importFrom(lifecycle,deprecate_stop)
+importFrom(lifecycle,deprecate_warn)
+importFrom(lifecycle,deprecated)
+importFrom(lifecycle,is_present)
importFrom(lwgeom,st_geod_azimuth)
-importFrom(rlang,"!!")
-importFrom(rlang,":=")
+importFrom(lwgeom,st_split)
+importFrom(methods,hasArg)
+importFrom(pillar,style_subtle)
+importFrom(rlang,"%||%")
+importFrom(rlang,check_installed)
+importFrom(rlang,dots_n)
+importFrom(rlang,enquo)
+importFrom(rlang,eval_tidy)
+importFrom(rlang,expr)
+importFrom(rlang,is_installed)
+importFrom(rlang,try_fetch)
importFrom(sf,"st_agr<-")
importFrom(sf,"st_crs<-")
importFrom(sf,"st_geometry<-")
@@ -169,16 +261,20 @@ importFrom(sf,"st_precision<-")
importFrom(sf,sf_use_s2)
importFrom(sf,st_agr)
importFrom(sf,st_area)
+importFrom(sf,st_as_binary)
importFrom(sf,st_as_s2)
importFrom(sf,st_as_sf)
importFrom(sf,st_as_sfc)
importFrom(sf,st_bbox)
+importFrom(sf,st_buffer)
importFrom(sf,st_cast)
importFrom(sf,st_centroid)
importFrom(sf,st_collection_extract)
importFrom(sf,st_combine)
+importFrom(sf,st_concave_hull)
importFrom(sf,st_contains)
importFrom(sf,st_contains_properly)
+importFrom(sf,st_convex_hull)
importFrom(sf,st_coordinates)
importFrom(sf,st_covered_by)
importFrom(sf,st_covers)
@@ -201,14 +297,18 @@ importFrom(sf,st_is_within_distance)
importFrom(sf,st_join)
importFrom(sf,st_length)
importFrom(sf,st_line_merge)
+importFrom(sf,st_linestring)
importFrom(sf,st_m_range)
importFrom(sf,st_nearest_feature)
importFrom(sf,st_nearest_points)
importFrom(sf,st_normalize)
importFrom(sf,st_overlaps)
+importFrom(sf,st_point)
importFrom(sf,st_precision)
importFrom(sf,st_reverse)
importFrom(sf,st_sample)
+importFrom(sf,st_segmentize)
+importFrom(sf,st_set_geometry)
importFrom(sf,st_set_precision)
importFrom(sf,st_sf)
importFrom(sf,st_sfc)
@@ -221,34 +321,41 @@ importFrom(sf,st_wrap_dateline)
importFrom(sf,st_z_range)
importFrom(sf,st_zm)
importFrom(sfheaders,sf_to_df)
+importFrom(sfheaders,sfc_cast)
importFrom(sfheaders,sfc_linestring)
importFrom(sfheaders,sfc_point)
importFrom(sfheaders,sfc_to_df)
+importFrom(stats,as.dist)
importFrom(stats,median)
importFrom(tibble,as_tibble)
-importFrom(tibble,trunc_mat)
+importFrom(tibble,tibble)
importFrom(tidygraph,"%>%")
+importFrom(tidygraph,.E)
importFrom(tidygraph,.G)
+importFrom(tidygraph,.N)
importFrom(tidygraph,.graph_context)
+importFrom(tidygraph,.register_graph_context)
importFrom(tidygraph,activate)
importFrom(tidygraph,active)
importFrom(tidygraph,as_tbl_graph)
+importFrom(tidygraph,bind_edges)
+importFrom(tidygraph,bind_nodes)
+importFrom(tidygraph,convert)
+importFrom(tidygraph,crystallise)
+importFrom(tidygraph,crystallize)
importFrom(tidygraph,graph_join)
importFrom(tidygraph,morph)
-importFrom(tidygraph,mutate)
-importFrom(tidygraph,node_distance_from)
-importFrom(tidygraph,node_distance_to)
+importFrom(tidygraph,play_geometry)
importFrom(tidygraph,reroute)
importFrom(tidygraph,tbl_graph)
+importFrom(tidygraph,unfocus)
importFrom(tidygraph,unmorph)
importFrom(tidygraph,with_graph)
-importFrom(tools,toTitleCase)
+importFrom(tidyselect,eval_select)
importFrom(units,as_units)
importFrom(units,deparse_unit)
importFrom(units,drop_units)
importFrom(units,set_units)
importFrom(utils,capture.output)
importFrom(utils,head)
-importFrom(utils,modifyList)
-importFrom(utils,packageVersion)
importFrom(utils,tail)
diff --git a/NEWS.md b/NEWS.md
index b93fc072..5227cc50 100644
--- a/NEWS.md
+++ b/NEWS.md
@@ -1,3 +1,135 @@
+# sfnetworks v1.0.0 "Itzling" (in progress)
+
+### Network creation
+
+- Creating networks directly from linestring geometries is now implemented in the new function `create_from_spatial_lines()`. The `as_sfnetwork()` method for `sf` objects will call this function when geometries are linestrings, and forward the `...` arguments to it. This now also allows to already subdivide the edges at locations where interior points are shared, setting `subdivide = TRUE`.
+- Creating networks directly from point geometries is now implemented in the new function `create_from_spatial_points()`. The `as_sfnetwork()` method for `sf` objects will call this function when geometries are points, and forward the `...` arguments to it.
+- There are now many more options to create spatial networks directly from spatial point data. How nodes should be connected can be specified by providing a logical adjacency matrix in addition to the point data. This adjacency matrix can also be sparse, e.g. the output of a spatial predicate function in `{sf}`. Furthermore, `{sfnetworks}` can create the adjacency matrix for you according to a specified method. In that case, you only need to specify the name of the method. Supported options are: a complete graph, a sequence, a minimum spanning tree, a delaunay triangulation, a Gabriel graph, a relative nearest neighbor graph, an a k nearest neighbor graph. See [here](https://luukvdmeer.github.io/sfnetworks/articles/sfn02_create_represent.html#from-spatial-points) for a detailed explanation with examples.
+- The new function `play_geometric()` can create random geometric networks.
+- There is a new method for `as_sfnetwork()` to create `dodgr_streetnet` objects from the {dodgr} package directly into a `sfnetwork`. This internally calls `dodgr_to_sfnetwork()`. For the conversion in the other direction, use `sfnetwork_to_dodgr()`.
+- It is now also possible to convert between `sfnetwork` objects and neighbor lists, using the new functions `nb_to_sfnetwork()` and `sfnetwork_to_nb()`. Neighbor lists are sparse adjacency matrices that can be found e.g. in the `{spdep}` package and the `{sf}` package (as the output of spatial predicate functions).
+- Since the interpretation of the weights argument when `weights = NULL` changed (see below), the argument `length_as_weight` of the `sfnetwork()` construction function has been deprecated. Instead, you can now set `compute_length = TRUE` to store edge lengths in a attribute named *length*. However, this attribute will not anymore automatically be recognized as edge weights in routing functions.
+
+### Routing
+
+- As mentioned above, the interpretation of the setting `weights = NULL` has changed for all routing functions. Before, an edge attribute named *weight* would be automatically recognized as edge weights, just like in `{igraph}`. Although convenient, it proved to be very confusing since in `{tidygraph}`, the setting `weights = NULL` does not have the same meaning. There is always means that no edge weights are used, no matter if a *weight* attribute is present. We now decided that (since we are primarily integrating `{tidygraph}` and `{sf}`) to follow the `{tidygraph}` design choice, meaning that `weights = NULL` always means that no edge weights are used. However, in the routing functions of `{sfnetworks}` the default is no longer `weights = NULL`, but `weights = edge_length()`. Hence, geographic length is the default edge weight in all routing functions of `{sfnetworks}`
+- The way in which edge weights can be specified in routing functions has been updated to better fit in tidy data analysis workflows. You can now directly provide edge measure functions as value to the `weight` argument. This also allows to provide custom edge measure functions, for example ones that are time-dependent. Furthermore, to reference a column in the edges table, you can now do this using tidy evaluation, i.e. unquoted column names rather than quoted ones. For dual weighted routing, the new `dual_weights()` function can be used. See [here](https://luukvdmeer.github.io/sfnetworks/articles/sfn05_routing#specifying-edge-weights) for a full overview of possible specification formats.
+- The way in which *from* and *to* nodes in routing functions can be specified has been updated to better fit in tidy data analysis workflows. You can now directly provide node query or node measure functions as value to the `from` and `to` arguments. Furthermore, to reference a column in the nodes table, you can now do this using tidy evaluation, i.e. unquoted column names rather than quoted ones. See [here](https://luukvdmeer.github.io/sfnetworks/articles/sfn05_routing#specifying-origins-and-destinations) for a full overview of possible specification formats.
+- It is now possible to choose between different "routing backends" through the `router` argument in all routing functions. The default routing backend is *igraph*, meaning that routing functions from the `{igraph}` package will be called internally. The second supported routing backend now is *dodgr*, which will call routing functions from the `{dodgr}` package instead. All conversion happens internally, such that as a user you can use the same functions and arguments independent from which routing engine you choose. See [here](https://luukvdmeer.github.io/sfnetworks/articles/sfn05_routing#choosing-a-routing-backend) for more details.
+- The output returned by `st_network_paths()` is restructured. Instead of `tbl_df`, the function will now return a `sf` object, with the course of each path stored as a linestring geometry. It will also return the total cost of each path in a column named *cost*. Columns *node_paths* and *edge_paths* are renamed to *node_path* and *edge_path*, respectively. The boolean column *path_found* specifies if the requested path was found. If not, the path will be assigned an infinite cost and empty geometry. New boolean arguments `return_cost` and `return_geometry` can be set to `FALSE` if you do not want the cost and/or geometry columns to be returned.
+- The `type` argument of `st_network_paths()` is deprecated. To compute *all* shortest paths instead of a single shortest path, set `all = TRUE` instead. Support for computing all simple paths is dropped.
+- The `st_network_paths()` function now supports one-to-one k shortest paths routing. This is implemented through the new `k` argument, which you can set to an integer higher than 1.
+- The `use_names` argument of `st_network_paths()` now has a default value of `FALSE`. This means that even if the nodes have a *name* column, they will be encoded by their integer indices in the output object.
+- The `use_names` argument is now also added to `st_network_cost()`, letting you specify if you want node names to be used for column and rownames in the returned matrix. Also here, it defaults to `FALSE`.
+- The new function `st_network_distance()` is added as a synonym for `st_network_cost()` where the edge weights are fixed to be geographic distance. This is done to provide an intuitive network-specific alternative to `sf::st_distance()`.
+- The new function `st_network_travel()` now provides an interface to the `TSP` package to solve traveling salesman problems. This requires `TSP` to be installed. See [here](https://luukvdmeer.github.io/sfnetworks/articles/sfn05_routing#traveling-salesman-problem) for an example.
+- The new function `st_network_iso()` now implements the computation of isodistance/isochrone polygons around a given source node. It first computes the neighborhood of the node, and then draws a concave hull around it. See [here](https://luukvdmeer.github.io/sfnetworks/articles/sfn05_routing#isodistance-polygons) for an example. For concave hulls that are more detailed than the convex hull, i.e. with a ratio smaller than 1, GEOS >= 3.11 is required.
+
+### Morphers
+- The new morpher `to_spatial_unique()` allows to contract nodes at equal spatial locations, while specifying how their attributes should be combined.
+- The new morpher `to_spatial_mixed()` allows to mimic a mixed network representation (i.e. a network with both directed and undirected edges) by duplicating and reversing those edges that should be undirected.
+- The new morpher `to_spatial_reversed()` reverses edges, including their linestring geometries. Selected edges can be protected from reversion using the `protect` argument.
+- The new morpher `to_spatial_implicit()` drops edge geometries.
+- The `summarise_attributes` argument that appears in several morphers is renamed to `attribute_summary`, to avoid differences between UK and US spelling. For now, `summarise_attributes` will be automatically converted to `attribute_summary`, while giving a soft deprecation warning.
+- The `to_spatial_subdivision()` morpher now has the argument `all`. If set to `TRUE`, edges will be subdivided at each interior point (i.e. creating one edge per segment), instead of only at interior points that are shared between multiple edges.
+- The `to_spatial_subdivision()` morpher now has the argument `merge_equal`. If set to `TRUE`, edges will only be subvidived, but subdivision points that are shared between multiple edges will not be merged into a single node.
+- The `to_spatial_subdivision()` moprher now has the argument `protect`, which allows to protect specified edges from being subdivided. The edges to be protected can be specified in several ways, e.g. by their integer index, by using edge query functions, or by referencing a column using tidy evaluation.
+- The `protect` argument of the `to_spatial_smooth()` morpher is now updated to fit better in tidy data analysis workflows. Nodes to be protected from being smoothed can be specified in the same way as origins and destination nodes in routing functions, see above.
+- The `require_equal` argument of the `to_spatial_smooth()` morpher is now updated to fit better in tidy data analysis workflows. Attributes to check for equality can now be specified using tidy selection. This means you can also use tidy selection helpers from `{dplyr}`.
+- The `to_spatial_contracted()` morpher now has the argument `compute_centroid`. If set to `FALSE`, contracted groups of nodes will not have their centroid as new geometry, but simply the geometry of the first node in the group. This can improve performance significantly on large networks.
+- The `simplify` argument of the `to_spatial_contracted()` morpher now has `TRUE` as the default value. This means that by default the contracted network will be simplified.
+- The `to_spatial_shortest_paths()` morpher now automatically orders the nodes and edges in each returned network to match the order in which they are visited by the path.
+- The `from` argument of `to_spatial_neighborhood()` is renamed to `node`. For now, `from` will be automatically converted to `node`, while giving a soft deprecation warning.
+- The `to_spatial_neighborhood()` morpher now internally calls `st_network_cost()`, and forwards `...` arguments to it.
+- The `to_spatial_neighborhood()` morpher now accepts multiple threshold values, returning one network per specified threshold.
+- The internal workers of the morphers dedicated to network cleaning are now exported as well, to make it possibe to perform data cleaning outside of the tidygraph framework. These are `simplify_network()` for `to_spatial_simple()`, `subdivide_edges()` for `to_spatial_subdivision()`, `smooth_pseudo_nodes()` for `to_spatial_smooth()`, and contract nodes for `to_spatial_contracted()`.
+
+### Spatial grouping
+
+- The new function `group_spatial_dbscan()` provides a tidy interface to the `{dbscan}` package to group nodes spatially using the DBSCAN spatial clustering algorithm, based on network distances between nodes.
+
+### Blending
+
+- `st_network_blend()` now allows to blend points that have the same projected location on the network, by setting `ignore_duplicates = FALSE`. All but the first one of those will be added as isolated nodes, which can then be merged using the new morpher `to_spatial_unique()`.
+
+### Node specific functions
+
+- The new centrality function `centrality_straightness()` allows to compute the straightness centrality of nodes.
+- The new node query function `node_is_pseudo()` and `node_is_dangling()` allow to easily query pseudo (nodes with one incoming and one outgoing edges) and dangling (nodes with a degree centrality of 1) nodes.
+- The new node predicate function `node_is_nearest()` defines if a node is the nearest node to any feature in a given set of spatial features.
+
+### Edge specific functions
+
+- The new edge measure function `edge_segment_count()` returns the number of segments in each edge.
+- The new edge predicate function `edge_is_nearest()` defines if a edge is the nearest edge to any feature in a given set of spatial features.
+- Several internal functions to modify edge geometries are now exported:
+ - `make_edges_valid()` makes edge geometries fit in the spatial network structure by either replacing their endpoints with the nodes that are referenced in the *from* and *to* columns (if `preserve_geometries = FALSE`), or by adding unmatched endpoints as new nodes to the network and updating the *from* and *to* columns (if `preserve_geometries = TRUE`).
+ - `make_edges_directed()` turns a undirected network into a directed network by updating the *from* and *to* columns according to the direction given by the linestring geometries. This is the internal worker of the morpher `to_spatial_directed()`.
+ - `make_edges_mixed()` duplicates and reverses edges in a directed network that should be undirected. This is the internal worker of the morpher `to_spatial_mixed()`.
+ - `make_edges_explicit()` adds a geometry column to spatially implicit edges. This is the internal worker of the morpher `to_spatial_explicit()`.
+ - `make_edges_implicit()` drops the geometry column of spatially explicit edges. This is the internal worker of the morpher `to_spatial_implicit()`.
+ - `make_edges_follow_indices()` updates edge geometries in undirected networks to match the node indices specified in the *from* and *to* columns, in case they are swapped.
+
+### Other new functions
+
+- The new function `st_network_faces()` allows to extract the faces of a spatial network as a `sf` object with polygons geometries.
+- The new function `st_project_on_network()` replaces geometries of `sf` objects with their projection on a spatial network.
+- The new functions `bind_spatial_nodes()` and `bind_spatial_edges()` allow to bind additional nodes or edges to the network. These are the spatial alternatives to `tidygraph::bind_nodes()` and `tidygraph::bind_edges()`, which cannot handle geometry list columns.
+
+### Methods for sf
+
+- There is now a `sfnetwork` method for `sf::st_segmentize()`, allowing you to add interior points to edge geometries at fixed intervals.
+- The `sfnetwork` methods for `sf::st_intersection()`, `sf::st_difference()`, and `sf::st_crop()` now also work as expected on undirected networks.
+- The `st_geometry<-` method for `sfnetwork` objects now allows to replace node geometries with any set of points, and edge geometries with any set of lines. Internally, the network structure will be kept valid by replacing endpoints of edge geometries (when replacing nodes), or by adding unmatched edge endpoints as new nodes to the network (when replacing edges).
+- The `sf::st_join()` method for `sfnetwork` objects now allows multiple matches for the same node. In these case, the node will be duplicated once per additional match, and duplicates are added as isolated nodes to the resulting network.
+
+### Upkeep with tidygraph
+
+- Functions in `{sfnetworks}` now work well with the new concept of *focused graphs*, as recently implemented in `{tidygraph}`. See [here](https://www.data-imaginist.com/posts/2023-12-18-a-new-focus-on-tidygraph/index.html#let-us-focus-on-the-news) for details.
+- There is now a `sfnetwork` method for `tidygraph::reroute()`. However, if you only want to reverse edges, we recommend to use the morpher `to_spatial_reversed()` instead, as `reroute` will only replace endpoints of edge geometries, and not reverse complete linestring geometries.
+- The tidygraph verbs `morph()`, `unmorph()`, `crystallize()`, and `convert()` are now re-exported by `{sfnetworks}`, such that it is not needed anymore to load `{tidygraph}` explicitly in order to use the spatial morphers. Furthermore, the utility function `tidygraph::with_graph()` is now re-exported.
+
+### Plotting
+
+- The `plot()` method for `sfnetwork` objects now allows different style settings for nodes and edges, using the new `node_args` and `edge_args` arguments.
+- The `plot()` method for `sfnetwork` objects now allows to plot multiple networks on top of each other.
+
+### Data extraction utilities
+
+- Several utility functions to extract data from a `sfnetwork` object are now exported:
+ - The functions `node_data()` and `edge_data()` extract the node and edge table, respectively. Nodes are always extracted as a `sf` object. Edges are extracted as `sf` object if they are spatially explicit, and as regular `tbl_df` if they are spatially implicit.
+ - The functions `node_ids()` and `edge_ids()` extract the indices of the nodes and edges, respectively. The indices correspond to rownumbers in the node and edge tables.
+ - The functions `nearest_nodes()` and `nearest_edges()` return respectively the nearest nodes and nearest edges to a set of spatial features.
+ - The functions `nearest_node_ids()` and `nearest_edge_ids()` return respectively the indices of the nearest nodes and nearest edges to a set of spatial features. The indices correspond to rownumbers in the node and edge tables.
+ - The functions `n_nodes()` and `n_edges()` return the respectively the number of nodes and edges in the network.
+
+### Other utilities
+
+- Added `is_sfnetwork()` as an alias of `is.sfnetwork()`.
+- The new function `validate_network()` allows to validate the spatial network structure of a `sfnetwork` object.
+- The new function `wrap_igraph()` allows to wrap any function from `{igraph}` that returns a network, and make it return a `sfnetwork` object instead of a `igraph` object.
+- The functions `st_duplicated()`, `st_match()` and `st_round()` are added as spatial variations to common base R functions, respectively for determining spatial duplicates, geometry matching, and coordinate rounding.
+
+### Other updates
+
+- When determining spatial equality of nodes, `{sfnetworks}` now by default uses a 12-digit precision. This gives a considerable performance improvement especially on large networks. Precision can be changed by explicitly setting coordinate precision using `sf::st_set_precision()`.
+- All messages, warnings and errors in `{sfnetworks}` are now raised using the `{cli}` and `{rlang}` packages.
+
+### Bug fixes
+
+- The print method now works correctly again after aligning with updates in `{tidygraph}`.
+- The morpher `to_spatial_contracted()` now correctly handles group indices that are not ordered.
+- The `plot()` method for `sfnetwork` objects now correctly plots networks with spatially implicit edges that are active.
+- `st_network_bbox()` now also computes bounding boxes for networks with spatially implicit edges.
+
+### Dependencies
+- The minimum required version for `{sf}` is now 1.0-11
+- The minimum required version for `{tidygraph}` is now 1.3.0
+- The minimum required version for `{igraph}` is now 2.1.0
+- The `{crayon}` package is not a dependency anymore.
+- Base R packages `{methods}` and `{stats}` are added as new dependencies.
+- Additional packages `{cli}`, `{lifecycle}`, `{pillar}` and `{tidyselect}` are added as new dependencies.
+
# sfnetworks v0.6.4
### New features
diff --git a/R/agr.R b/R/agr.R
deleted file mode 100644
index b11c1e8b..00000000
--- a/R/agr.R
+++ /dev/null
@@ -1,114 +0,0 @@
-#' Get or set the agr attribute of the active element of a sfnetwork
-#'
-#' @param x An object of class \code{\link{sfnetwork}}.
-#'
-#' @param value A named factor with appropriate levels. Names should
-#' correspond to the attribute columns of the targeted element of x. Attribute
-#' columns do not involve the geometry list column, but do involve the from and
-#' to columns.
-#'
-#' @param active Either 'nodes' or 'edges'. If \code{NULL}, the currently
-#' active element of x will be used.
-#'
-#' @return For the getter, a named agr factor. The setter only modifies x.
-#'
-#' @noRd
-agr = function(x, active = NULL) {
- if (is.null(active)) {
- active = attr(x, "active")
- }
- switch(
- active,
- nodes = node_agr(x),
- edges = edge_agr(x),
- raise_unknown_input(active)
- )
-}
-
-#' @name agr
-#' @importFrom igraph vertex_attr
-#' @noRd
-node_agr = function(x) {
- agr = attr(vertex_attr(x), "agr")
- make_agr_valid(agr, names = node_feature_attribute_names(x))
-}
-
-#' @name agr
-#' @importFrom igraph edge_attr
-#' @noRd
-edge_agr = function(x) {
- agr = attr(edge_attr(x), "agr")
- if (has_explicit_edges(x)) {
- agr = make_agr_valid(agr, names = edge_feature_attribute_names(x))
- }
- agr
-}
-
-#' @name agr
-#' @noRd
-`agr<-` = function(x, active = NULL, value) {
- if (is.null(active)) {
- active = attr(x, "active")
- }
- switch(
- active,
- nodes = `node_agr<-`(x, value),
- edges = `edge_agr<-`(x, value),
- raise_unknown_input(active)
- )
-}
-
-#' @name agr
-#' @importFrom igraph vertex_attr<-
-#' @noRd
-`node_agr<-` = function(x, value) {
- attr(vertex_attr(x), "agr") = value
- x
-}
-
-#' @name agr
-#' @importFrom igraph edge_attr<-
-#' @noRd
-`edge_agr<-` = function(x, value) {
- attr(edge_attr(x), "agr") = value
- x
-}
-
-#' Create an empty agr factor
-#'
-#' @param names A character vector containing the names that should be present
-#' in the agr factor.
-#'
-#' @return A named factor with appropriate levels. Values are all equal to
-#' \code{\link[sf]{NA_agr_}}. Names correspond to the attribute columns of the
-#' targeted element of x. Attribute columns do not involve the geometry list
-#' column, but do involve the from and to columns.
-#'
-#' @noRd
-empty_agr = function(names) {
- structure(rep(sf::NA_agr_, length(names)), names = names)
-}
-
-#' Make an agr factor valid
-#'
-#' @param agr The agr factor to be made valid.
-#'
-#' @param names A character vector containing the names that should be present
-#' in the agr factor.
-#'
-#' @return A named factor with appropriate levels. Names are guaranteed to
-#' correspond to the attribute columns of the targeted element of x and are
-#' guaranteed to be sorted in the same order as those attribute columns.
-#' Attribute columns do not involve the geometry list column, but do involve
-#' the from and to columns.
-#'
-#' @noRd
-make_agr_valid = function(agr, names) {
- levels = c("constant", "aggregate", "identity")
- if (is.null(agr)) {
- valid_agr = empty_agr(names)
- } else {
- valid_agr = structure(agr[names], names = names, levels = levels)
- }
- valid_agr
-}
diff --git a/R/attrs.R b/R/attrs.R
index 3d0518ec..bdc63ad9 100644
--- a/R/attrs.R
+++ b/R/attrs.R
@@ -2,18 +2,15 @@
#'
#' @param x An object of class \code{\link{sfnetwork}}.
#'
-#' @param name Name of the attribute to query. Either \code{'sf_column'} or
-#' \code{'agr'}.
+#' @param name Name of the attribute to query. Either \code{'sf_column'} to
+#' extract the name of the geometry list column, or \code{'agr'} to extract the
+#' specification of attribute-geometry relationships.
#'
#' @param active Which network element (i.e. nodes or edges) to activate before
#' extracting. If \code{NULL}, it will be set to the current active element of
#' the given network. Defaults to \code{NULL}.
#'
-#' @return The value of the attribute matched, or \code{NULL} if no exact
-#' match is found.
-#'
-#' @details sf attributes include \code{sf_column} (the name of the sf column)
-#' and \code{agr} (the attribute-geometry-relationships).
+#' @return The value of the queried attribute.
#'
#' @examples
#' net = as_sfnetwork(roxel)
@@ -26,276 +23,203 @@ sf_attr = function(x, name, active = NULL) {
name,
agr = agr(x, active),
sf_column = geom_colname(x, active),
- raise_unknown_input(name)
+ raise_unknown_input("name", name, c("agr", "sf_column"))
)
}
-#' Preserve the attributes of the original network and its elements
-#'
-#' @param new An object of class \code{\link{sfnetwork}}.
-#'
-#' @param orig An object of class \code{\link{sfnetwork}}.
-#'
-#' @details All attributes include the network attributes *and* the sf specific
-#' attributes of its element objects (i.e. the nodes and edges tables).
-#'
-#' The network attributes always contain the class of the network and the name
-#' of the active element. Users can also add their own attributes to the
-#' network.
-#'
-#' The sf specific element attributes contain the name of the geometry list
-#' column and the agr factor of the element. In a spatially implicit network
-#' these attributes will be \code{NULL} for the edges table. Note that we talk
-#' about the attributes of the element *objects*. Hence, attributes attached to
-#' the table that stores the elements data. This is *not* the same as the
-#' attribute columns *in* the element table.
-#'
-#' @importFrom igraph graph_attr graph_attr<-
-#' @noRd
-`%preserve_all_attrs%` = function(new, orig) {
- graph_attr(new) = graph_attr(orig)
- attributes(new) = attributes(orig)
- node_geom_colname(new) = node_geom_colname(orig)
- node_agr(new) = node_agr(orig)
- edge_geom_colname(new) = edge_geom_colname(orig)
- edge_agr(new) = edge_agr(orig)
- new
-}
-
-#' Preserve the attributes of the original network
-#'
-#' @param new An object of class \code{\link{sfnetwork}}.
-#'
-#' @param orig An object of class \code{\link{sfnetwork}}.
-#'
-#' @details The network attributes are the attributes directly attached to
-#' the network object as a whole. Hence, this does *not* include attributes
-#' belonging to the element objects (i.e. the nodes and the edges tables). The
-#' network attributes always contain the class of the network and the name of
-#' the active element. Users can also add their own attributes to the network.
-#'
-#' @importFrom igraph graph_attr graph_attr<-
-#' @noRd
-`%preserve_network_attrs%` = function(new, orig) {
- graph_attr(new) = graph_attr(orig)
- attributes(new) = attributes(orig)
- new
-}
-
-#' Preserve the sf specific attributes of the nodes and edges tables
-#'
-#' @param new An object of class \code{\link{sfnetwork}}.
-#'
-#' @param orig An object of class \code{\link{sfnetwork}}.
-#'
-#' @details The sf specific attributes of the network elements (i.e. the nodes
-#' and edges tables) contain the name of the geometry list column and the agr
-#' factor of the element. In a spatially implicit network these attributes will
-#' be \code{NULL} for the edges table. Note that we talk about the attributes
-#' of the element *objects*. Hence, attributes attached to the table that
-#' stores the elements data. This is *not* the same as the attribute columns
-#' *in* the element table.
-#'
-#' @noRd
-`%preserve_sf_attrs%` = function(new, orig) {
- node_geom_colname(new) = node_geom_colname(orig)
- node_agr(new) = node_agr(orig)
- edge_geom_colname(new) = edge_geom_colname(orig)
- edge_agr(new) = edge_agr(orig)
- new
-}
-
-#' Get attribute column names from the active element of a sfnetwork
+#' Get or set the agr attribute of the active element of a sfnetwork
#'
#' @param x An object of class \code{\link{sfnetwork}}.
#'
+#' @param value A named factor with appropriate levels. Names should
+#' correspond to the attribute columns of the targeted element of x. Attribute
+#' columns do not involve the geometry list column, but do involve the from and
+#' to columns.
+#'
#' @param active Either 'nodes' or 'edges'. If \code{NULL}, the currently
#' active element of x will be used.
#'
-#' @return A character vector.
-#'
-#' @details Which columns in the nodes or edges table of the network are
-#' considered attribute columns can be different depending on our perspective.
-#'
-#' From the graph-centric point of view, the geometry is considered an
-#' attribute of a node or edge. Edges are defined by the nodes they connect,
-#' and hence the from and to columns in the edges table define the edges,
-#' rather than being attributes of them. Therefore, the function
-#' \code{attribute_names} will return a vector of names that includes the name
-#' of the geometry column, but - when \code{active = "edges"} - not the names
-#' of the to and from columns.
-#'
-#' However, when we take a geometry-centric point of view, the geometries are
-#' spatial features that contain attributes. Such a feature is defined by its
-#' geometry, and hence the geometry list-column is not considered an attribute
-#' column. The indices of the start and end nodes, however, are considered
-#' attributes of the edge linestring features. Therefore, the function
-#' \code{feature_attribute_names} will return a vector of names that does not
-#' include the name of the geometry column, but - when \code{active = "edges"}
-#' - does include the names of the to and from columns.
-#'
-#' @name attr_names
+#' @return For the getter, a named agr factor. The setter only modifies x.
+#'
#' @noRd
-attribute_names = function(x, active = NULL) {
+agr = function(x, active = NULL) {
if (is.null(active)) {
active = attr(x, "active")
}
switch(
active,
- nodes = node_attribute_names(x),
- edges = edge_attribute_names(x),
- raise_unknown_input(active)
+ nodes = node_agr(x),
+ edges = edge_agr(x),
+ raise_invalid_active(active)
)
}
-#' @name attr_names
+#' @name agr
+#' @importFrom igraph vertex_attr
#' @noRd
-#' @importFrom igraph vertex_attr_names
-node_attribute_names = function(x) {
- vertex_attr_names(x)
+node_agr = function(x) {
+ agr = attr(vertex_attr(x), "agr")
+ colnames = node_colnames(x, geom = FALSE)
+ make_agr_valid(agr, names = colnames)
}
-#' @name attr_names
+#' @name agr
+#' @importFrom igraph edge_attr
#' @noRd
-#' @importFrom igraph edge_attr_names
-edge_attribute_names = function(x) {
- edge_attr_names(x)
+edge_agr = function(x) {
+ agr = attr(edge_attr(x), "agr")
+ colnames = edge_colnames(x, idxs = TRUE, geom = FALSE)
+ make_agr_valid(agr, names = colnames)
}
-#' @name attr_names
+#' @name agr
#' @noRd
-feature_attribute_names = function(x, active = NULL) {
+`agr<-` = function(x, active = NULL, value) {
if (is.null(active)) {
active = attr(x, "active")
}
switch(
active,
- nodes = node_feature_attribute_names(x),
- edges = edge_feature_attribute_names(x),
- raise_unknown_input(active)
+ nodes = `node_agr<-`(x, value),
+ edges = `edge_agr<-`(x, value),
+ raise_invalid_active(active)
)
}
-#' @name attr_names
+#' @name agr
+#' @importFrom igraph vertex_attr<-
#' @noRd
-node_feature_attribute_names = function(x) {
- g_attrs = node_attribute_names(x)
- g_attrs[g_attrs != node_geom_colname(x)]
+`node_agr<-` = function(x, value) {
+ attr(vertex_attr(x), "agr") = value
+ x
}
-#' @name attr_names
+#' @name agr
+#' @importFrom igraph edge_attr<-
#' @noRd
-edge_feature_attribute_names = function(x) {
- g_attrs = edge_attribute_names(x)
- geom_colname = edge_geom_colname(x)
- if (is.null(geom_colname)) {
- character(0)
- } else {
- c("from", "to", g_attrs[g_attrs != geom_colname])
- }
+`edge_agr<-` = function(x, value) {
+ attr(edge_attr(x), "agr") = value
+ x
+}
+
+update_node_agr = function(x) {
+ node_agr(x) = node_agr(x)
}
-#' Set or replace attribute column values of the active element of a sfnetwork
+update_edge_agr = function(x) {
+ edge_agr(x) = edge_agr(x)
+}
+
+#' Create an empty agr factor
#'
-#' @param x An object of class \code{\link{sfnetwork}}.
+#' @param names A character vector containing the names that should be present
+#' in the agr factor.
#'
-#' @param active Either 'nodes' or 'edges'. If \code{NULL}, the currently
-#' active element of x will be used.
+#' @return A named factor with appropriate levels. Values are all equal to
+#' \code{\link[sf]{NA_agr_}}. Names correspond to the attribute columns of the
+#' targeted element of x. Attribute columns do not involve the geometry list
+#' column, but do involve the from and to columns.
+#'
+#' @noRd
+empty_agr = function(names) {
+ structure(rep(sf::NA_agr_, length(names)), names = names)
+}
+
+#' Make an agr factor valid
#'
-#' @param value A table in which each column is an attribute to be set. If the
-#' nodes are active, this table has to be of class \code{\link[sf]{sf}}. For
-#' the edges, it can also be a \code{data.frame} or
-#' \code{\link[tibble]{tibble}}.
+#' @param agr The agr factor to be made valid.
#'
-#' @return An object of class \code{\link{sfnetwork}} with updated attributes.
+#' @param names A character vector containing the names that should be present
+#' in the agr factor.
#'
-#' @details From the network-centric point of view, the geometry is considered
-#' an attribute of a node or edge, and the indices of the start and end nodes
-#' of an edge are not considered attributes of that edge.
+#' @return A named factor with appropriate levels. Names are guaranteed to
+#' correspond to the attribute columns of the targeted element of x and are
+#' guaranteed to be sorted in the same order as those attribute columns.
+#' Attribute columns do not involve the geometry list column, but do involve
+#' the from and to columns.
#'
-#' @name attr_values
#' @noRd
-`attribute_values<-` = function(x, active = NULL, value) {
- if (is.null(active)) {
- active = attr(x, "active")
+make_agr_valid = function(agr, names) {
+ levels = c("constant", "aggregate", "identity")
+ if (is.null(agr)) {
+ valid_agr = empty_agr(names)
+ } else {
+ valid_agr = structure(agr[names], names = names, levels = levels)
}
- switch(
- active,
- nodes = `node_attribute_values<-`(x, value),
- edges = `edge_attribute_values<-`(x, value),
- raise_unknown_input(active)
- )
+ valid_agr
}
-#' @name attr_values
+#' Preserve the attributes of the original network and its elements
+#'
+#' @param new An object of class \code{\link{sfnetwork}}.
+#'
+#' @param orig An object of class \code{\link{sfnetwork}}.
+#'
+#' @details All attributes include the network attributes and the sf specific
+#' attributes of its elements (i.e. the nodes and edges tables).
+#'
+#' The network attributes always contain the class of the network and the name
+#' of the active element. Users can also add their own attributes to the
+#' network.
+#'
+#' The sf specific element attributes contain the name of the geometry list
+#' column and the agr factor of the element. In a spatially implicit network
+#' these attributes will be \code{NULL} for the edges table. Note that we talk
+#' about the attributes of the element objects. Hence, attributes attached to
+#' the table that stores the elements data. This is not the same as the
+#' attribute columns in the element table.
+#'
+#' @importFrom igraph graph_attr graph_attr<-
#' @noRd
-#' @importFrom igraph vertex_attr<-
-`node_attribute_values<-` = function(x, value) {
- vertex_attr(x) = as.list(value)
- x
+`%preserve_all_attrs%` = function(new, orig) {
+ `%preserve_sf_attrs%`(`%preserve_network_attrs%`(new, orig), orig)
}
-#' @name attr_values
+#' Preserve the attributes of the original network
+#'
+#' @param new An object of class \code{\link{sfnetwork}}.
+#'
+#' @param orig An object of class \code{\link{sfnetwork}}.
+#'
+#' @details The network attributes are the attributes directly attached to
+#' the network object as a whole. Hence, this does not include attributes
+#' belonging to the element objects (i.e. the nodes and the edges tables). The
+#' network attributes always contain the class of the network and the name of
+#' the active element. Users can also add their own attributes to the network.
+#'
+#' @importFrom igraph graph_attr graph_attr<-
#' @noRd
-#' @importFrom igraph edge_attr<-
-`edge_attribute_values<-` = function(x, value) {
- edge_attr(x) = as.list(value[, !names(value) %in% c("from", "to")])
- x
+`%preserve_network_attrs%` = function(new, orig) {
+ graph_attr(new) = graph_attr(orig)
+ attributes(new) = attributes(orig)
+ new
}
-#' Get the specified summary function for an attribute column.
+#' Preserve the sf specific attributes of the nodes and edges tables
#'
-#' @param attr Name of the attribute.
+#' @param new An object of class \code{\link{sfnetwork}}.
#'
-#' @param spec Specification of the summary function belonging to each
-#' attribute.
+#' @param orig An object of class \code{\link{sfnetwork}}.
#'
-#' @return A function that takes a vector of attribute values as input and
-#' returns a single value.
+#' @details The sf specific attributes of the network elements (i.e. the nodes
+#' and edges tables) contain the name of the geometry list column and the agr
+#' factor of the element. In a spatially implicit network these attributes will
+#' be \code{NULL} for the edges table. Note that we talk about the attributes
+#' of the element objects. Hence, attributes attached to the table that
+#' stores the elements data. This is not the same as the attribute columns
+#' in the element table.
#'
#' @noRd
-get_summary_function = function(attr, spec) {
- if (!is.list(spec)) {
- func = spec
- } else {
- names = names(spec)
- if (is.null(names)) {
- func = spec[[1]]
- } else {
- func = spec[[attr]]
- if (is.null(func)) {
- default = which(names == "")
- if (length(default) > 0) {
- func = spec[[default[1]]]
- } else {
- func = "ignore"
- }
- }
- }
+`%preserve_sf_attrs%` = function(new, orig) {
+ node_geom_colname = node_geom_colname(orig)
+ if (! is.null(node_geom_colname)) {
+ node_geom_colname(new) = node_geom_colname
+ node_agr(new) = node_agr(orig)
}
- if (is.function(func)) {
- func
- } else {
- summariser(func)
+ edge_geom_colname = edge_geom_colname(orig)
+ if (! is.null(edge_geom_colname)) {
+ edge_geom_colname(new) = edge_geom_colname
+ edge_agr(new) = edge_agr(orig)
}
-}
-
-#' @importFrom stats median
-#' @importFrom utils head tail
-summariser = function(name) {
- switch(
- name,
- ignore = function(x) NA,
- sum = function(x) sum(x),
- prod = function(x) prod(x),
- min = function(x) min(x),
- max = function(x) max(x),
- random = function(x) sample(x, 1),
- first = function(x) head(x, 1),
- last = function(x) tail(x, 1),
- mean = function(x) mean(x),
- median = function(x) median(x),
- concat = function(x) c(x),
- raise_unknown_input(name)
- )
+ new
}
diff --git a/R/bbox.R b/R/bbox.R
index 7ec52746..4adaaee4 100644
--- a/R/bbox.R
+++ b/R/bbox.R
@@ -1,6 +1,6 @@
-#' Get the bounding box of a spatial network
+#' Compute the bounding box of a spatial network
#'
-#' A spatial network specific bounding box extractor, returning the combined
+#' A spatial network specific bounding box creator, returning the combined
#' bounding box of the nodes and edges in the network.
#'
#' @param x An object of class \code{\link{sfnetwork}}.
@@ -13,17 +13,21 @@
#' @details See \code{\link[sf]{st_bbox}} for details.
#'
#' @examples
-#' library(sf)
+#' library(sf, quietly = TRUE)
+#'
+#' oldpar = par(no.readonly = TRUE)
+#' par(mar = c(1,1,1,1), mfrow = c(1,2))
#'
#' # Create a network.
-#' node1 = st_point(c(8, 51))
-#' node2 = st_point(c(7, 51.5))
-#' node3 = st_point(c(8, 52))
-#' node4 = st_point(c(9, 51))
-#' edge1 = st_sfc(st_linestring(c(node1, node2, node3)))
-#'
-#' nodes = st_as_sf(c(st_sfc(node1), st_sfc(node3), st_sfc(node4)))
-#' edges = st_as_sf(edge1)
+#' n1 = st_point(c(8, 51))
+#' n2 = st_point(c(7, 51.5))
+#' n3 = st_point(c(8, 52))
+#' n4 = st_point(c(9, 51))
+#' e1 = st_sfc(st_linestring(c(n1, n2, n3)))
+#'
+#' nodes = st_as_sf(c(st_sfc(n1), st_sfc(n3), st_sfc(n4)))
+#'
+#' edges = st_as_sf(e1)
#' edges$from = 1
#' edges$to = 2
#'
@@ -38,13 +42,13 @@
#' net_bbox
#'
#' # Plot.
-#' oldpar = par(no.readonly = TRUE)
-#' par(mar = c(1,1,1,1), mfrow = c(1,2))
#' plot(net, lwd = 2, cex = 4, main = "Element bounding boxes")
-#' plot(st_as_sfc(node_bbox), border = "red", lty = 2, lwd = 4, add = TRUE)
-#' plot(st_as_sfc(edge_bbox), border = "blue", lty = 2, lwd = 4, add = TRUE)
+#' plot(st_as_sfc(node_bbox), border = "orange", lty = 2, lwd = 4, add = TRUE)
+#' plot(st_as_sfc(edge_bbox), border = "skyblue", lty = 2, lwd = 4, add = TRUE)
+#'
#' plot(net, lwd = 2, cex = 4, main = "Network bounding box")
-#' plot(st_as_sfc(net_bbox), border = "red", lty = 2, lwd = 4, add = TRUE)
+#' plot(st_as_sfc(net_bbox), border = "orange", lty = 2, lwd = 4, add = TRUE)
+#'
#' par(oldpar)
#'
#' @export
@@ -52,17 +56,30 @@ st_network_bbox = function(x, ...) {
UseMethod("st_network_bbox")
}
-#' @importFrom sf st_bbox st_geometry
+#' @importFrom sf st_bbox
#' @export
st_network_bbox.sfnetwork = function(x, ...) {
- # Extract bbox from nodes and edges.
- nodes_bbox = st_bbox(st_geometry(x, "nodes"), ...)
- edges_bbox = st_bbox(st_geometry(x, "edges"), ...)
- # Take most extreme coordinates to form the network bbox.
- x_bbox = nodes_bbox
- x_bbox["xmin"] = min(nodes_bbox["xmin"], edges_bbox["xmin"])
- x_bbox["ymin"] = min(nodes_bbox["ymin"], edges_bbox["ymin"])
- x_bbox["xmax"] = max(nodes_bbox["xmax"], edges_bbox["xmax"])
- x_bbox["ymax"] = max(nodes_bbox["ymax"], edges_bbox["ymax"])
- x_bbox
+ # If the network is spatially implicit:
+ # --> The network bbox is equal to the node bbox.
+ # If the network is spatially explicit:
+ # --> Get most extreme coordinates among node and edge bboxes.
+ nodes_bbox = st_bbox(pull_node_geom(x), ...)
+ if (has_explicit_edges(x)) {
+ edges_bbox = st_bbox(pull_edge_geom(x), ...)
+ net_bbox = merge_bboxes(nodes_bbox, edges_bbox)
+ } else {
+ net_bbox = nodes_bbox
+ }
+ net_bbox
+}
+
+#' @importFrom sf st_as_sfc st_bbox st_buffer st_crs st_distance st_point st_sfc
+extended_network_bbox = function(x, ratio = 0.1) {
+ crs = st_crs(x)
+ bbox = st_network_bbox(x)
+ lowleft = st_sfc(st_point(c(bbox["xmin"], bbox["ymin"])), crs = crs)
+ upright = st_sfc(st_point(c(bbox["xmax"], bbox["ymax"])), crs = crs)
+ diameter = st_distance(lowleft, upright)
+ buffer = st_buffer(st_as_sfc(bbox), dist = diameter * ratio)
+ st_bbox(buffer)
}
diff --git a/R/bind.R b/R/bind.R
new file mode 100644
index 00000000..b4de8425
--- /dev/null
+++ b/R/bind.R
@@ -0,0 +1,123 @@
+#' Add nodes or edges to a spatial network.
+#'
+#' These functions are the spatially aware versions of tidygraph's
+#' \code{\link[tidygraph]{bind_nodes}} and \code{\link[tidygraph]{bind_edges}}
+#' that allow you to add rows to the nodes or edges tables in a
+#' \code{\link{sfnetwork}} object. As with \code{\link[dplyr]{bind_rows}}
+#' columns are matched by name and filled with \code{NA} if the column does not
+#' exist in some instances.
+#'
+#' @param .data An object of class \code{\link{sfnetwork}}.
+#'
+#' @param ... One or more objects of class \code{\link[sf]{sf}} containing the
+#' nodes or edges to be added.
+#'
+#' @param node_key The name of the column in the nodes table that character
+#' represented \code{to} and \code{from} columns should be matched against. If
+#' \code{NA}, the first column is always chosen. This setting has no effect if
+#' \code{to} and \code{from} are given as integers. Defaults to \code{'name'}.
+#'
+#' @param force Should network validity checks be skipped? Defaults to
+#' \code{FALSE}, meaning that network validity checks are executed after binding
+#' edges, making sure that boundary points of edges match their corresponding
+#' node coordinates.
+#'
+#' @returns An object of class \code{\link{sfnetwork}} with added nodes or
+#' edges.
+#'
+#' @examples
+#' library(sf, quietly = TRUE)
+#' library(dplyr, quietly = TRUE)
+#'
+#' net = roxel |>
+#' slice(c(1:2)) |>
+#' st_transform(3035) |>
+#' as_sfnetwork()
+#'
+#' pts = roxel |>
+#' slice(c(3:4)) |>
+#' st_transform(3035) |>
+#' st_centroid()
+#'
+#' bind_spatial_nodes(net, pts)
+#'
+#' @name bind_spatial
+#' @importFrom cli cli_abort
+#' @importFrom sf st_drop_geometry st_geometry st_geometry<-
+#' @importFrom tidygraph activate bind_nodes
+#' @export
+bind_spatial_nodes = function(.data, ...) {
+ # Bind geometries
+ net_geom = list(pull_node_geom(.data))
+ add_geom = lapply(list(...), st_geometry)
+ new_geom = do.call("c", c(net_geom, add_geom))
+ # Validate if binded nodes are points.
+ if (! are_points(new_geom)) {
+ cli_abort("Not all nodes have geometry type {.cls POINT}")
+ }
+ # Bind other data.
+ net = drop_node_geom(.data)
+ add = lapply(list(...), st_drop_geometry)
+ new_net = bind_nodes(net, add)
+ # Add geometries back to the network.
+ active = attr(.data, "active")
+ if (active == "nodes") {
+ st_geometry(new_net) = new_geom
+ new_net
+ } else {
+ new_net = activate(new_net, "nodes")
+ st_geometry(new_net) = new_geom
+ activate(new_net, "edges")
+ }
+}
+
+#' @name bind_spatial
+#' @importFrom cli cli_abort
+#' @importFrom igraph is_directed
+#' @importFrom sf st_drop_geometry st_geometry
+#' @importFrom tidygraph bind_edges
+#' @export
+bind_spatial_edges = function(.data, ..., node_key = "name", force = FALSE) {
+ # If edges are not spatially explicit.
+ # We can simply use tidygraphs bind_edges function without any additions.
+ if (! has_explicit_edges(.data)) {
+ if (any(do.call("c", lapply(list(...), has_sfc)))) {
+ cli_abort(c(
+ "Can not bind spatially explicit edges to spatially implicit edges.",
+ "i" = "Use {.fn sfnetworks::to_spatial_explicit} to explicitize edges."
+ ))
+ }
+ return (bind_edges(.data, ..., node_key = node_key))
+ }
+ # Bind geometries.
+ net_geom = list(pull_edge_geom(.data))
+ add_geom = lapply(list(...), st_geometry)
+ new_geom = do.call("c", c(net_geom, add_geom))
+ # Validate if binded edges are lines.
+ if (! are_linestrings(new_geom)) {
+ cli_abort("Not all edges have geometry type {.cls LINESTRING}")
+ }
+ # Bind other data.
+ net = drop_edge_geom(.data)
+ add = lapply(list(...), st_drop_geometry)
+ new_net = bind_edges(net, add, node_key = node_key)
+ # Add geometries back to the network.
+ new_net = mutate_edge_geom(new_net, new_geom)
+ # Validate if binded edges meet the valid spatial network structure.
+ if (! force) {
+ if (is_directed(.data)) {
+ # Start point should equal start node.
+ # End point should equal end node.
+ if (! all(nodes_equal_edge_boundaries(.data))) {
+ cli_abort("Node locations do not match edge boundaries")
+ }
+ } else {
+ # Start point should equal either start or end node.
+ # End point should equal either start or end node.
+ if (! all(nodes_in_edge_boundaries(.data))) {
+ cli_abort("Node locations do not match edge boundaries")
+ }
+ }
+ }
+ new_net
+}
\ No newline at end of file
diff --git a/R/blend.R b/R/blend.R
index d2d70071..a937b7cc 100644
--- a/R/blend.R
+++ b/R/blend.R
@@ -1,11 +1,11 @@
-#' Blend geospatial points into a spatial network
+#' Blend spatial points into a spatial network
#'
-#' Blending a point into a network is the combined process of first snapping
-#' the given point to its nearest point on its nearest edge in the network,
-#' subsequently splitting that edge at the location of the snapped point, and
-#' finally adding the snapped point as node to the network. If the location
-#' of the snapped point is already a node in the network, the attributes of the
-#' point (if any) will be joined to that node.
+#' Blending a point into a network is the combined process of first projecting
+#' the point onto its nearest point on its nearest edge in the network, then
+#' subdividing that edge at the location of the projected point, and finally
+#' adding the projected point as node to the network. If the location of the
+#' projected point is equal an existing node in the network, the attributes of
+#' the point will be joined to that node, instead of adding a new node.
#'
#' @param x An object of class \code{\link{sfnetwork}}.
#'
@@ -19,355 +19,386 @@
#' meters. If set to \code{Inf} all features will be blended. Defaults to
#' \code{Inf}.
#'
+#' @param ignore_duplicates If there are multiple points in \code{y} that have
+#' the same projected location, only the first one of them is blended into
+#' the network. But what should happen with the others? If this argument is set
+#' to \code{TRUE}, they will be ignored. If this argument is set to
+#' \code{FALSE}, they will be added as isolated nodes to the returned network.
+#' Nodes at equal locations can then be merged using the spatial morpher
+#' \code{\link{to_spatial_unique}}. Defaults to \code{TRUE}.
+#'
#' @return The blended network as an object of class \code{\link{sfnetwork}}.
#'
-#' @details There are two important details to be aware of. Firstly: when the
-#' snap locations of multiple points are equal, only the first of these points
-#' is blended into the network. By arranging \code{y} before blending you can
-#' influence which (type of) point is given priority in such cases.
-#' Secondly: when the snap location of a point intersects with multiple edges,
-#' it is only blended into the first of these edges. You might want to run the
-#' \code{\link{to_spatial_subdivision}} morpher after blending, such that
-#' intersecting but unconnected edges get connected.
+#' @details When the projected location of a given point intersects with more
+#' than one edge, it is only blended into the first of these edges. Edges are
+#' not connected at blending locations. Use the spatial morpher
+#' \code{\link{to_spatial_subdivision}} for that.
+#'
+#' To determine if a projected point is equal to an existing node, and to
+#' determine if multiple projected points are equal to each other, sfnetworks
+#' by default rounds coordinates to 12 decimal places. You can influence this
+#' behavior by explicitly setting the precision of the network using
+#' \code{\link[sf]{st_set_precision}}.
#'
#' @note Due to internal rounding of rational numbers, it may occur that the
#' intersection point between a line and a point is not evaluated as
#' actually intersecting that line by the designated algorithm. Instead, the
#' intersection point lies a tiny-bit away from the edge. Therefore, it is
#' recommended to set the tolerance to a very small number (for example 1e-5)
-#' even if you only want to blend points that intersect the line.
+#' even if you only want to blend points that intersect an edge.
#'
#' @examples
#' library(sf, quietly = TRUE)
#'
-#' # Create a network and a set of points to blend.
-#' n11 = st_point(c(0,0))
-#' n12 = st_point(c(1,1))
-#' e1 = st_sfc(st_linestring(c(n11, n12)), crs = 3857)
+#' oldpar = par(no.readonly = TRUE)
+#' par(mar = c(1,1,1,1), mfrow = c(1,2))
#'
-#' n21 = n12
-#' n22 = st_point(c(0,2))
-#' e2 = st_sfc(st_linestring(c(n21, n22)), crs = 3857)
+#' # Create a spatial network.
+#' n1 = st_point(c(0, 0))
+#' n2 = st_point(c(1, 0))
+#' n3 = st_point(c(2, 0))
#'
-#' n31 = n22
-#' n32 = st_point(c(-1,1))
-#' e3 = st_sfc(st_linestring(c(n31, n32)), crs = 3857)
+#' e1 = st_sfc(st_linestring(c(n1, n2)), crs = 3857)
+#' e2 = st_sfc(st_linestring(c(n2, n3)), crs = 3857)
#'
-#' net = as_sfnetwork(c(e1,e2,e3))
+#' net = as_sfnetwork(c(e1, e2))
#'
-#' pts = net %>%
-#' st_bbox() %>%
-#' st_as_sfc() %>%
-#' st_sample(10, type = "random") %>%
-#' st_set_crs(3857) %>%
-#' st_cast('POINT')
+#' # Create spatial points to blend in.
+#' p1 = st_sfc(st_point(c(0.5, 0.1)))
+#' p2 = st_sfc(st_point(c(0.5, -0.2)))
+#' p3 = st_sfc(st_point(c(1, 0.2)))
+#' p4 = st_sfc(st_point(c(1.75, 0.2)))
+#' p5 = st_sfc(st_point(c(1.25, 0.1)))
#'
-#' # Blend points into the network.
-#' # --> By default tolerance is set to Inf
-#' # --> Meaning that all points get blended
+#' pts = st_sf(foo = letters[1:5], geometry = c(p1, p2, p3, p4, p5), crs = 3857)
+#'
+#' # Blend all points into the network.
#' b1 = st_network_blend(net, pts)
#' b1
#'
-#' # Blend points with a tolerance.
-#' tol = units::set_units(0.2, "m")
+#' plot(net)
+#' plot(st_geometry(pts), pch = 20, col = "orange", add = TRUE)
+#' plot(b1)
+#' plot(st_geometry(pts), pch = 20, col = "orange", add = TRUE)
+#'
+#' # Blend points within a tolerance distance.
+#' tol = units::set_units(0.1, "m")
#' b2 = st_network_blend(net, pts, tolerance = tol)
#' b2
#'
-#' ## Plot results.
-#' # Initial network and points.
-#' oldpar = par(no.readonly = TRUE)
-#' par(mar = c(1,1,1,1), mfrow = c(1,3))
-#' plot(net, cex = 2, main = "Network + set of points")
-#' plot(pts, cex = 2, col = "red", pch = 20, add = TRUE)
+#' plot(net)
+#' plot(st_geometry(pts), pch = 20, col = "orange", add = TRUE)
+#' plot(b2)
+#' plot(st_geometry(pts), pch = 20, col = "orange", add = TRUE)
#'
-#' # Blend with no tolerance
-#' plot(b1, cex = 2, main = "Blend with tolerance = Inf")
-#' plot(pts, cex = 2, col = "red", pch = 20, add = TRUE)
+#' # Add points with duplicated projected location as isolated nodes.
+#' b3 = st_network_blend(net, pts, ignore_duplicates = FALSE)
+#' b3
#'
-#' # Blend with tolerance.
-#' within = st_is_within_distance(pts, st_geometry(net, "edges"), tol)
-#' pts_within = pts[lengths(within) > 0]
-#' plot(b2, cex = 2, main = "Blend with tolerance = 0.2 m")
-#' plot(pts, cex = 2, col = "grey", pch = 20, add = TRUE)
-#' plot(pts_within, cex = 2, col = "red", pch = 20, add = TRUE)
#' par(oldpar)
#'
#' @export
-st_network_blend = function(x, y, tolerance = Inf) {
+st_network_blend = function(x, y, tolerance = Inf, ignore_duplicates = TRUE) {
UseMethod("st_network_blend")
}
+#' @importFrom cli cli_abort
+#' @importFrom tidygraph unfocus
#' @export
-st_network_blend.sfnetwork = function(x, y, tolerance = Inf) {
- require_explicit_edges(x, hard = TRUE)
- stopifnot(has_single_geom_type(y, "POINT"))
- stopifnot(have_equal_crs(x, y))
- stopifnot(as.numeric(tolerance) >= 0)
- if (will_assume_planar(x)) raise_assume_planar("st_network_blend")
- blend_(x, y, tolerance)
+st_network_blend.sfnetwork = function(x, y, tolerance = Inf,
+ ignore_duplicates = TRUE) {
+ x = unfocus(x)
+ if (! has_explicit_edges(x)) {
+ cli_abort(c(
+ "{.arg x} should have spatially explicit edges.",
+ "i" = "Call {.fn sfnetworks::to_spatial_explicit} to explicitize edges."
+ ))
+ }
+ if (! are_points(y)) {
+ cli_abort("All features in {.arg y} should have {.cls POINT} geometries.")
+ }
+ if (! have_equal_crs(x, y)) {
+ cli_abort(c(
+ "{.arg x} and {.arg y} should have the same CRS.",
+ "i" = "Call {.fn sf::st_transform} to transform to a different CRS."
+ ))
+ }
+ if (! as.numeric(tolerance) >= 0) {
+ cli_abort("{.arg tolerance} should be positive.")
+ }
+ if (will_assume_projected(x)) {
+ raise_assume_projected("st_network_blend")
+ }
+ blend(x, y, tolerance = tolerance, ignore_duplicates = ignore_duplicates)
}
-#' @importFrom dplyr bind_rows full_join
-#' @importFrom igraph is_directed vcount
-#' @importFrom sf st_as_sf st_cast st_crs st_crs<- st_distance st_equals
-#' st_geometry st_geometry<- st_intersects st_is_within_distance
-#' st_nearest_feature st_nearest_points st_precision st_precision<-
-#' @importFrom sfheaders sfc_linestring sfc_to_df
+#' @importFrom cli cli_warn
+#' @importFrom dplyr bind_rows left_join
+#' @importFrom igraph is_directed
+#' @importFrom sf st_distance st_drop_geometry st_geometry st_geometry<-
+#' st_is_within_distance st_nearest_feature st_nearest_points st_precision
+#' @importFrom sfheaders sfc_cast sfc_to_df
#' @importFrom units set_units
-blend_ = function(x, y, tolerance) {
+blend = function(x, y, tolerance, ignore_duplicates = TRUE) {
# Extract the following:
# --> The node data of x and its geometries.
# --> The edge data of x and its geometries.
# --> The geometries of the features to be blended.
nodes = nodes_as_sf(x)
edges = edges_as_sf(x)
- N = st_geometry(nodes)
- E = st_geometry(edges)
Y = st_geometry(y)
# For later use:
- # --> Check wheter x is directed.
- # --> Count the number of nodes in x.
# --> Retrieve the name of the geometry column of the nodes in x.
- directed = is_directed(x)
- ncount = vcount(x)
- geom_colname = attr(nodes, "sf_column")
+ # --> Retrieve the precision of x and y.
+ node_colname = attr(nodes, "sf_column")
+ xp = network_precision(x)
+ yp = st_precision(y)
## ===========================
- # STEP I: PARSE THE TOLERANCE
- # If tolerance is not a units object:
- # --> Convert into units object assuming a units of meters.
- # --> Unless tolerance is infinite.
+ # STEP I: DECOMPOSE THE EDGES
+ # Decompose the edges linestring geometries into the points that shape them.
## ===========================
- if (! (is.infinite(tolerance) || inherits(tolerance, "units"))) {
- tolerance = set_units(tolerance, "m")
- }
+ # Decompose edge linestrings into points.
+ edge_pts = sf_to_df(edges)
+ # Define the total number of edge points.
+ n = nrow(edge_pts)
+ # Store additional information for each edge point.
+ edge_pts$pid = seq_len(n) # Unique id for each edge point.
+ edge_pts$eid = edge_pts$linestring_id # Edge index for each edge point.
+ # Define which edge points are boundaries.
+ is_startpoint = !duplicated(edge_pts$eid)
+ is_endpoint = !duplicated(edge_pts$eid, fromLast = TRUE)
+ is_boundary = is_startpoint | is_endpoint
+ # Store for each edge point the node index, if it is a boundary.
+ edge_nids = rep(NA, n)
+ edge_nids[is_boundary] = edge_incident_ids(x)
+ edge_pts$nid = edge_nids
+ # Store for each edge point a segment index.
+ # The edge point gets the index of the segment it is the start of.
+ edge_pts$sid = NA
+ edge_pts$sid[!is_endpoint] = seq_len(n - nrow(edges))
+ # Store for each edge point a feature index.
+ # This will store the index of the feature in y it is the projection of.
+ # This will be filled later, for now only store a placeholder.
+ edge_pts$fid = NA
+ # Clean up.
+ edge_pts$sfg_id = NULL
+ edge_pts$linestring_id = NULL
## ================================
- # STEP II: DEFINE SPATIAL RELATIONS
- # Relate each feature in y to the edges of x by checking if:
- # --> The feature in y is located *on* an edge in x.
- # --> The feature in y is located *close* to an edge in x.
- # With *on* being defined as:
- # --> Intersecting with the edge.
- # With *close* being defined as:
- # --> Within the tolerance distance from an edge.
- # --> But not intersecting with that edge.
+ # STEP II: CONSTRUCT EDGE SEGMENTS
+ # Create geometries for each individual edge segment.
## ================================
- # Find indices of features in y that are located:
- # --> *on* an edge in x.
- intersects = suppressMessages(st_intersects(Y, E))
- is_on = lengths(intersects) > 0
- # Find indices of features in y that are located:
- # --> *close* to an edge in x.
- # We define a feature yi being *close* to an edge xj when:
- # --> yi is located within a given tolerance distance from xj.
- # --> yi is not located on xj.
- if (as.numeric(tolerance) == 0 | all(is_on)) {
- # If tolerance is 0.
- # --> By definition no feature is *close*.
- # If all features are already *on* an edge.
- # --> By definition no feature is *close*.
- is_close = rep(FALSE, length(is_on))
- } else if (is.infinite(tolerance)) {
- # If tolerance was set to infinite:
- # --> That implies there is no upper bound for what to define as *close*.
- # --> Hence, all features that are not *on* are *close*.
- is_close = !is_on
+ # Subset the start points and end points of each segment.
+ segment_src = edge_pts[!is_endpoint, ]
+ segment_trg = edge_pts[!is_startpoint, ]
+ segment_src$sid = seq_len(nrow(segment_src))
+ segment_trg$sid = seq_len(nrow(segment_trg))
+ # Construct the segment geometries.
+ segment_pts = rbind(segment_src, segment_trg)
+ segment_pts = segment_pts[order(segment_pts$sid), ]
+ S = df_to_lines(segment_pts, x, id_col = "sid")
+ # Store for each feature the index of its nearest segment.
+ # This will be filled later, for now only store a placeholder.
+ nearest = rep(NA, length(Y))
+ ## ========================================
+ # STEP III: SELECT FEATURES TO BE BLENDED.
+ # This depends on the provided tolerance.
+ ## ========================================
+ # Define which features to blend.
+ if (is.infinite(tolerance)) {
+ # Infinite tolerance means:
+ # --> All given features should be blended.
+ do_blend = rep(TRUE, length(Y))
} else {
- # If a non-infinite tolerance was set:
- # --> Features are *close* if within tolerance distance from an edge.
- # --> But not *on* an edge.
- is_within = st_is_within_distance(Y[!is_on], E, tolerance)
- is_close = !is_on
- is_close[is_close] = lengths(is_within) > 0
- }
- ## =======================
- # STEP III: SNAP FEATURES
- # We need to "project" the features in y onto the edges of the network.
- # This is also called "snapping".
- # The geometries of the *on* features in y do not have to be changed.
- # Since they already are located on an edge geometry.
- # The geometries of the *close* features in y should be replaced by:
- # --> Their nearest point on their nearest edge.
- ## =======================
- if (any(is_close)) {
- # Find the nearest edge to each close feature.
- A = suppressMessages(st_nearest_feature(Y[is_close], E))
- # Find the nearest point on the nearest edge to each close feature.
- # st_nearest_points returns a straight line between two features.
- # Hence, the endpoint of that line is the location we are looking for.
- B = suppressMessages(st_nearest_points(Y[is_close], E[A], pairwise = TRUE))
- B = linestring_boundary_points(B)
- B = B[seq(2, length(B), 2)]
- # Replace the geometries of the *close* features.
- Y[is_close] = B
+ # Parse the tolerance.
+ # If units are not explicitly specified we assume its in meters.
+ if (! inherits(tolerance, "units")) {
+ tolerance = set_units(tolerance, "m")
+ }
+ # Finite tolerance means:
+ # --> Only features within tolerance distance should be blended.
+ do_blend = lengths(st_is_within_distance(Y, S, tolerance)) > 0
}
- ## ========================
- # STEP IV: SUBSET FEATURES
- # Subset the features in y by removing those that:
- # --> Are neither *on* nor *close* to an edge in x.
- # --> Are duplicated.
- ## ========================
- # Keep only features that are *on* or *close*.
- Y = Y[is_on | is_close]
- # Return x when there are no features left to be blended.
+ # Subset the features.
+ Y = Y[do_blend]
+ # Return the network unmodified when there are no features to be blended.
if (length(Y) == 0) {
- warning(
- "No points were blended. Increase the tolerance distance?",
- call. = FALSE
- )
+ cli_warn(c(
+ "{.fn st_network_blend} did not blend any points into the network.",
+ "i" = "Increase {.arg tolerance} for a higher tolerance distance."
+ ))
return (x)
} else {
if (will_assume_constant(x)) raise_assume_constant("st_network_blend")
}
- # Remove duplicated features in y.
- # These features will have the same blending location.
- # Only one point can be blended per location.
- is_duplicated = st_duplicated(Y)
- Y = Y[!is_duplicated]
- ## ==========================================
- # STEP V: INCLUDE FEATURES IN EDGE GEOMETRIES
- # The snapped features in y should be included in the edge geometries.
- # Only then we can start to split the edges.
- # There are two options:
- # --> The feature already matches an interior or endpoint of an edge.
- # --> The feature does not match any interior or endpoint of an edge.
- # In the first case we need to map the feature to the edge point.
- # In the second case we also need to include a new point in the edge.
- ## ==========================================
- # Decompose the edge geometries into their points.
- # Map each of these points to the index of its "parent edge".
- edge_pts = st_cast(E, "POINT")
- pts_idxs = rep(seq_along(E), lengths(E) / 2)
- # Define for each snapped feature in y which edge point it equals.
- # If it equals more than one edge point, only the first match is taken.
- # Since blending only blends a feature into a single edge.
- matches = do.call("c", lapply(st_equals(Y, edge_pts), `[`, 1))
- # Define which snapped features in y:
- # --> Are actually equal to an edge point.
- # --> Are not equal to any edge point.
- real_matches = which(!is.na(matches))
- na_matches = which(is.na(matches))
- # Convert the edge points object into a dataframe.
- # As additional information, we will store for each edge point:
- # --> The index of its edge.
- # --> The index of the snapped feature in y that equals it, if any.
- # --> The row index.
- edge_pts = data.frame(
- geom = edge_pts,
- edge_id = pts_idxs,
- feat_id = NA,
- row_id = seq_along(edge_pts)
- )
- # Add the indices of the snapped features in y that equal an edge point.
- if (length(real_matches) > 0) {
- edge_pts$feat_id[matches[real_matches]] = real_matches
+ ## ============================================
+ # STEP IV: PROJECT FEATURES ONTO THE NETWORK.
+ # This means finding the nearest point on the nearest edge to each feature.
+ ## ============================================
+ # Find the nearest edge segment to each feature.
+ nearest = suppressMessages(st_nearest_feature(Y, S))
+ # Find the nearest point on the nearest edge to each close feature.
+ # For this we can use sf::sf_nearest_points, which returns:
+ # --> A straight line between feature and point if they are different.
+ # --> A multipoint of feature and point if they are equal.
+ # To make it easier for ourselves we cast all outputs to lines.
+ # Then, the endpoint of that line is the location we are looking for.
+ L = suppressMessages(st_nearest_points(Y, S[nearest], pairwise = TRUE))
+ L = sfc_cast(L, "LINESTRING")
+ P = linestring_end_points(L)
+ # Determine if multiple features have the same projected location.
+ # This features will not be blended into the network.
+ # They may be added as isolated nodes afterwards, if ignore_duplicates = FALSE.
+ is_duplicated = st_duplicated_points(P)
+ if (any(is_duplicated)) {
+ P = P[!is_duplicated]
+ nearest = nearest[!is_duplicated]
+ if (ignore_duplicates) {
+ cli_warn(c(
+ "{.fn st_network_blend} did not blend in all requested features.",
+ "!" = paste(
+ "Some projected features have duplicated locations, of which all",
+ "but the first one are ignored."
+ ),
+ "i" = paste(
+ "If you want to add duplicated projection locations as isolated",
+ "nodes instead, set {.arg ignore_duplicates} to {.code FALSE}."
+ )
+ ))
+ } else {
+ cli_warn(c(
+ "{.fn st_network_blend} created isolated nodes.",
+ "!" = paste(
+ "Some projected features have duplicated locations, of which all",
+ "but the first one are added as isolated nodes to the network."
+ ),
+ "i" = paste(
+ "If you want to ignore duplicated projection locations instead,",
+ "set {.arg ignore_duplicates} to {.code TRUE}."
+ )
+ ))
+ P_dups = P[is_duplicated]
+ }
}
- # Include the locations of the other snapped features as an edge point.
- if (length(na_matches) > 0) {
- # First we need to define where to include the feature geometries.
- # For that we need to subdivide the edge geometries into their segments.
- # A segment is the part of an edge between two edge points.
- # Hence: decompose the edge geometries into their segments.
- edge_sgs = linestring_segments(E)
- # Map each of these segments to the index of its "parent edge".
- sgs_idxs = rep(seq_along(E), lengths(E) / 2 - 1)
- # Define for each segment its position within its "parent edge".
- # Hence, the first segment within an edge gets a 1, etc.
- sgs_psns = do.call("c", lapply(rle(sgs_idxs)$lengths, seq_len))
- # Now we find for each feature its nearest segment.
- # Then we know exactly where to include the feature geometry.
- nearest = suppressMessages(st_nearest_feature(Y[na_matches], edge_sgs))
- # Include the features by looping over the identified nearest segments.
- # If only a single feature needs to be included in that segment:
- # --> Add that feature at the right position in the edge points table.
- # If multiple features need to be included in a single segment:
- # --> Order these features by distance to the startpoint of the segment.
- # --> Add them at the right position in the edge points table.
- include = function(i) {
- # Retrieve the following with respect to the current segment:
- # --> The index of the edge of which the segment is part.
- # --> The index of the edge point at the start of the segment.
- # --> The indices of the features for which this segment is nearest.
- edge_id = sgs_idxs[i]
- src_id = which(pts_idxs == edge_id)[sgs_psns[i]]
- feat_idxs = na_matches[which(nearest == i)]
- # If there are multiple features for which this segment is nearest:
- # --> Order them by distance to the startpoint of the segment.
- n = length(feat_idxs)
- if (n > 1) {
- feats = Y[feat_idxs]
- point = edge_pts$geom[src_id]
- dists = st_distance(point, feats)
- feat_idxs = feat_idxs[order(dists)]
+ ## =====================================================
+ # STEP V: INCLUDE PROJECTED FEATURES IN EDGE GEOMETRIES
+ # The projected features should be included in the edge geometries.
+ # Only then we can start to subdivide the edges.
+ # There are two options:
+ # --> The projection already matches an interior or endpoint of an edge.
+ # --> The projection does not match any interior or endpoint of an edge.
+ # In case 1 we need to map the projection to the existing edge point.
+ # In case 2 we need to include a new point in the edge geometry.
+ ## =====================================================
+ # Convert projection points into the same structure as the decomposed edges.
+ p_pts = sfc_to_df(P)
+ p_pts$pid = NA
+ p_pts$eid = NA
+ p_pts$nid = NA
+ p_pts$sid = NA
+ p_pts$fid = p_pts$point_id
+ p_pts$sfg_id = NULL
+ p_pts$point_id = NULL
+ # Define a function to:
+ # --> Include one or more projected features in an edge segment.
+ include_in_segment = function(i) {
+ # Extract the features to be included in segment i.
+ fts = p_pts[which(nearest == i), ]
+ fts_coords = df_to_coords(fts, yp)
+ # Extract the source edge point of segment i.
+ src_pid = which(edge_pts$sid == i)
+ src = edge_pts[src_pid, ]
+ # Extract the target edge point of segment i.
+ trg_pid = src_pid + 1
+ trg = edge_pts[trg_pid, ]
+ # Define the position of the feature in the segment.
+ if (nrow(fts) == 1) {
+ # There is only one feature to be included in segment i.
+ # First check if the feature matches the source.
+ src_coords = df_to_coords(src, xp)
+ if (fts_coords == src_coords) {
+ src$fid = fts$fid
+ fts = src
+ } else {
+ # Then check if the feature matches the target.
+ trg_coords = df_to_coords(trg, xp)
+ if (fts_coords == trg_coords) {
+ trg$fid = fts$fid
+ fts = trg
+ } else {
+ # Otherwise add the feature between the source and target.
+ fts$pid = src_pid + 0.5
+ }
+ }
+ } else {
+ # There are multiple features to be included in segment i.
+ # First check which of them equal source or target.
+ # And which should be added as new points to the segment.
+ src_coords = df_to_coords(src, xp)
+ trg_coords = df_to_coords(trg, xp)
+ equal_to_src = fts_coords == src_coords
+ equal_to_trg = fts_coords == trg_coords
+ not_equal = !(equal_to_src | equal_to_trg)
+ # Match feature to source.
+ if (any(equal_to_src)) {
+ src$fid = fts$fid[equal_to_src]
+ fts[equal_to_src, ] = src
+ }
+ # Match feature to target.
+ if (any(equal_to_trg)) {
+ trg$fid = fts$fid[equal_to_trg]
+ fts[equal_to_trg, ] = trg
+ }
+ # Add feature(s) as new point(s).
+ if (any(not_equal)) {
+ n = sum(not_equal)
+ if (n > 1) {
+ # If there are multiple features to be added.
+ # Determine their order based on distance to the source point.
+ src_geom = df_to_points(src, edges) # Convert to sfc.
+ fts_geom = df_to_points(fts[not_equal, ], y) # Convert to sfc.
+ dists = st_distance(src_geom, fts_geom)
+ d = 1 / (n + 1) # How much the pid should increment per feature.
+ fts$pid[not_equal][order(dists)] = seq(d, d * n, d) + src_pid
+ } else {
+ # If there is one feature to be added.
+ # Add it between the source and target points.
+ fts$pid[not_equal] = src_pid + 0.5
+ }
}
- # Define where to insert the features in the edge points table.
- # This is directly after the startpoint of the segment.
- # The row indices of the features should be a value between:
- # --> The row index of the startpoint of the segment.
- # --> The row index of the endpoint of the segment.
- # Recall that the latter is the startpoint index plus 1.
- # Hence, for the features to be inserted we need:
- # --> A value between 0 and 1 added to the segment startpoint index.
- # If there are multiple features, their order should be preserved.
- stepsize = 1 / (n + 1)
- values = seq(stepsize, stepsize * n, stepsize)
- row_idxs = values + src_id
- # Return in the same format as the edge points table.
- data.frame(
- geom = Y[feat_idxs],
- edge_id = rep(edge_id, n),
- feat_id = feat_idxs,
- row_id = row_idxs
- )
}
- new_pts = do.call("rbind", lapply(unique(nearest), include))
- edge_pts = bind_rows(edge_pts, new_pts)
- edge_pts = edge_pts[order(edge_pts$row_id), ]
+ # Fill the other columns.
+ fts$eid = src$eid
+ fts$sid = NA
+ # Return the updated edge points for segment i.
+ fts
}
- ## =============================
- # STEP V: SPLIT EDGE GEOMETRIES
- # New nodes should be added for snapped features of y whenever:
- # --> There is not an existing node yet at that location.
- # The edges should be splitted at the locations of these new nodes.
- ## =============================
- # First, we define where to split the edges. This is at edge points that:
- # --> Are equal to a snapped feature in y.
- # --> Are *not* already an edge boundary.
- is_startpoint = !duplicated(edge_pts$edge_id)
- is_endpoint = !duplicated(edge_pts$edge_id, fromLast = TRUE)
- is_boundary = is_startpoint | is_endpoint
- is_split = !is.na(edge_pts$feat_id) & !is_boundary
- # Create a repetition vector:
- # --> This defines for each edge point if it should be duplicated.
- # --> A value of '1' means 'store once', i.e. don't duplicate.
- # --> A value of '2' means 'store twice', i.e. duplicate.
- # --> Split points will be part of two new edges and should be duplicated.
- reps = rep(1L, nrow(edge_pts))
- reps[is_split] = 2L
- # Extract a coordinate data frame from the edge points.
- # Apply the repitition vector to this data frame.
- # This gives us the coordinates of the new edge points.
- edge_coords = sfc_to_df(edge_pts$geom)
- edge_coords = edge_coords[names(edge_coords) %in% c("x", "y", "z", "m")]
- new_edge_coords = data.frame(lapply(edge_coords, rep, reps))
- # Apply the repetition vector also to the edge indices of the edge points.
- # This gives us the *original* edge index of the new edge points.
- orig_edge_idxs = rep(edge_pts$edge_id, reps)
- # Update these original edge indices according to the splits.
- # Remember that edges are splitted at each split point.
- # That is: a new edge originates from each split point.
- # Hence, to get the new edge indices:
- # --> Increment each original edge index by 1 at each split point.
- incs = integer(nrow(new_edge_coords)) # By default don't increment.
- incs[which(is_split) + seq_len(sum(is_split))] = 1L # Add 1 after each split.
- new_edge_idxs = orig_edge_idxs + cumsum(incs)
- new_edge_coords$edge_id = new_edge_idxs
- # Build the new edge geometries.
- new_edge_geoms = sfc_linestring(new_edge_coords, linestring_id = "edge_id")
- st_crs(new_edge_geoms) = st_crs(edges)
- st_precision(new_edge_geoms) = st_precision(edges)
- new_edge_coords$edge_id = NULL
- ## ================================
- # STEP VI: RESTORE EDGE ATTRIBUTES
+ # Apply the function to each segment that is nearest to a projected feature.
+ new_pts = do.call("rbind", lapply(unique(nearest), include_in_segment))
+ # Update the edge points data frame by integrating the updates.
+ edge_pts = rbind(edge_pts, new_pts)
+ edge_pts = edge_pts[!duplicated(edge_pts$pid, fromLast = TRUE), ]
+ edge_pts = edge_pts[order(edge_pts$pid), ]
+ # Clean up.
+ rownames(edge_pts) = NULL
+ edge_pts$pid = seq_len(nrow(edge_pts))
+ ## ==========================================
+ # STEP VI: SUBDIVIDE EDGE GEOMETRIES
+ # Now we can subdivide edge geometries at each projected feature.
+ # Then we need to build a linestring geometry for each new edge.
+ ## ==========================================
+ # Define where to subdivide.
+ # This is at edge points that:
+ # --> Match a projected feature location.
+ # --> Are not already an endpoint of an edge.
+ is_split = !is.na(edge_pts$fid) & is.na(edge_pts$nid)
+ # Create the new set of edge points by duplicating split points.
+ new_edge_pts = create_new_edge_df(edge_pts, is_split)
+ # Define the new edge index of each new edge point.
+ new_edge_ids = create_new_edge_ids(new_edge_pts, is_split)
+ # Construct the new edge linestring geometries.
+ new_edge_geoms = create_new_edge_geoms(new_edge_pts, new_edge_ids, edges)
+ ## =====================================
+ # STEP VII: CONSTRUCT THE NEW EDGE DATA
# We now have the geometries of the new edges.
# However, the original edge attributes got lost.
# We will restore them by:
@@ -375,133 +406,75 @@ blend_ = function(x, y, tolerance) {
# --> Duplicating original attributes within splitted edges.
# Beware that from and to columns will remain unchanged at this stage.
# We will update them later.
- ## ================================
- # First, we find which *original* edge belongs to which *new* edge:
- # --> Use the lists of edge indices mapped to the new edge points.
- # --> There we already mapped each new edge point to its original edge.
- # --> First define which new edge points are startpoints of new edges.
- # --> Then retrieve the original edge index from these new startpoints.
- # --> This gives us a single original edge index for each new edge.
- is_new_startpoint = !duplicated(new_edge_idxs)
- orig_edge_idxs = orig_edge_idxs[is_new_startpoint]
- # Duplicate original edge data whenever needed.
- new_edges = edges[orig_edge_idxs, ]
- # Set the new edge geometries as geometries of these new edges.
+ ## =====================================
+ # Define at which new edge points a new edge starts and ends.
+ is_new_startpoint = !duplicated(new_edge_ids)
+ is_new_endpoint = !duplicated(new_edge_ids, fromLast = TRUE)
+ # Use the original edge ids of the startpoints to copy original attributes.
+ new_edges = edges[new_edge_pts$eid[is_new_startpoint], ]
+ # Insert the newly constructed edge geometries.
st_geometry(new_edges) = new_edge_geoms
- ## =================================================
- # STEP VII: UPDATE FROM AND TO INDICES OF NEW EDGES
- # Now we have:
- # --> Constructed new edge geometries.
- # --> Duplicated edge attributes wherever needed.
- # Still left to do is updating the from and to indices of the new edges.
- # They should match with the indices of the new nodes in the network.
- # The new nodes are a combination of:
- # --> Already existing nodes.
- # --> New nodes that are going to be added at split points.
- ## =================================================
- # Map each of the original edge points to the index of an original node.
- # Edge points that do no equal an original node get assigned NA.
- edge_pts$node_id = rep(NA, nrow(edge_pts))
- if (directed) {
- edge_pts[is_boundary, ]$node_id = edge_boundary_node_indices(x)
+ ## ======================================
+ # STEP VIII: CONSTRUCT THE NEW NODE DATA
+ # New nodes are added at the subdivision locations.
+ ## ======================================
+ # Identify and select the edge points that become a node in the new network.
+ is_new_node = is_new_startpoint | is_new_endpoint
+ new_node_pts = new_edge_pts[is_new_node, ]
+ # Define the node indices of those nodes that are added to the network.
+ is_add = is.na(new_node_pts$nid)
+ add_node_pids = new_node_pts$pid[is_add]
+ add_node_ids = match(add_node_pids, unique(add_node_pids)) + nrow(nodes)
+ new_node_pts[is_add, ]$nid = add_node_ids
+ # Construct the geometries of those nodes.
+ add_node_pts = new_node_pts[is_add, ][!duplicated(add_node_ids), ]
+ add_node_geoms = df_to_points(add_node_pts, nodes)
+ # Construct the new node data.
+ # This is done by simply binding original node data with added geometries.
+ add_nodes = sfc_to_sf(add_node_geoms, colname = node_colname)
+ new_nodes = bind_rows(nodes, add_nodes)
+ # Join the attributes of the blended features into the new nodes.
+ # This is of course only needed if the given features have attributes.
+ if (is_sf(y) && ncol(y) > 1) {
+ # Subset y to contain only attributes (not geometries) of blended features.
+ y_blend = st_drop_geometry(y)
+ y_blend = y_blend[do_blend, , drop = FALSE][!is_duplicated, , drop = FALSE]
+ # Subset the node points data frame to contain each node only once.
+ new_nodes_df = new_node_pts[!duplicated(new_node_pts$nid), ]
+ # Add an index column to match nodes to features.
+ if (".sfnetwork_index" %in% c(names(nodes), names(y))) {
+ raise_reserved_attr(".sfnetwork_index")
+ }
+ y_blend$.sfnetwork_index = seq_len(nrow(y_blend))
+ new_nodes$.sfnetwork_index = new_nodes_df$fid[order(new_nodes_df$nid)]
+ # Join attributes of blended features with the new nodes table.
+ new_nodes = left_join(new_nodes, y_blend, by = ".sfnetwork_index")
+ new_nodes$.sfnetwork_index = NULL
+ # Add features with duplicated projection locations if requested.
+ if (!ignore_duplicates && any(is_duplicated)) {
+ y_dups = y[do_blend, , drop = FALSE][is_duplicated, , drop = FALSE]
+ st_geometry(y_dups) = P_dups
+ st_geometry(y_dups) = node_colname # Use correct name.
+ new_nodes = bind_rows(new_nodes, y_dups)
+ }
} else {
- edge_pts[is_boundary, ]$node_id = edge_boundary_point_indices(x)
+ # Add features with duplicated projection locations if requested.
+ if (!ignore_duplicates && any(is_duplicated)) {
+ y_dups = sfc_to_sf(P_dups, colname = node_colname)
+ new_nodes = bind_rows(new_nodes, y_dups)
+ }
}
- # Update this vector of original node indices by:
- # --> Adding a new, unique node index to each of the split points.
- # --> Applying the repetition vector to map them to the new edge points.
- new_node_idxs = edge_pts$node_id
- added_node_idxs = c((ncount + 1):(ncount + sum(is_split)))
- new_node_idxs[is_split] = added_node_idxs
- new_node_idxs = rep(new_node_idxs, reps)
- # Drop NA values from this vector of new node indices.
- # Recall that NA values belong to edge points that do not equal a node.
- # After dropping them we are left with an index vector of the form:
- # --> [source node edge 1, target node edge 1, source node edge 2, ...]
- new_node_idxs = new_node_idxs[!is.na(new_node_idxs)]
- # Define for each of the indices if it belongs to a source node.
- is_source = rep(c(TRUE, FALSE), length(new_node_idxs) / 2)
- # Update the from and to columns of the new edges accordingly.
- new_edges$from = new_node_idxs[is_source]
- new_edges$to = new_node_idxs[!is_source]
## ==================================================
- # STEP VIII: JOIN THE NODES WITH THE BLENDED FEATURES
- # The blended features of y are either:
- # --> Matched to an already existing node.
- # --> A new node in the network.
- # In the first case:
- # --> Their attributes (if any) should be joined with the existing nodes.
- # In the second case:
- # --> They should be binded to the already existing nodes.
+ # STEP IX: UPDATE FROM AND TO INDICES OF NEW EDGES
+ # Now we constructed the new node data with updated node indices.
+ # Therefore we need to update the from and to columns of the edges as well.
## ==================================================
- # When a snapped feature in y matched a original node of x:
- # --> Get the index of both the feature and the node.
- is_match = is_boundary & !is.na(edge_pts$feat_id)
- matched_node_idxs = edge_pts$node_id[is_match]
- matched_feat_idxs = edge_pts$feat_id[is_match]
- # When a snapped feature in y is a new node of x:
- # --> Get the index of that feature.
- is_new = is_split
- new_feat_idxs = edge_pts$feat_id[is_new]
- # Join the orignal node data and the blended features.
- # Different scenarios require a different approach.
- if (is.sf(y) && ncol(y) > 1) {
- # Scenario I: the features in y have attributes.
- # This requires:
- # --> A full join between the original node data and the features.
- # First, subset y to keep only those features that were blended.
- y = y[is_on | is_close, ]
- y = y[!is_duplicated, ]
- # Add an index column matching the features in y to their new node index.
- y$.sfnetwork_index = NA_integer_
- y[matched_feat_idxs, ]$.sfnetwork_index = matched_node_idxs
- y[new_feat_idxs, ]$.sfnetwork_index = added_node_idxs
- # Add an index column matching the orginal nodes to their new node index.
- nodes$.sfnetwork_index = seq_len(ncount)
- # Remove the geometry columns.
- # Since the full join is an attribute join.
- # We will re-add geometries later on.
- st_geometry(y) = NULL
- st_geometry(nodes) = NULL
- # Perform a full join between the attributes of the nodes and features.
- # Base the join on the created index column.
- # Remove that index column afterwards.
- new_nodes = full_join(nodes, y, by = ".sfnetwork_index")
- new_nodes = new_nodes[order(new_nodes$.sfnetwork_index), ]
- new_nodes$.sfnetwork_index = NULL
- # Add the new node geometries.
- new_node_geoms = c(N, Y[new_feat_idxs])
- new_nodes[geom_colname] = list(new_node_geoms)
- new_nodes = st_as_sf(new_nodes, sf_column_name = geom_colname)
- } else if (ncol(nodes) > 1) {
- # Scenario II: the features in y don't have attributes but the nodes do.
- # This requires:
- # --> The geometries of the new nodes binded to the original nodes.
- # --> The attribute values of these new nodes being filled with NA.
- # First, we select only those blended features that became a new node.
- y_new = st_as_sf(Y[new_feat_idxs])
- # Align the name of the geometry columns.
- names(y_new)[1] = geom_colname
- st_geometry(y_new) = geom_colname
- # Bind the new nodes with original nodes.
- # The dplyr::bind_rows function will take care of the NA filling.
- new_nodes = bind_rows(nodes, y_new)
- } else {
- # Scenario III: neither the features in y nor the nodes have attributes.
- # This requires:
- # --> The geometries of the new nodes binded to the original nodes.
- # First, we select only those blended features that became a new node.
- y_new = Y[new_feat_idxs]
- # Bind these geometries to the original node geometries.
- new_nodes = st_as_sf(c(N, y_new))
- # Set the geometry column name equal to the one in the original network.
- names(new_nodes)[1] = geom_colname
- st_geometry(new_nodes) = geom_colname
- }
+ new_edges$from = new_node_pts$nid[is_new_startpoint[is_new_node]]
+ new_edges$to = new_node_pts$nid[is_new_endpoint[is_new_node]]
## ============================
- # STEP IX: RECREATE THE NETWORK
+ # STEP X: RECREATE THE NETWORK
# Use the new nodes data and the new edges data to create the new network.
## ============================
- x_new = sfnetwork_(new_nodes, new_edges, directed = directed)
+ x_new = sfnetwork_(new_nodes, new_edges, directed = is_directed(x))
x_new %preserve_network_attrs% x
}
diff --git a/R/centrality.R b/R/centrality.R
new file mode 100644
index 00000000..f56c335b
--- /dev/null
+++ b/R/centrality.R
@@ -0,0 +1,57 @@
+#' Compute spatial centrality measures
+#'
+#' These functions are a collection of centrality measures that are specific
+#' for spatial networks, and form a spatial extension to
+#' \code{\link[tidygraph:centrality]{centrality measures}} in tidygraph.
+#'
+#' @param ... Additional arguments passed on to other functions.
+#'
+#' @details Just as with all centrality functions in tidygraph, these functions
+#' are meant to be called inside tidygraph verbs such as
+#' \code{\link[tidygraph]{mutate}} or \code{\link[tidygraph]{filter}}, where
+#' the network that is currently being worked on is known and thus not needed
+#' as an argument to the function. If you want to use an algorithm outside of
+#' the tidygraph framework you can use \code{\link[tidygraph]{with_graph}} to
+#' set the context temporarily while the algorithm is being evaluated.
+#'
+#' @return A numeric vector of the same length as the number of nodes in the
+#' network.
+#'
+#' @name spatial_centrality
+NULL
+
+#' @describeIn spatial_centrality The straightness centrality of node i is the
+#' average ratio of Euclidean distance and network distance between node i and
+#' all other nodes in the network. \code{...} is forwarded to
+#' \code{\link{st_network_distance}} to compute the network distance matrix.
+#' Euclidean distances are computed using \code{\link[sf]{st_distance}}.
+#'
+#' @examples
+#' library(tidygraph, quietly = TRUE)
+#'
+#' net = as_sfnetwork(roxel, directed = FALSE)
+#'
+#' net |>
+#' activate(nodes) |>
+#' mutate(sc = centrality_straightness())
+#'
+#' @importFrom sf st_distance
+#' @export
+centrality_straightness = function(...) {
+ require_active_nodes()
+ x = .G()
+ # Compute network distances.
+ ndists = st_network_distance(
+ x,
+ from = node_ids(x),
+ to = node_ids(x),
+ Inf_as_NaN = TRUE,
+ ...
+ )
+ # Compute Euclidean distances.
+ sdists = st_distance(pull_node_geom(x, focused = TRUE))
+ # Compute ratios.
+ ratios = sdists / ndists
+ # Compute average of ratios per node.
+ apply(ratios, 1, mean, na.rm = TRUE)
+}
diff --git a/R/checks.R b/R/checks.R
index e6d086a1..cfc813c4 100644
--- a/R/checks.R
+++ b/R/checks.R
@@ -1,28 +1,142 @@
-#' Check if a table has spatial information stored in a geometry list column
+#' Check if an object is a sfnetwork
#'
-#' @param x A flat table, such as an sf object, data.frame or tibble.
+#' @param x Object to be checked.
#'
-#' @return \code{TRUE} if the table has a geometry list column, \code{FALSE}
-#' otherwise.
+#' @return \code{TRUE} if the given object is an object of class
+#' \code{\link{sfnetwork}}, \code{FALSE} otherwise.
+#'
+#' @examples
+#' library(tidygraph, quietly = TRUE, warn.conflicts = FALSE)
+#'
+#' net = as_sfnetwork(roxel)
+#' is_sfnetwork(net)
+#' is_sfnetwork(as_tbl_graph(net))
+#'
+#' @export
+is_sfnetwork = function(x) {
+ inherits(x, "sfnetwork")
+}
+
+#' @name is_sfnetwork
+#' @export
+is.sfnetwork = function(x) {
+ is_sfnetwork(x)
+}
+
+#' Check if a network is focused
+#'
+#' @param x An object of class \code{\link{sfnetwork}} or
+#' \code{\link[tidygraph]{tbl_graph}}.
+#'
+#' @return \code{TRUE} if the given network is focused on nodes or edges,
+#' \code{FALSE} otherwise.
+#'
+#' @details See \code{\link[tidygraph]{focus}} for more information on focused
+#' networks.
#'
#' @noRd
-has_sfc = function(x) {
- any(vapply(x, is.sfc, FUN.VALUE = logical(1)), na.rm = TRUE)
+is_focused = function(x) {
+ inherits(x, "focused_tbl_graph")
}
-#' Check if geometries are all of a specific type
+#' Check if an object is an sf object
#'
-#' @param x An object of class \code{\link{sfnetwork}} or \code{\link[sf]{sf}}.
+#' @param x Object to be checked.
+#'
+#' @return \code{TRUE} if the given object is an object of class
+#' \code{\link[sf]{sf}}, \code{FALSE} otherwise.
+#'
+#' @noRd
+is_sf = function(x) {
+ inherits(x, "sf")
+}
+
+#' Check if an object is an sfc object
+#'
+#' @param x Object to be checked.
+#'
+#' @return \code{TRUE} if the given object is an object of class
+#' \code{\link[sf]{sfc}}, \code{FALSE} otherwise.
+#'
+#' @noRd
+is_sfc = function(x) {
+ inherits(x, "sfc")
+}
+
+#' Check if an object is an sfc object with linestring geometries
+#'
+#' @param x Object to be checked.
+#'
+#' @return \code{TRUE} if the given object is an object of class
+#' \code{\link[sf]{sfc}} with geometries of type \code{LINESTRING},
+#' \code{FALSE} otherwise.
+#'
+#' @noRd
+is_sfc_linestring = function(x) {
+ inherits(x, "sfc_LINESTRING")
+}
+
+#' Check if an object is an sfc object with point geometries
+#'
+#' @param x Object to be checked.
+#'
+#' @return \code{TRUE} if the given object is an object of class
+#' \code{\link[sf]{sfc}} with geometries of type \code{POINT},
+#' \code{FALSE} otherwise.
+#'
+#' @noRd
+is_sfc_point = function(x) {
+ inherits(x, "sfc_POINT")
+}
+
+#' Check if an object is an sfg object
#'
-#' @param type The geometry type to check for, as a string.
+#' @param x Object to be checked.
#'
-#' @return \code{TRUE} when all geometries are of the given type, \code{FALSE}
+#' @return \code{TRUE} if the given object is an object of class
+#' \code{\link[sf:st]{sfg}}, \code{FALSE} otherwise.
+#'
+#' @noRd
+is_sfg = function(x) {
+ inherits(x, "sfg")
+}
+
+#' Check if an object has only linestring geometries
+#'
+#' @param x An object of class \code{\link{sfnetwork}}, \code{\link[sf]{sf}} or
+#' \code{\link[sf]{sfc}}.
+#'
+#' @return \code{TRUE} if the geometries of the given object are all of type
+#' \code{LINESTRING}, \code{FALSE} otherwise.
+#'
+#' @noRd
+are_linestrings = function(x) {
+ is_sfc_linestring(st_geometry(x))
+}
+
+#' Check if an object has only point geometries
+#'
+#' @param x An object of class \code{\link{sfnetwork}}, \code{\link[sf]{sf}} or
+#' \code{\link[sf]{sfc}}.
+#'
+#' @return \code{TRUE} if the geometries of the given object are all of type
+#' \code{POINT}, \code{FALSE} otherwise.
+#'
+#' @noRd
+are_points = function(x) {
+ is_sfc_point(st_geometry(x))
+}
+
+#' Check if a table has spatial information stored in a geometry list column
+#'
+#' @param x A flat table, such as an sf object, data.frame or tibble.
+#'
+#' @return \code{TRUE} if the table has a geometry list column, \code{FALSE}
#' otherwise.
#'
-#' @importFrom sf st_is
#' @noRd
-has_single_geom_type = function(x, type) {
- all(st_is(x, type))
+has_sfc = function(x) {
+ any(vapply(x, is_sfc, FUN.VALUE = logical(1)), na.rm = TRUE)
}
#' Check if a tbl_graph has nodes with a geometry list column
@@ -32,9 +146,11 @@ has_single_geom_type = function(x, type) {
#' @return \code{TRUE} if the nodes table of the tbl_graph has a geometry list
#' column, \code{FALSE} otherwise.
#'
+#' @importFrom igraph vertex_attr
#' @noRd
has_spatial_nodes = function(x) {
- any(vapply(vertex_attr(x), is.sfc, FUN.VALUE = logical(1)), na.rm = TRUE)
+ cols = vertex_attr(x)
+ any(vapply(cols, is_sfc_point, FUN.VALUE = logical(1)), na.rm = TRUE)
}
#' Check if a sfnetwork has spatially explicit edges
@@ -47,7 +163,8 @@ has_spatial_nodes = function(x) {
#' @importFrom igraph edge_attr
#' @noRd
has_explicit_edges = function(x) {
- any(vapply(edge_attr(x), is.sfc, FUN.VALUE = logical(1)), na.rm = TRUE)
+ cols = edge_attr(x)
+ any(vapply(cols, is_sfc_linestring, FUN.VALUE = logical(1)), na.rm = TRUE)
}
#' Check if the CRS of two objects are the same
@@ -64,7 +181,9 @@ has_explicit_edges = function(x) {
#' @importFrom sf st_crs
#' @noRd
have_equal_crs = function(x, y) {
- st_crs(x) == st_crs(y)
+ x_crs = if (is_sfnetwork(x)) st_crs(pull_node_geom(x)) else st_crs(x)
+ y_crs = if (is_sfnetwork(y)) st_crs(pull_node_geom(y)) else st_crs(y)
+ x_crs == y_crs
}
#' Check if the precision of two objects is the same
@@ -81,7 +200,9 @@ have_equal_crs = function(x, y) {
#' @importFrom sf st_precision
#' @noRd
have_equal_precision = function(x, y) {
- st_precision(x) == st_precision(y)
+ xp = if (is_sfnetwork(x)) st_precision(pull_node_geom(x)) else st_precision(x)
+ yp = if (is_sfnetwork(y)) st_precision(pull_node_geom(y)) else st_precision(y)
+ xp == yp
}
#' Check if two sfnetworks have the same type of edges
@@ -95,13 +216,9 @@ have_equal_precision = function(x, y) {
#'
#' @noRd
have_equal_edge_type = function(x, y) {
- both_explicit = function(x, y) {
- has_explicit_edges(x) && has_explicit_edges(y)
- }
- both_implicit = function(x, y) {
- !has_explicit_edges(x) && !has_explicit_edges(y)
- }
- both_explicit(x, y) || both_implicit(x, y)
+ x_is_explicit = has_explicit_edges(x)
+ y_is_explicit = has_explicit_edges(y)
+ (x_is_explicit && y_is_explicit) || (!x_is_explicit && !y_is_explicit)
}
#' Check if two sf objects have the same geometries
@@ -110,7 +227,9 @@ have_equal_edge_type = function(x, y) {
#'
#' @param y An object of class \code{\link[sf]{sf}} or \code{\link[sf]{sfc}}.
#'
-#' @return A vector of booleans, one element for each (x[i], y[i]) pair.
+#' @return A logical vector with one element for each (x[i], y[i]) pair. An
+#' element is \code{TRUE} if the geometry of x[i] is equal to the geometry of
+#' y[i], and \code{FALSE} otherwise.
#'
#' @details This is a pairwise check. Each row in x is compared to its
#' corresponding row in y. Hence, x and y should be of the same length.
@@ -118,69 +237,101 @@ have_equal_edge_type = function(x, y) {
#' @importFrom sf st_equals
#' @noRd
have_equal_geometries = function(x, y) {
- diag(st_equals(x, y, sparse = FALSE))
+ equals = st_equals(x, y)
+ do.call("c", lapply(seq_along(equals), \(i) i %in% equals[[i]]))
+}
+
+#' Check if an object is a single string
+#'
+#' @param x The object to be checked.
+#'
+#' @return \code{TRUE} if \code{x} is a single string, \code{FALSE} otherwise.
+#'
+#' @noRd
+is_single_string = function(x) {
+ is.character(x) && length(x) == 1
}
-#' Check if any boundary point of an edge is equal to any of its boundary nodes
+#' Check if any boundary point of an edge is equal to any of its incident nodes
#'
#' @param x An object of class \code{\link{sfnetwork}}.
#'
+#' @return A logical vector of the same length as the number of edges in the
+#' network, holding a \code{TRUE} value if the boundary of the edge geometry
+#' contains the geometries of both its incident nodes.
+#'
#' @importFrom sf st_equals
#' @noRd
nodes_in_edge_boundaries = function(x) {
- boundary_points = edge_boundary_points(x)
- boundary_nodes = edge_boundary_nodes(x)
- # Test for each edge :
- # Does one of the boundary points equals at least one of the boundary nodes.
- M = st_equals(boundary_points, boundary_nodes, sparse = FALSE)
- f = function(x) sum(M[x:(x + 1), x:(x + 1)]) > 1
- vapply(seq(1, nrow(M), by = 2), f, FUN.VALUE = logical(1))
+ boundary_geoms = edge_boundary_geoms(x)
+ incident_geoms = edge_incident_geoms(x)
+ # Test for each edge:
+ # Does one of the boundary points equals at least one of the incident nodes.
+ equals = st_equals(boundary_geoms, incident_geoms)
+ is_in = function(i) {
+ pool = c(equals[[i]], equals[[i + 1]])
+ i %in% pool && i + 1 %in% pool
+ }
+ do.call("c", lapply(seq(1, length(equals), by = 2), is_in))
}
#' Check if edge boundary points are equal to their corresponding nodes
#'
#' @param x An object of class \code{\link{sfnetwork}}.
#'
+#' @return A logical vector of twice the length as the number of edges in the
+#' network, with per edge one element for its startpoint and one for its
+#' endpoint, holding a \code{TRUE} value if the point is equal to the geometry
+#' of the corresponding node.
+#'
#' @noRd
-nodes_match_edge_boundaries = function(x) {
- boundary_points = edge_boundary_points(x)
- boundary_nodes = edge_boundary_nodes(x)
+nodes_equal_edge_boundaries = function(x) {
+ boundary_geoms = edge_boundary_geoms(x)
+ incident_geoms = edge_incident_geoms(x)
# Test if the boundary geometries are equal to their corresponding nodes.
- have_equal_geometries(boundary_points, boundary_nodes)
+ have_equal_geometries(boundary_geoms, incident_geoms)
}
-#' Check if constant edge attributes will be assumed for a network
+#' Check if constant attributes will be assumed for a network
#'
#' @param x An object of class \code{\link{sfnetwork}}.
#'
+#' @param agr The attribute-geometry relationship values to check against.
+#' Defaults to the agr factor of the edges.
+#'
+#' @param ignore_ids Should known index columns be ignored by the check?
+#' Defaults to \code{TRUE}.
+#'
#' @return \code{TRUE} when the attribute-geometry relationship of at least
-#' one edge attribute of x is not constant, but sf will for some operations
+#' one attribute of x is not constant, but sf will for some operations
#' assume that it is, \code{FALSE} otherwise.
#'
#' @noRd
-will_assume_constant = function(x) {
- ignore = c(
- "from",
- "to",
- ".tidygraph_edge_index",
- ".tidygraph_index",
- ".sfnetwork_edge_index",
- ".sfnetwork_index"
- )
- agr = edge_agr(x)
- real_agr = agr[!names(agr) %in% ignore]
- any(is.na(real_agr)) || any(real_agr != "constant")
+will_assume_constant = function(x, agr = edge_agr(x), ignore_ids = TRUE) {
+ if (ignore_ids) {
+ ignore = c(
+ "from",
+ "to",
+ ".tidygraph_node_index",
+ ".tidygraph_edge_index",
+ ".tidygraph_index",
+ ".tbl_graph_index",
+ ".sfnetwork_index"
+ )
+ agr = agr[!names(agr) %in% ignore]
+ }
+ any(is.na(agr)) || any(agr != "constant")
}
-#' Check if a planar coordinates will be assumed for a network
+#' Check if projected coordinates will be assumed for a network
#'
#' @param x An object of class \code{\link{sfnetwork}}.
#'
#' @return \code{TRUE} when the coordinates of x are longitude-latitude, but sf
-#' will for some operations assume they are planar, \code{FALSE} otherwise.
+#' will for some operations assume they are projected, \code{FALSE} otherwise.
#'
#' @importFrom sf sf_use_s2 st_crs st_is_longlat
#' @noRd
-will_assume_planar = function(x) {
+will_assume_projected = function(x) {
(!is.na(st_crs(x)) && st_is_longlat(x)) && !sf_use_s2()
}
diff --git a/R/contract.R b/R/contract.R
new file mode 100644
index 00000000..5100fc97
--- /dev/null
+++ b/R/contract.R
@@ -0,0 +1,156 @@
+#' Contract groups of nodes in a spatial network
+#'
+#' Combine groups of nodes into a single node per group. The centroid such a
+#' group will be used by default as new geometry of the contracted node. If
+#' edges are spatially explicit, edge geometries are updated accordingly such
+#' that the valid spatial network structure is preserved.
+#'
+#' @param x An object of class \code{\link{sfnetwork}}.
+#'
+#' @param groups A group index for each node in x.
+#'
+#' @param simplify Should the network be simplified after contraction? Defaults
+#' to \code{TRUE}. This means that multiple edges and loop edges will be
+#' removed. Multiple edges are introduced by contraction when there are several
+#' connections between the same groups of nodes. Loop edges are introduced by
+#' contraction when there are connections within a group. Note however that
+#' setting this to \code{TRUE} also removes multiple edges and loop edges that
+#' already existed before contraction.
+#'
+#' @param compute_centroids Should the new geometry of each contracted group of
+#' nodes be the centroid of all group members? Defaults to \code{TRUE}. If set
+#' to \code{FALSE}, the geometry of the first node in each group will be used
+#' instead, which requires considerably less computing time.
+#'
+#' @param reconnect_edges Should the geometries of the edges be updated such
+#' they match the new node geometries? Defaults to \code{TRUE}. Only set this
+#' to \code{FALSE} if you know the node geometries did not change, otherwise
+#' the valid spatial network structure is broken.
+#'
+#' @param attribute_summary How should the attributes of contracted nodes be
+#' summarized? There are several options, see
+#' \code{\link[igraph]{igraph-attribute-combination}} for details.
+#'
+#' @param store_original_ids For each group of contracted nodes, should
+#' the indices of the original nodes be stored as an attribute of the new edge,
+#' in a column named \code{.tidygraph_node_index}? This is in line with the
+#' design principles of \code{tidygraph}. Defaults to \code{FALSE}.
+#'
+#' @param store_original_data For each group of contracted nodes, should
+#' the data of the original nodes be stored as an attribute of the new edge, in
+#' a column named \code{.orig_data}? This is in line with the design principles
+#' of \code{tidygraph}. Defaults to \code{FALSE}.
+#'
+#' @returns The contracted network as object of class \code{\link{sfnetwork}}.
+#'
+#' @importFrom igraph contract delete_edges delete_vertex_attr is_directed
+#' vertex_attr<- vertex_attr_names which_loop which_multiple
+#' @importFrom sf st_as_sf st_centroid st_combine
+#' @importFrom tibble as_tibble
+#' @importFrom tidygraph as_tbl_graph
+#' @export
+contract_nodes = function(x, groups, simplify = TRUE,
+ compute_centroids = TRUE, reconnect_edges = TRUE,
+ attribute_summary = "ignore",
+ store_original_ids = FALSE,
+ store_original_data = FALSE) {
+ # Add index columns if not present.
+ # These keep track of original node and edge indices.
+ x = add_original_ids(x)
+ # Extract nodes.
+ nodes = nodes_as_sf(x)
+ node_geomcol = attr(nodes, "sf_column")
+ node_geom = nodes[[node_geomcol]]
+ # If each group consists of only one node:
+ # --> We do not need to do any contraction.
+ if (! any(duplicated(groups))) {
+ # Store original node data in a .orig_data column if requested.
+ if (store_original_data) {
+ x = add_original_node_data(x, nodes)
+ }
+ # Remove original indices if requested.
+ if (! store_original_ids) {
+ x = delete_vertex_attr(x, ".tidygraph_node_index")
+ }
+ # Return x without contraction.
+ return(x)
+ }
+ ## ===========================
+ # STEP I: CONTRACT THE NODES
+ # # For this we simply rely on igraphs contract function
+ ## ===========================
+ # Update the attribute summary instructions.
+ # In the summarise attributes only real attribute columns were referenced.
+ # On top of those, we need to include:
+ # --> The tidygraph node index column.
+ if (! inherits(attribute_summary, "list")) {
+ attribute_summary = list(attribute_summary)
+ }
+ attribute_summary[".tidygraph_node_index"] = "concat"
+ # The geometries will be summarized at a later stage.
+ # However igraph does not know the geometries are special.
+ # We therefore temporarily remove the geometries before contracting.
+ x_tmp = delete_vertex_attr(x, node_geomcol)
+ # Contract with igraph::contract.
+ x_new = as_tbl_graph(contract(x_tmp, groups, attribute_summary))
+ ## =======================================
+ # STEP II: SUMMARIZE THE NODE GEOMETRIES
+ # Each contracted node should get a new geometry.
+ ## =======================================
+ # Extract the nodes from the contracted network.
+ new_nodes = as_tibble(x_new, "nodes", focused = FALSE)
+ # Add geometries to the new nodes.
+ # Geometries of contracted nodes are a summary of the original group members.
+ # Either the centroid or the geometry of the first member.
+ if (compute_centroids) {
+ centroid = function(i) if (length(i) > 1) st_centroid(st_combine(i)) else i
+ grouped_geoms = split(node_geom, groups)
+ names(grouped_geoms) = NULL
+ new_node_geom = do.call("c", lapply(grouped_geoms, centroid))
+ } else {
+ new_node_geom = node_geom[!duplicated(groups)]
+ }
+ new_nodes[node_geomcol] = list(new_node_geom)
+ new_nodes = st_as_sf(new_nodes, sf_column_name = node_geomcol)
+ ## ============================================
+ # STEP III: CONVERT BACK INTO A SPATIAL NETWORK
+ # Now we have the geometries of the new nodes.
+ # This means we can convert the contracted network into a sfnetwork again.
+ # We copy original attributes of x to not lose them.
+ ## ============================================
+ # First we remove multiple edges and loop edges if this was requested.
+ # Multiple edges occur when there are several connections between groups.
+ # Loop edges occur when there are connections within groups.
+ # Note however that original multiple and loop edges are also removed.
+ if (simplify) {
+ x_new = delete_edges(x_new, which(which_multiple(x_new) | which_loop(x_new)))
+ }
+ # Now add the spatially embedded nodes to the network.
+ # And copy original attributes (including the sfnetwork class).
+ node_data(x_new) = new_nodes
+ x_new = x_new %preserve_all_attrs% x
+ ## =======================================
+ # STEP IV: RECONNECT THE EDGE GEOMETRIES
+ # The geometries of the contracted nodes are updated.
+ # This means the edge geometries of their incident edges also need an update.
+ # Otherwise the valid spatial network structure is not preserved.
+ ## =======================================
+ if (reconnect_edges & has_explicit_edges(x)) {
+ if (! is_directed(x)) {
+ x_new = make_edges_follow_indices(x_new)
+ }
+ x_new = make_edges_valid(x_new)
+ }
+ ## ==============================================
+ # STEP V: POST-PROCESS AND RETURN
+ ## ==============================================
+ # Store original data if requested.
+ if (store_original_data) {
+ x_new = add_original_node_data(x_new, nodes)
+ }
+ # Remove original indices if requested.
+ if (! store_original_ids) {
+ x_new = drop_original_ids(x_new)
+ }
+ x_new
+}
\ No newline at end of file
diff --git a/R/cost.R b/R/cost.R
new file mode 100644
index 00000000..5bd62a40
--- /dev/null
+++ b/R/cost.R
@@ -0,0 +1,242 @@
+#' Compute a cost matrix of a spatial network
+#'
+#' Compute total travel costs of shortest paths between nodes in a spatial
+#' network.
+#'
+#' @param x An object of class \code{\link{sfnetwork}}.
+#'
+#' @param from The nodes where the paths should start. Evaluated by
+#' \code{\link{evaluate_node_query}}. By default, all nodes in the network are
+#' included.
+#'
+#' @param to The nodes where the paths should end. Evaluated by
+#' \code{\link{evaluate_node_query}}. By default, all nodes in the network are
+#' included.
+#'
+#' @param weights The edge weights to be used in the shortest path calculation.
+#' Evaluated by \code{\link{evaluate_weight_spec}}. The default is
+#' \code{\link{edge_length}}, which computes the geographic lengths of the
+#' edges.
+#'
+#' @param direction The direction of travel. Defaults to \code{'out'}, meaning
+#' that the direction given by the network is followed and costs are computed
+#' from the points given as argument \code{from}. May be set to \code{'in'},
+#' meaning that the opposite direction is followed an costs are computed
+#' towards the points given as argument \code{from}. May also be set to
+#' \code{'all'}, meaning that the network is considered to be undirected. This
+#' argument is ignored for undirected networks.
+#'
+#' @param Inf_as_NaN Should the cost values of unconnected nodes be stored as
+#' \code{NaN} instead of \code{Inf}? Defaults to \code{FALSE}.
+#'
+#' @param router The routing backend to use for the cost matrix computation.
+#' Currently supported options are \code{'igraph'} and \code{'dodgr'}. See
+#' Details.
+#'
+#' @param use_names If a column named \code{name} is present in the nodes
+#' table, should these names be used as row and column names in the matrix,
+#' instead of the node indices? Defaults to \code{FALSE}. Ignored when the
+#' nodes table does not have a column named \code{name}.
+#'
+#' @param ... Additional arguments passed on to the underlying function of the
+#' chosen routing backend. See Details.
+#'
+#' @details The sfnetworks package does not implement its own routing algorithms
+#' to compute cost matrices. Instead, it relies on "routing backends", i.e.
+#' other R packages that have implemented such algorithms. Currently two
+#' different routing backends are supported.
+#'
+#' The default is \code{\link[igraph]{igraph}}. This package supports
+#' many-to-many cost matrix computation with the \code{\link[igraph]{distances}}
+#' function. The igraph router does not support dual-weighted routing.
+#'
+#' The second supported routing backend is \code{\link[dodgr]{dodgr}}. This
+#' package supports many-to-many cost matrix computation with the
+#' \code{\link[dodgr]{dodgr_dists}} function. It also supports dual-weighted
+#' routing. The dodgr package is a conditional dependency of sfnetworks. Using
+#' the dodgr router requires the dodgr package to be installed.
+#'
+#' The default router can be changed by setting the \code{sfn_default_router}
+#' option.
+#'
+#' @seealso \code{\link{st_network_paths}}, \code{\link{st_network_travel}}
+#'
+#' @return An n times m numeric matrix where n is the length of the \code{from}
+#' argument, and m is the length of the \code{to} argument.
+#'
+#' @examples
+#' library(sf, quietly = TRUE)
+#' library(tidygraph, quietly = TRUE)
+#'
+#' net = as_sfnetwork(roxel, directed = FALSE) |>
+#' st_transform(3035)
+#'
+#' # Compute the network cost matrix between node pairs.
+#' # Note that geographic edge length is used as edge weights by default.
+#' st_network_cost(net, from = c(495, 121), to = c(495, 121))
+#'
+#' # st_network_distance is a synonym for st_network_cost with default weights.
+#' st_network_distance(net, from = c(495, 121), to = c(495, 121))
+#'
+#' # Compute the network cost matrix between spatial point features.
+#' # These are snapped to their nearest node before computing costs.
+#' p1 = st_geometry(net, "nodes")[495] + st_sfc(st_point(c(50, -50)))
+#' st_crs(p1) = st_crs(net)
+#' p2 = st_geometry(net, "nodes")[121] + st_sfc(st_point(c(-10, 100)))
+#' st_crs(p2) = st_crs(net)
+#'
+#' st_network_cost(net, from = c(p1, p2), to = c(p1, p2))
+#'
+#' # Use a node type query function to specify origins and/or destinations.
+#' st_network_cost(net, from = 499, to = node_is_connected(499))
+#'
+#' # Use a spatial edge measure to specify edge weights.
+#' # By default edge_length() is used.
+#' st_network_cost(net, c(p1, p2), c(p1, p2), weights = edge_displacement())
+#'
+#' # Use a column in the edges table to specify edge weights.
+#' # This uses tidy evaluation.
+#' net |>
+#' activate("edges") |>
+#' mutate(foo = runif(n(), min = 0, max = 1)) |>
+#' st_network_cost(c(p1, p2), c(p1, p2), weights = foo)
+#'
+#' # Compute the cost matrix without edge weights.
+#' # Here the cost is defined by the number of edges, ignoring space.
+#' st_network_cost(net, c(p1, p2), c(p1, p2), weights = NA)
+#'
+#' # Use the dodgr router for dual-weighted routing.
+#' paths = st_network_cost(net,
+#' from = c(p1, p2),
+#' to = c(p1, p2),
+#' weights = dual_weights(edge_segment_count(), edge_length()),
+#' router = "dodgr"
+#' )
+#'
+#' # Not providing any from or to points includes all nodes by default.
+#' with_graph(net, graph_order()) # Our network has 701 nodes.
+#' cost_matrix = st_network_cost(net)
+#' dim(cost_matrix)
+#'
+#' @export
+st_network_cost = function(x, from = node_ids(x), to = node_ids(x),
+ weights = edge_length(), direction = "out",
+ Inf_as_NaN = FALSE,
+ router = getOption("sfn_default_router", "igraph"),
+ use_names = FALSE, ...) {
+ UseMethod("st_network_cost")
+}
+
+#' @importFrom rlang enquo
+#' @export
+st_network_cost.sfnetwork = function(x, from = node_ids(x), to = node_ids(x),
+ weights = edge_length(),
+ direction = "out",
+ Inf_as_NaN = FALSE,
+ router = getOption("sfn_default_router", "igraph"),
+ use_names = FALSE, ...) {
+ # Evaluate the given from node query.
+ from = evaluate_node_query(x, enquo(from))
+ if (any(is.na(from))) raise_na_values("from")
+ # Evaluate the given to node query.
+ to = evaluate_node_query(x, enquo(to))
+ if (any(is.na(to))) raise_na_values("to")
+ # Evaluate the given weights specification.
+ weights = evaluate_weight_spec(x, enquo(weights))
+ # Compute the cost matrix.
+ compute_costs(
+ x, from, to, weights,
+ direction = direction,
+ Inf_as_NaN = Inf_as_NaN,
+ router = router,
+ use_names = use_names,
+ ...
+ )
+}
+
+#' @name st_network_cost
+#' @export
+st_network_distance = function(x, from = node_ids(x), to = node_ids(x),
+ direction = "out", Inf_as_NaN = FALSE,
+ router = getOption("sfn_default_router", "igraph"),
+ use_names = FALSE, ...) {
+ st_network_cost(
+ x, from, to,
+ weights = edge_length(),
+ direction = direction,
+ Inf_as_NaN = Inf_as_NaN,
+ router = router,
+ use_names = use_names,
+ ...
+ )
+}
+
+#' @importFrom units as_units deparse_unit
+compute_costs = function(x, from, to, weights, direction = "out",
+ Inf_as_NaN = FALSE,
+ router = getOption("sfn_default_router", "igraph"),
+ use_names = FALSE, ...) {
+ # Compute cost matrix with the given router.
+ costs = switch(
+ router,
+ igraph = igraph_costs(x, from, to, weights, direction, use_names, ...),
+ dodgr = dodgr_costs(x, from, to, weights, direction, use_names, ...),
+ raise_unknown_input("router", router, c("igraph", "dodgr"))
+ )
+ # Post-process and return.
+ # --> Convert Inf to NaN if requested.
+ # --> Attach units if the provided weights had units.
+ if (Inf_as_NaN) costs[is.infinite(costs)] = NaN
+ if (inherits(weights, "units")) {
+ as_units(costs, deparse_unit(weights))
+ } else {
+ costs
+ }
+}
+
+#' @importFrom igraph distances
+#' @importFrom methods hasArg
+igraph_costs = function(x, from, to, weights, direction = "out",
+ use_names = FALSE, ...) {
+ # The direction argument is used instead of igraphs mode argument.
+ # This means the mode argument should not be set.
+ if (hasArg("mode")) raise_unsupported_arg("mode", replacement = "direction")
+ # Call igraph::distances function to compute the cost matrix.
+ # Special attention is required if there are duplicated 'to' nodes:
+ # --> In igraph this cannot be handled.
+ # --> Therefore we call igraph::distances with unique 'to' nodes.
+ # --> Afterwards we copy cost values to duplicated 'to' nodes.
+ if(any(duplicated(to))) {
+ tou = unique(to)
+ mat = distances(x, from, tou, weights = weights, mode = direction, ...)
+ mat = mat[, match(to, tou), drop = FALSE]
+ } else {
+ mat = distances(x, from, to, weights = weights, mode = direction, ...)
+ }
+ # Drop node names as row and column names if not requested.
+ if (!use_names) {
+ rownames(mat) = from
+ colnames(mat) = to
+ }
+ mat
+}
+
+#' @importFrom igraph vertex_attr_names
+#' @importFrom rlang check_installed
+dodgr_costs = function(x, from, to, weights, direction = "out",
+ use_names = FALSE, ...) {
+ check_installed("dodgr") # Package dodgr is required for this function.
+ # Convert the network to dodgr format.
+ x_dodgr = sfnetwork_to_minimal_dodgr(x, weights, direction)
+ # Call dodgr::dodgr_dists to compute the cost matrix.
+ mat = dodgr::dodgr_dists(x_dodgr, as.character(from), as.character(to), ...)
+ # Assign infinite cost to paths that were not found.
+ mat[is.na(mat)] = Inf
+ # Use node names as row and column names if requested.
+ if (use_names && "name" %in% vertex_attr_names(x)) {
+ nnames = vertex_attr(x, "name")
+ rownames(mat) = nnames[from]
+ colnames(mat) = nnames[to]
+ }
+ mat
+}
diff --git a/R/create.R b/R/create.R
new file mode 100644
index 00000000..be63a38d
--- /dev/null
+++ b/R/create.R
@@ -0,0 +1,910 @@
+#' Create a sfnetwork
+#'
+#' \code{sfnetwork} is a tidy data structure for geospatial networks. It
+#' extends the \code{\link[tidygraph]{tbl_graph}} data structure for
+#' relational data into the domain of geospatial networks, with nodes and
+#' edges embedded in geographical space, and offers smooth integration with
+#' \code{\link[sf]{sf}} for spatial data analysis.
+#'
+#' @param nodes The nodes of the network. Should be an object of class
+#' \code{\link[sf]{sf}}, or directly convertible to it using
+#' \code{\link[sf]{st_as_sf}}. All features should have an associated geometry
+#' of type \code{POINT}.
+#'
+#' @param edges The edges of the network. May be an object of class
+#' \code{\link[sf]{sf}}, with all features having an associated geometry of
+#' type \code{LINESTRING}. It may also be a regular \code{\link{data.frame}} or
+#' \code{\link[tibble]{tbl_df}} object. In any case, the nodes at the ends of
+#' each edge must be referenced in a \code{to} and \code{from} column, as
+#' integers or characters. Integers should refer to the position of a node in
+#' the nodes table, while characters should refer to the name of a node stored
+#' in the column referred to in the \code{node_key} argument. Setting edges to
+#' \code{NULL} will create a network without edges.
+#'
+#' @param directed Should the constructed network be directed? Defaults to
+#' \code{TRUE}.
+#'
+#' @param node_key The name of the column in the nodes table that character
+#' represented \code{to} and \code{from} columns should be matched against. If
+#' \code{NA}, the first column is always chosen. This setting has no effect if
+#' \code{to} and \code{from} are given as integers. Defaults to \code{'name'}.
+#'
+#' @param edges_as_lines Should the edges be spatially explicit, i.e. have
+#' \code{LINESTRING} geometries stored in a geometry list column? If
+#' \code{NULL}, this will be automatically defined, by setting the argument to
+#' \code{TRUE} when the edges are given as an object of class
+#' \code{\link[sf]{sf}}, and \code{FALSE} otherwise. Defaults to \code{NULL}.
+#'
+#' @param compute_length Should the geographic length of the edges be stored in
+#' a column named \code{length}? Uses \code{\link[sf]{st_length}} to compute
+#' the length of the edge geometries when edges are spatially explicit, and
+#' \code{\link[sf]{st_distance}} to compute the distance between boundary nodes
+#' when edges are spatially implicit. If there is already a column named
+#' \code{length}, it will be overwritten. Please note that the values in this
+#' column are \strong{not} automatically recognized as edge weights. This needs
+#' to be specified explicitly when calling a function that uses edge weights.
+#' Defaults to \code{FALSE}.
+#'
+#' @param length_as_weight Deprecated, use \code{compute_length} instead.
+#'
+#' @param force Should network validity checks be skipped? Defaults to
+#' \code{FALSE}, meaning that network validity checks are executed when
+#' constructing the network. These checks guarantee a valid spatial network
+#' structure. For the nodes, this means that they all should have \code{POINT}
+#' geometries. In the case of spatially explicit edges, it is also checked that
+#' all edges have \code{LINESTRING} geometries, nodes and edges have the same
+#' CRS and boundary points of edges match their corresponding node coordinates.
+#' These checks are important, but also time consuming. If you are already sure
+#' your input data meet the requirements, the checks are unnecessary and can be
+#' turned off to improve performance.
+#'
+#' @param message Should informational messages (those messages that are
+#' neither warnings nor errors) be printed when constructing the network?
+#' Defaults to \code{TRUE}.
+#'
+#' @param ... Arguments passed on to \code{\link[sf]{st_as_sf}}, if nodes need
+#' to be converted into an \code{\link[sf]{sf}} object during construction.
+#'
+#' @return An object of class \code{sfnetwork}.
+#'
+#' @examples
+#' library(sf, quietly = TRUE)
+#'
+#' p1 = st_point(c(7, 51))
+#' p2 = st_point(c(7, 52))
+#' p3 = st_point(c(8, 52))
+#' nodes = st_as_sf(st_sfc(p1, p2, p3, crs = 4326))
+#'
+#' e1 = st_cast(st_union(p1, p2), "LINESTRING")
+#' e2 = st_cast(st_union(p1, p3), "LINESTRING")
+#' e3 = st_cast(st_union(p3, p2), "LINESTRING")
+#' edges = st_as_sf(st_sfc(e1, e2, e3, crs = 4326))
+#' edges$from = c(1, 1, 3)
+#' edges$to = c(2, 3, 2)
+#'
+#' # Default.
+#' sfnetwork(nodes, edges)
+#'
+#' # Undirected network.
+#' sfnetwork(nodes, edges, directed = FALSE)
+#'
+#' # Using character encoded from and to columns.
+#' nodes$name = c("city", "village", "farm")
+#' edges$from = c("city", "city", "farm")
+#' edges$to = c("village", "farm", "village")
+#' sfnetwork(nodes, edges, node_key = "name")
+#'
+#' # Spatially implicit edges.
+#' sfnetwork(nodes, edges, edges_as_lines = FALSE)
+#'
+#' # Store edge lenghts in a column named 'length'.
+#' sfnetwork(nodes, edges, compute_length = TRUE)
+#'
+#' @importFrom cli cli_abort
+#' @importFrom igraph edge_attr<-
+#' @importFrom lifecycle deprecated
+#' @importFrom rlang try_fetch
+#' @importFrom sf st_as_sf
+#' @importFrom tidygraph tbl_graph with_graph
+#' @export
+sfnetwork = function(nodes, edges = NULL, directed = TRUE, node_key = "name",
+ edges_as_lines = NULL, compute_length = FALSE,
+ length_as_weight = deprecated(),
+ force = FALSE, message = TRUE, ...) {
+ if (isTRUE(length_as_weight)) deprecate_length_as_weight("sfnetwork")
+ # Prepare nodes.
+ # If nodes is not an sf object:
+ # --> Try to convert it to an sf object.
+ # --> Arguments passed in ... will be passed on to st_as_sf.
+ if (! is_sf(nodes)) {
+ nodes = try_fetch(
+ st_as_sf(nodes, ...),
+ error = function(e) {
+ sferror = sub(".*:", "", e)
+ cli_abort(c(
+ "Failed to convert nodes to a {.cls sf} object.",
+ "x" = "The following error occured in {.fn sf::st_as_sf}:{sferror}"
+ ), call = call("sfnetwork"))
+ }
+ )
+ }
+ # Create network.
+ x_tbg = tbl_graph(nodes, edges, directed = directed, node_key = node_key)
+ x_sfn = structure(x_tbg, class = c("sfnetwork", class(x_tbg)))
+ # Post-process network. This includes:
+ # --> Checking if the network has a valid spatial network structure.
+ # --> Making edges spatially explicit or implicit if requested.
+ # --> Adding additional attributes if requested.
+ if (is_sf(edges)) {
+ # Add sf attributes to the edges table.
+ # They were removed when creating the tbl_graph.
+ edge_geom_colname(x_sfn) = attr(edges, "sf_column")
+ edge_agr(x_sfn) = attr(edges, "agr")
+ # Remove edge geometries if requested.
+ if (isFALSE(edges_as_lines)) {
+ x_sfn = drop_edge_geom(x_sfn)
+ }
+ # Run validity check after implicitizing edges.
+ if (! force) validate_network(x_sfn, message = message)
+ } else {
+ # Run validity check before explicitizing edges.
+ if (! force) validate_network(x_sfn, message = message)
+ # Add edge geometries if requested.
+ if (isTRUE(edges_as_lines)) {
+ x_sfn = make_edges_explicit(x_sfn)
+ }
+ }
+ if (compute_length) {
+ if ("length" %in% names(edges)) {
+ raise_overwrite("length")
+ }
+ edge_attr(x_sfn, "length") = with_graph(x_sfn, edge_length())
+ }
+ x_sfn
+}
+
+# Simplified construction function.
+# Must be sure that nodes and edges together form a valid sfnetwork.
+# ONLY FOR INTERNAL USE!
+
+#' @importFrom tidygraph tbl_graph
+sfnetwork_ = function(nodes, edges = NULL, directed = TRUE) {
+ x_tbg = tbl_graph(nodes, edges, directed = directed)
+ if (! is.null(edges)) {
+ edge_geom_colname(x_tbg) = attr(edges, "sf_column")
+ edge_agr(x_tbg) = attr(edges, "agr")
+ }
+ structure(x_tbg, class = c("sfnetwork", class(x_tbg)))
+}
+
+# Fast function to convert from tbl_graph to sfnetwork.
+# Must be sure that tbl_graph has already a valid sfnetwork structure.
+# ONLY FOR INTERNAL USE!
+
+tbg_to_sfn = function(x) {
+ class(x) = c("sfnetwork", class(x))
+ x
+}
+
+#' Convert a foreign object to a sfnetwork
+#'
+#' Convert a given object into an object of class \code{\link{sfnetwork}}.
+#'
+#' @param x Object to be converted into a \code{\link{sfnetwork}}.
+#'
+#' @param ... Additional arguments passed on to other functions.
+#'
+#' @return An object of class \code{\link{sfnetwork}}.
+#'
+#' @export
+as_sfnetwork = function(x, ...) {
+ UseMethod("as_sfnetwork")
+}
+
+#' @describeIn as_sfnetwork By default, the provided object is first converted
+#' into a \code{\link[tidygraph]{tbl_graph}} using
+#' \code{\link[tidygraph]{as_tbl_graph}}. Further conversion into an
+#' \code{\link{sfnetwork}} will work as long as the nodes can be converted to
+#' an \code{\link[sf]{sf}} object through \code{\link[sf]{st_as_sf}}. Arguments
+#' to \code{\link[sf]{st_as_sf}} can be provided as additional arguments and
+#' will be forwarded to \code{\link[sf]{st_as_sf}} through the
+#' \code{\link{sfnetwork}} construction function.
+#'
+#' @importFrom tidygraph as_tbl_graph
+#' @export
+as_sfnetwork.default = function(x, ...) {
+ as_sfnetwork(as_tbl_graph(x), ...)
+}
+
+#' @describeIn as_sfnetwork Convert spatial features of class
+#' \code{\link[sf]{sf}} directly into a \code{\link{sfnetwork}}.
+#' Supported geometry types are either \code{LINESTRING} or \code{POINT}. In
+#' the first case, the lines become the edges in the network, and nodes are
+#' placed at their boundaries. Additional arguments are forwarded to
+#' \code{\link{create_from_spatial_lines}}. In the latter case, the points
+#' become the nodes in the network, and are connected by edges according to a
+#' specified method. Additional arguments are forwarded to
+#' \code{\link{create_from_spatial_points}}.
+#'
+#' @examples
+#' # From an sf object with LINESTRING geometries.
+#' library(sf, quietly = TRUE)
+#'
+#' oldpar = par(no.readonly = TRUE)
+#' par(mar = c(1,1,1,1), mfrow = c(1,2))
+#'
+#' as_sfnetwork(roxel)
+#'
+#' plot(st_geometry(roxel))
+#' plot(as_sfnetwork(roxel))
+#'
+#' # From an sf object with POINT geometries.
+#' # For more examples see ?create_from_spatial_points.
+#' as_sfnetwork(mozart)
+#'
+#' plot(st_geometry(mozart))
+#' plot(as_sfnetwork(mozart))
+#'
+#' par(oldpar)
+#'
+#' @importFrom cli cli_abort
+#' @importFrom methods hasArg
+#' @export
+as_sfnetwork.sf = function(x, ...) {
+ if (hasArg("length_as_weight")) deprecate_length_as_weight("as_sfnetwork.sf")
+ if (are_linestrings(x)) {
+ if (hasArg("edges_as_lines")) deprecate_edges_as_lines()
+ create_from_spatial_lines(x, ...)
+ } else if (are_points(x)) {
+ create_from_spatial_points(x, ...)
+ } else {
+ cli_abort(c(
+ "Unsupported geometry types.",
+ "i" = "If geometries are edges, they should all be {.cls LINESTRING}.",
+ "i" = "If geometries are nodes, they should all be {.cls POINT}."
+ ))
+ }
+}
+
+#' @describeIn as_sfnetwork Convert spatial geometries of class
+#' \code{\link[sf]{sfc}} directly into a \code{\link{sfnetwork}}.
+#' Supported geometry types are either \code{LINESTRING} or \code{POINT}. In
+#' the first case, the lines become the edges in the network, and nodes are
+#' placed at their boundaries. Additional arguments are forwarded to
+#' \code{\link{create_from_spatial_lines}}. In the latter case, the points
+#' become the nodes in the network, and are connected by edges according to a
+#' specified method. Additional arguments are forwarded to
+#' \code{\link{create_from_spatial_points}}.
+#'
+#' @importFrom sf st_as_sf
+#' @export
+as_sfnetwork.sfc = function(x, ...) {
+ as_sfnetwork(st_as_sf(x), ...)
+}
+
+#' @describeIn as_sfnetwork Convert a directed graph of class
+#' \code{\link[dodgr]{dodgr_streetnet}} directly into a
+#' \code{\link{sfnetwork}}. Additional arguments are forwarded to
+#' \code{\link{dodgr_to_sfnetwork}}. This requires the
+#' \code{\link[dodgr:dodgr-package]{dodgr}} package to be installed.
+#'
+#' @examples
+#' # From a dodgr_streetnet object.
+#' if (require(dodgr, quietly = TRUE) & require(geodist, quietly = TRUE)) {
+#' as_sfnetwork(dodgr::weight_streetnet(hampi))
+#' }
+#'
+#' @export
+as_sfnetwork.dodgr_streetnet = function(x, ...) {
+ dodgr_to_sfnetwork(x, ...)
+}
+
+#' @describeIn as_sfnetwork Convert spatial linear networks of class
+#' \code{\link[spatstat.linnet]{linnet}} directly into a
+#' \code{\link{sfnetwork}}. Additional arguments are forwarded to
+#' \code{\link{create_from_spatial_lines}}. This requires the
+#' \code{\link[spatstat.geom:spatstat.geom-package]{spatstat.geom}} package
+#' to be installed.
+#'
+#' @examples
+#' # From a linnet object.
+#' if (require(spatstat.geom, quietly = TRUE)) {
+#' as_sfnetwork(simplenet)
+#' }
+#'
+#' @importFrom rlang check_installed is_installed
+#' @export
+as_sfnetwork.linnet = function(x, ...) {
+ check_installed("spatstat.geom")
+ check_installed("sf (>= 1.0)")
+ if (is_installed("spatstat")) check_installed("spatstat (>= 2.0)")
+ # The easiest approach is the same as for psp objects, i.e. converting the
+ # linnet object into a psp format and then applying the corresponding method.
+ x_psp = spatstat.geom::as.psp(x)
+ as_sfnetwork(x_psp, ...)
+}
+
+#' @describeIn as_sfnetwork Convert spatial line segments of class
+#' \code{\link[spatstat.geom]{psp}} directly into a \code{\link{sfnetwork}}.
+#' The lines become the edges in the network, and nodes are placed at their
+#' boundary points. Additional arguments are forwarded to
+#' \code{\link{create_from_spatial_lines}}.
+#'
+#' @examples
+#' # From a psp object.
+#' if (require(spatstat.geom, quietly = TRUE)) {
+#' set.seed(42)
+#' test_psp = psp(runif(10), runif(10), runif(10), runif(10), window=owin())
+#' as_sfnetwork(test_psp)
+#' }
+#'
+#' @importFrom rlang check_installed
+#' @importFrom sf st_as_sf st_collection_extract
+#' @export
+as_sfnetwork.psp = function(x, ...) {
+ check_installed("sf (>= 1.0)")
+ # The easiest method for transforming a Line Segment Pattern (psp) object
+ # into sfnetwork format is to transform it into sf format and then apply
+ # the usual methods.
+ x_sf = st_as_sf(x)
+ # x_sf is an sf object composed by 1 POLYGON (the window of the psp object)
+ # and several LINESTRINGs (the line segments). I'm not sure if and how we can
+ # use the window object so I will extract only the LINESTRINGs.
+ x_linestring = st_collection_extract(x_sf, "LINESTRING")
+ # Apply as_sfnetwork.sf.
+ as_sfnetwork(x_linestring, ...)
+}
+
+#' @describeIn as_sfnetwork Convert spatial networks of class
+#' \code{sfNetwork} from the \pkg{stplanr} package directly into a
+#' \code{\link{sfnetwork}}. This will extract the edges as an
+#' \code{\link[sf]{sf}} object and re-create the network structure. Additional
+#' arguments are forwarded to \code{\link{create_from_spatial_lines}}.The
+#' directness of the original network is preserved unless specified otherwise
+#' through the \code{directed} argument.
+#'
+#' @importFrom igraph is_directed
+#' @importFrom methods hasArg
+#' @export
+as_sfnetwork.sfNetwork = function(x, ...) {
+ if (hasArg("directed")) {
+ as_sfnetwork(x@sl, ...)
+ } else {
+ as_sfnetwork(x@sl, directed = is_directed(x@g), ...)
+ }
+}
+
+#' @describeIn as_sfnetwork Convert graph objects of class
+#' \code{\link[tidygraph]{tbl_graph}} directly into a \code{\link{sfnetwork}}.
+#' This will work if at least the nodes can be converted to an
+#' \code{\link[sf]{sf}} object through \code{\link[sf]{st_as_sf}}. Arguments
+#' to \code{\link[sf]{st_as_sf}} can be provided as additional arguments and
+#' will be forwarded to \code{\link[sf]{st_as_sf}} through the
+#' \code{\link{sfnetwork}} construction function. The directness of the original
+#' graph is preserved unless specified otherwise through the \code{directed}
+#' argument.
+#'
+#' @examples
+#' # From a tbl_graph with coordinate columns.
+#' library(tidygraph, quietly = TRUE)
+#'
+#' nodes = data.frame(lat = c(7, 7, 8), lon = c(51, 52, 52))
+#' edges = data.frame(from = c(1, 1, 3), to = c(2, 3, 2))
+#' tbl_net = tbl_graph(nodes, edges)
+#' as_sfnetwork(tbl_net, coords = c("lon", "lat"), crs = 4326)
+#'
+#' @importFrom igraph is_directed
+#' @importFrom methods hasArg
+#' @importFrom tibble as_tibble
+#' @export
+as_sfnetwork.tbl_graph = function(x, ...) {
+ nodes = as_tibble(x, "nodes", focused = FALSE)
+ edges = as_tibble(x, "edges", focused = FALSE)
+ if (hasArg("directed")) {
+ x_new = sfnetwork(nodes, edges, ...)
+ } else {
+ x_new = sfnetwork(nodes, edges, directed = is_directed(x), ...)
+ }
+ tbg_to_sfn(x_new %preserve_all_attrs% x)
+}
+
+#' @export
+as_sfnetwork.focused_tbl_graph = function(x, ...) {
+ x_new = NextMethod()
+ base_class = setdiff(class(x_new), "focused_tbl_graph")
+ class(x_new) = c("focused_tbl_graph", "sfnetwork", base_class)
+ x_new
+}
+
+#' Create a spatial network from linestring geometries
+#'
+#' @param x An object of class \code{\link[sf]{sf}} or \code{\link[sf]{sfc}}
+#' with \code{LINESTRING} geometries.
+#'
+#' @param directed Should the constructed network be directed? Defaults to
+#' \code{TRUE}.
+#'
+#' @param compute_length Should the geographic length of the edges be stored in
+#' a column named \code{length}? Uses \code{\link[sf]{st_length}} to compute
+#' the length of the edge geometries. If there is already a column named
+#' \code{length}, it will be overwritten. Please note that the values in this
+#' column are \strong{not} automatically recognized as edge weights. This needs
+#' to be specified explicitly when calling a function that uses edge weights.
+#' Defaults to \code{FALSE}.
+#'
+#' @param subdivide Should the given linestring geometries be subdivided at
+#' locations where an interior point is equal to an interior or boundary point
+#' in another feature? This will connect the features at those locations.
+#' Defaults to \code{FALSE}, meaning that features are only connected at their
+#' boundaries.
+#'
+#' @details It is assumed that the given linestring geometries form the edges
+#' in the network. Nodes are created at the line boundaries. Shared boundaries
+#' between multiple linestrings become the same node.
+#'
+#' @note By default sfnetworks rounds coordinates to 12 decimal places to
+#' determine spatial equality. You can influence this behavior by explicitly
+#' setting the precision of the linestrings using
+#' \code{\link[sf]{st_set_precision}}.
+#'
+#' @seealso \code{\link{create_from_spatial_points}}
+#'
+#' @return An object of class \code{\link{sfnetwork}}.
+#'
+#' @examples
+#' library(sf, quietly = TRUE)
+#'
+#' oldpar = par(no.readonly = TRUE)
+#' par(mar = c(1,1,1,1), mfrow = c(1,2))
+#'
+#' net = as_sfnetwork(roxel)
+#' net
+#'
+#' plot(st_geometry(roxel))
+#' plot(net)
+#'
+#' par(oldpar)
+#'
+#' @importFrom sf st_agr st_as_sf st_precision st_sf
+#' @export
+create_from_spatial_lines = function(x, directed = TRUE, compute_length = FALSE,
+ subdivide = FALSE) {
+ # The provided lines will form the edges of the network.
+ edges = st_as_sf(x)
+ # Decompose the given edges into the points that shape them.
+ edge_pts = sf_to_df(edges)
+ # Define which edge points are boundaries (i.e. nodes).
+ is_start = !duplicated(edge_pts$linestring_id)
+ is_end = !duplicated(edge_pts$linestring_id, fromLast = TRUE)
+ is_bound = is_start | is_end
+ # Subset those edge points that should become nodes
+ # And assign them a node index.
+ # Nodes at the same location should get the same index.
+ # If requested:
+ # --> First subdivide edges at shared interior points.
+ if (subdivide) {
+ if (will_assume_constant(x, st_agr(x), ignore_ids = FALSE)) {
+ raise_assume_constant("create_from_spatial_lines")
+ }
+ # Assign each edge point a unique location index.
+ # This will define which edge points are equal to each other.
+ edge_coords = edge_pts[names(edge_pts) %in% c("x", "y", "z", "m")]
+ edge_lids = st_match_points_df(edge_coords, st_precision(x))
+ edge_pts$lid = edge_lids
+ # Define where to subdivide the edges.
+ has_duplicate_desc = duplicated(edge_lids)
+ has_duplicate_asc = duplicated(edge_lids, fromLast = TRUE)
+ has_duplicate = has_duplicate_desc | has_duplicate_asc
+ is_split = has_duplicate & !is_bound
+ # Create the new set of edge points by duplicating split points.
+ new_edge_pts = create_new_edge_df(edge_pts, is_split)
+ # Define the new edge index of each new edge point.
+ new_edge_ids = create_new_edge_ids(new_edge_pts, is_split, "linestring_id")
+ # Construct the new edge linestring geometries.
+ new_edge_geoms = create_new_edge_geoms(new_edge_pts, new_edge_ids, edges)
+ # Define for each of the new edge points if its a boundary.
+ is_start = !duplicated(new_edge_ids)
+ is_end = !duplicated(new_edge_ids, fromLast = TRUE)
+ is_bound = is_start | is_end
+ # Update the given edges with the subdivided geometries.
+ edges = edges[new_edge_pts$linestring_id[is_start], ]
+ st_geometry(edges) = new_edge_geoms
+ # Subset the edge points to obtain only those that become a node.
+ node_pts = new_edge_pts[is_bound, ]
+ node_coords = node_pts[names(node_pts) %in% c("x", "y", "z", "m")]
+ # Assign each node a node index.
+ # Edge points sharing a location become the same node.
+ node_lids = node_pts$lid
+ indices = match(node_lids, unique(node_lids))
+ } else {
+ # Subset the edge points to obtain only those that become a node.
+ node_pts = edge_pts[is_bound, ]
+ node_coords = node_pts[names(node_pts) %in% c("x", "y", "z", "m")]
+ # Assign each node a node index.
+ # Edge points sharing a location become the same node.
+ indices = st_match_points_df(node_coords, st_precision(x))
+ }
+ # Convert the node coordinates into point geometry objects.
+ nodes = df_to_points(node_coords, x, select = FALSE)
+ # Define for each endpoint if it is a source or target node.
+ is_source = rep(c(TRUE, FALSE), length(nodes) / 2)
+ # Define for each edge which node is its source and target node.
+ if ("from" %in% colnames(edges)) raise_overwrite("from")
+ edges$from = indices[is_source]
+ if ("to" %in% colnames(edges)) raise_overwrite("to")
+ edges$to = indices[!is_source]
+ # Remove duplicated nodes from the nodes table.
+ nodes = nodes[!duplicated(indices)]
+ # Convert to sf object
+ nodes = sfc_to_sf(nodes, colname = attr(edges, "sf_column"))
+ # Create a network out of the created nodes and the provided edges.
+ # Force to skip network validity tests because we already know they pass.
+ sfnetwork(nodes, edges,
+ directed = directed,
+ edges_as_lines = TRUE,
+ compute_length = compute_length,
+ force = TRUE
+ )
+}
+
+#' Create a spatial network from point geometries
+#'
+#' @param x An object of class \code{\link[sf]{sf}} or \code{\link[sf]{sfc}}
+#' with \code{POINT} geometries.
+#'
+#' @param connections How to connect the given point geometries to each other?
+#' Can be specified either as an adjacency matrix, or as a character
+#' describing a specific method to define the connections. See Details.
+#'
+#' @param directed Should the constructed network be directed? Defaults to
+#' \code{TRUE}.
+#'
+#' @param edges_as_lines Should the created edges be spatially explicit, i.e.
+#' have \code{LINESTRING} geometries stored in a geometry list column? Defaults
+#' to \code{TRUE}.
+#'
+#' @param compute_length Should the geographic length of the edges be stored in
+#' a column named \code{length}? Uses \code{\link[sf]{st_length}} to compute
+#' the length of the edge geometries when edges are spatially explicit, and
+#' \code{\link[sf]{st_distance}} to compute the distance between boundary nodes
+#' when edges are spatially implicit. Please note that the values in this
+#' column are \strong{not} automatically recognized as edge weights. This needs
+#' to be specified explicitly when calling a function that uses edge weights.
+#' Defaults to \code{FALSE}.
+#'
+#' @param k The amount of neighbors to connect to if
+#' \code{connections = 'knn'}. Defaults to \code{1}, meaning that nodes are
+#' only connected to their nearest neighbor. Ignored for any other value of the
+#' \code{connected} argument.
+#'
+#' @details It is assumed that the given points form the nodes in the network.
+#' How those nodes are connected by edges depends on the \code{connections}
+#' argument.
+#'
+#' The connections can be specified through an adjacency matrix A, which is an
+#' n x n matrix with n being the number of nodes, and element Aij holding a
+#' \code{TRUE} value if there is an edge from node i to node j, and a
+#' \code{FALSE} value otherwise. In the case of undirected networks, the matrix
+#' is not tested for symmetry, and an edge will exist between node i and node j
+#' if either element Aij or element Aji is \code{TRUE}. Non-logical matrices
+#' are first converted into logical matrices using \code{\link{as.logical}},
+#' whenever possible.
+#'
+#' The provided adjacency matrix may also be sparse. This can be an object of
+#' one of the sparse matrix classes from the \pkg{Matrix} package, or a
+#' list-formatted sparse matrix. This is a list with one element per node,
+#' holding the integer indices of the nodes it is adjacent to. An example are
+#' \code{\link[sf]{sgbp}} objects. If the values are not integers, they are
+#' first converted into integers using \code{\link{as.integer}}, whenever
+#' possible.
+#'
+#' Alternatively, the connections can be specified by providing the name of a
+#' specific method that will create the adjacency matrix internally. Valid
+#' options are:
+#'
+#' \itemize{
+#' \item \code{complete}: All nodes are directly connected to each other.
+#' \item \code{sequence}: The nodes are sequentially connected to each other,
+#' meaning that the first node is connected to the second node, the second
+#' node is connected to the third node, et cetera.
+#' \item \code{mst}: The nodes are connected by their spatial
+#' \href{https://en.wikipedia.org/wiki/Minimum_spanning_tree}{minimum
+#' spanning tree}, i.e. the set of edges with the minimum total edge length
+#' required to connect all nodes. The tree is always constructed on an
+#' undirected network, regardless of the value of the \code{directed}.
+#' argument. If \code{directed = TRUE}, each edge is duplicated and reversed
+#' to ensure full connectivity of the network. Can also be specified as
+#' \code{minimum_spanning_tree}.
+#' \item \code{delaunay}: The nodes are connected by their
+#' \href{https://en.wikipedia.org/wiki/Delaunay_triangulation}{Delaunay
+#' triangulation}.
+#' Requires the \href{https://r-spatial.github.io/spdep/index.html}{spdep}
+#' package to be installed, and assumes planar coordinates.
+#' \item \code{gabriel}: The nodes are connected as a
+#' \href{https://en.wikipedia.org/wiki/Gabriel_graph}{Gabriel graph}.
+#' Requires the \href{https://r-spatial.github.io/spdep/index.html}{spdep}
+#' package to be installed, and assumes planar coordinates.
+#' \item \code{rn}: The nodes are connected as a
+#' \href{https://en.wikipedia.org/wiki/Relative_neighborhood_graph}{relative
+#' neighborhood graph}. Can also be specified as \code{relative_neighborhood}
+#' or \code{relative_neighbourhood}.
+#' Requires the \href{https://r-spatial.github.io/spdep/index.html}{spdep}
+#' package to be installed, and assumes planar coordinates.
+#' \item \code{knn}: Each node is connected to its k nearest neighbors, with
+#' \code{k} being specified through the \code{k} argument. By default,
+#' \code{k = 1}, meaning that the nodes are connected as a
+#' \href{https://en.wikipedia.org/wiki/Nearest_neighbor_graph}{nearest
+#' neighbor graph}. Can also be specified as \code{nearest_neighbors} or
+#' \code{nearest_neighbours}.
+#' Requires the \href{https://r-spatial.github.io/spdep/index.html}{spdep}
+#' package to be installed.
+#' }
+#'
+#' @seealso \code{\link{create_from_spatial_lines}}, \code{\link{play_geometric}}
+#'
+#' @return An object of class \code{\link{sfnetwork}}.
+#'
+#' @examples
+#' library(sf, quietly = TRUE)
+#'
+#' oldpar = par(no.readonly = TRUE)
+#' par(mar = c(1,1,1,1))
+#'
+#' pts = st_transform(mozart, 3035)
+#'
+#' # Using a custom adjacency matrix
+#' adj = matrix(c(rep(TRUE, 17), rep(rep(FALSE, 17), 16)), nrow = 17)
+#' net = as_sfnetwork(pts, connections = adj)
+#'
+#' plot(net)
+#'
+#' # Using a sparse adjacency matrix from a spatial predicate
+#' dst = units::set_units(300, "m")
+#' adj = st_is_within_distance(pts, dist = dst, remove_self = TRUE)
+#' net = as_sfnetwork(pts, connections = adj, directed = FALSE)
+#'
+#' plot(net)
+#'
+#' # Using pre-defined methods
+#' cnet = as_sfnetwork(pts, connections = "complete")
+#' snet = as_sfnetwork(pts, connections = "sequence")
+#' mnet = as_sfnetwork(pts, connections = "mst")
+#' dnet = as_sfnetwork(pts, connections = "delaunay")
+#' gnet = as_sfnetwork(pts, connections = "gabriel")
+#' rnet = as_sfnetwork(pts, connections = "rn")
+#' nnet = as_sfnetwork(pts, connections = "knn")
+#' knet = as_sfnetwork(pts, connections = "knn", k = 2)
+#'
+#' par(mar = c(1,1,1,1), mfrow = c(4,2))
+#'
+#' plot(cnet, main = "complete")
+#' plot(snet, main = "sequence")
+#' plot(mnet, main = "minimum spanning tree")
+#' plot(dnet, main = "delaunay triangulation")
+#' plot(gnet, main = "gabriel graph")
+#' plot(rnet, main = "relative neighborhood graph")
+#' plot(nnet, main = "nearest neighbor graph")
+#' plot(knet, main = "k nearest neighbor graph (k = 2)")
+#'
+#' par(oldpar)
+#'
+#' @export
+create_from_spatial_points = function(x, connections = "complete",
+ directed = TRUE, edges_as_lines = TRUE,
+ compute_length = FALSE, k = 1) {
+ if (is_single_string(connections)) {
+ nb_to_sfnetwork(
+ switch(
+ connections,
+ complete = complete_neighbors(x),
+ sequence = sequential_neighbors(x),
+ mst = mst_neighbors(x),
+ delaunay = delaunay_neighbors(x),
+ gabriel = gabriel_neighbors(x),
+ rn = relative_neighbors(x),
+ knn = nearest_neighbors(x, k),
+ minimum_spanning_tree = mst_neighbors(x),
+ relative_neighborhood = relative_neighbors(x),
+ relative_neighbourhood = relative_neighbors(x),
+ nearest_neighbors = nearest_neighbors(x, k),
+ nearest_neighbours = nearest_neighbors(x, k),
+ raise_unknown_input("connections", connections)
+ ),
+ nodes = x,
+ directed = directed,
+ edges_as_lines = edges_as_lines,
+ compute_length = compute_length,
+ force = TRUE
+ )
+ } else {
+ nb_to_sfnetwork(
+ custom_neighbors(x, connections),
+ nodes = x,
+ directed = directed,
+ edges_as_lines = edges_as_lines,
+ compute_length = compute_length,
+ force = FALSE
+ )
+ }
+}
+
+#' @importFrom cli cli_abort
+custom_neighbors = function(x, connections) {
+ if (is.matrix(connections)) {
+ adj_to_nb(connections)
+ } else if (inherits(connections, c("dgCMatrix", "dsCMatrix", "dtCMatrix"))) {
+ adj_to_nb(connections)
+ } else if (inherits(connections, c("sgbp", "nb", "list"))) {
+ connections
+ } else {
+ cli_abort(c(
+ "Invalid value for {.arg connections}.",
+ "i" = paste(
+ "Connections should be specified as a matrix, a sparse matrix,",
+ "or a single character."
+ )
+ ))
+ }
+}
+
+#' @importFrom sf st_geometry
+complete_neighbors = function(x) {
+ n_nodes = length(st_geometry(x))
+ # Create the adjacency matrix, with everything connected to everything.
+ connections = matrix(TRUE, ncol = n_nodes, nrow = n_nodes)
+ diag(connections) = FALSE # No loop edges.
+ # Return as neighbor list.
+ adj_to_nb(connections)
+}
+
+#' @importFrom sf st_geometry
+sequential_neighbors = function(x) {
+ # Each node in x is connected to the next node in x.
+ n_nodes = length(st_geometry(x))
+ lapply(c(1:(n_nodes - 1)), \(x) x + 1)
+}
+
+#' @importFrom igraph as_edgelist graph_from_adjacency_matrix mst
+#' @importFrom sf st_distance st_geometry
+mst_neighbors = function(x) {
+ # Create a complete graph.
+ n_nodes = length(st_geometry(x))
+ connections = upper.tri(matrix(FALSE, ncol = n_nodes, nrow = n_nodes))
+ net = graph_from_adjacency_matrix(connections, mode = "undirected")
+ # Compute distances between adjacent nodes for each edge in that graph.
+ dists = st_distance(x)[as_edgelist(net, names = FALSE)]
+ # Compute minimum spanning tree of the weighted complete graph.
+ mst = mst(net, weights = dists)
+ # Return as a neighbor list.
+ sfnetwork_to_nb(mst)
+}
+
+#' @importFrom rlang check_installed
+#' @importFrom sf st_geometry
+delaunay_neighbors = function(x) {
+ check_installed("spdep") # Package spdep is required for this function.
+ spdep::tri2nb(st_geometry(x))
+}
+
+#' @importFrom rlang check_installed
+#' @importFrom sf st_geometry
+gabriel_neighbors = function(x) {
+ check_installed("spdep") # Package spdep is required for this function.
+ spdep::graph2nb(spdep::gabrielneigh(st_geometry(x)), sym = TRUE)
+}
+
+#' @importFrom rlang check_installed
+#' @importFrom sf st_geometry
+relative_neighbors = function(x) {
+ check_installed("spdep") # Package spdep is required for this function.
+ spdep::graph2nb(spdep::relativeneigh(st_geometry(x)), sym = TRUE)
+}
+
+#' @importFrom rlang check_installed
+#' @importFrom sf st_geometry
+nearest_neighbors = function(x, k = 1) {
+ check_installed("spdep") # Package spdep is required for this function.
+ spdep::knn2nb(spdep::knearneigh(st_geometry(x), k = k), sym = FALSE)
+}
+
+#' Create random spatial networks
+#'
+#' Random spatial networks are created by randomly sampling nodes within a
+#' given area, and connecting them by edges according to a specified method.
+#'
+#' @param n The number of nodes to be sampled.
+#'
+#' @param bounds The spatial features within which the nodes should be sampled
+#' as object of class \code{\link[sf]{sf}}, \code{\link[sf]{sfc}},
+#' \code{\link[sf:st]{sfg}} or \code{\link[sf:st_bbox]{bbox}}. If set to
+#' \code{NULL}, nodes will be sampled within a unit square.
+#'
+#' @param edges_as_lines Should the created edges be spatially explicit, i.e.
+#' have \code{LINESTRING} geometries stored in a geometry list column? Defaults
+#' to \code{TRUE}.
+#'
+#' @param compute_length Should the geographic length of the edges be stored in
+#' a column named \code{length}? Uses \code{\link[sf]{st_length}} to compute
+#' the length of the edge geometries when edges are spatially explicit, and
+#' \code{\link[sf]{st_distance}} to compute the distance between boundary nodes
+#' when edges are spatially implicit. Please note that the values in this
+#' column are \strong{not} automatically recognized as edge weights. This needs
+#' to be specified explicitly when calling a function that uses edge weights.
+#' Defaults to \code{FALSE}.
+#'
+#' @param ... Additional arguments passed on to \code{\link[sf]{st_sample}}.
+#' Ignored if \code{bounds = NULL}.
+#'
+#' @name play_spatial
+NULL
+
+#' @describeIn play_spatial Creates a random geometric graph. Two nodes will be
+#' connected by an edge if the distance between them is within the given radius.
+#' If nodes are sampled on a unit square (i.e. when \code{bounds = NULL}) this
+#' radius is unitless. If bounds are given as a spatial feature, the radius is
+#' assumed to be in meters for geographic coordinates, and in the units of the
+#' coordinate reference system for projected coordinates. Alternatively, units
+#' can also be specified explicitly by providing a \code{\link[units]{units}}
+#' object.
+#'
+#' @param radius The radius within which nodes will be connected by an edge.
+#'
+#' @examples
+#' library(sf, quietly = TRUE)
+#'
+#' oldpar = par(no.readonly = TRUE)
+#' par(mar = c(1,1,1,1))
+#'
+#' # Sample 10 nodes on a unit square
+#' # Connect nodes by an edge if they are within 0.25 distance from each other
+#' net = play_geometric(10, 0.25)
+#' net
+#'
+#' plot(net)
+#'
+#' # Sample 10 nodes within a spatial bounding box
+#' # Connect nodes by an edge if they are within 1 km from each other
+#' net = play_geometric(10, units::set_units(1, "km"), bounds = st_bbox(roxel))
+#' net
+#'
+#' plot(net)
+#'
+#' par(oldpar)
+#'
+#' @importFrom sf st_is_within_distance st_sample st_sf
+#' @importFrom tidygraph play_geometry
+#' @export
+play_geometric = function(n, radius, bounds = NULL, edges_as_lines = TRUE,
+ compute_length = FALSE, ...) {
+ if (is.null(bounds)) {
+ # Use play_geometry to create and link n nodes inside a unit square.
+ x_tbg = play_geometry(n, radius)
+ # Convert to sfnetwork.
+ x_sfn = as_sfnetwork(
+ x_tbg,
+ directed = FALSE,
+ edges_as_lines = edges_as_lines,
+ compute_length = compute_length,
+ force = TRUE,
+ coords = c("x", "y")
+ )
+ } else {
+ # Sample n points within the given spatial feature.
+ pts = st_sample(bounds, n, ...)
+ # Define the connections between the points based on distance.
+ conns = st_is_within_distance(pts, dist = radius)
+ # Remove loop edges.
+ # Currently setting remove_self = TRUE in the predicate does not work ...
+ # ... if coordinates are geographic and s2 is used.
+ conns = mapply(setdiff, conns, seq_along(conns), SIMPLIFY = FALSE)
+ # Create the sfnetwork.
+ x_sfn = create_from_spatial_points(
+ st_sf(geometry = pts), # Use geometry as column name.
+ connections = conns,
+ directed = FALSE,
+ edges_as_lines = edges_as_lines,
+ compute_length = compute_length
+ )
+ }
+ x_sfn
+}
diff --git a/R/data.R b/R/data.R
new file mode 100644
index 00000000..d3eafd70
--- /dev/null
+++ b/R/data.R
@@ -0,0 +1,240 @@
+#' Extract the node or edge data from a spatial network
+#'
+#' @param x An object of class \code{\link{sfnetwork}}.
+#'
+#' @param focused Should only features that are in focus be extracted? Defaults
+#' to \code{TRUE}. See \code{\link[tidygraph]{focus}} for more information on
+#' focused networks.
+#'
+#' @return For the nodes, always an object of class \code{\link[sf]{sf}}. For
+#' the edges, an object of class \code{\link[sf]{sf}} if the edges are
+#' spatially explicit, and an object of class \code{\link[tibble]{tibble}}
+#' if the edges are spatially implicity and \code{require_sf = FALSE}.
+#'
+#' @examples
+#' net = as_sfnetwork(roxel[1:10, ])
+#' node_data(net)
+#' edge_data(net)
+#'
+#' @name data
+#' @export
+node_data = function(x, focused = TRUE) {
+ nodes_as_sf(x, focused = focused)
+}
+
+#' @name data
+#'
+#' @param require_sf Is an \code{\link[sf]{sf}} object required? This will make
+#' extraction of edge data fail if the edges are spatially implicit. Defaults
+#' to \code{FALSE}.
+#'
+#' @export
+edge_data = function(x, focused = TRUE, require_sf = FALSE) {
+ if (require_sf) {
+ edges_as_sf(x, focused = focused)
+ } else {
+ edges_as_spatial_tibble(x, focused = focused)
+ }
+}
+
+#' Count the number of nodes or edges in a network
+#'
+#' @param x An object of class \code{\link{sfnetwork}}, or any other network
+#' object inheriting from \code{\link[igraph]{igraph}}.
+#'
+#' @param focused Should only features that are in focus be counted? Defaults
+#' to \code{FALSE}. See \code{\link[tidygraph]{focus}} for more information on
+#' focused networks.
+#'
+#' @return An integer.
+#'
+#' @examples
+#' net = as_sfnetwork(roxel)
+#' n_nodes(net)
+#' n_edges(net)
+#'
+#' @name n
+#' @importFrom igraph vcount
+#' @export
+n_nodes = function(x, focused = FALSE) {
+ if (focused) {
+ fids = attr(x, "nodes_focus_index")
+ if (is.null(fids)) vcount(x) else length(fids)
+ } else {
+ vcount(x)
+ }
+}
+
+#' @name n
+#' @importFrom igraph ecount
+#' @export
+n_edges = function(x, focused = FALSE) {
+ if (focused) {
+ fids = attr(x, "edges_focus_index")
+ if (is.null(fids)) ecount(x) else length(fids)
+ } else {
+ ecount(x)
+ }
+}
+
+#' Get the column names of the node or edge data
+#'
+#' @param x An object of class \code{\link{sfnetwork}}.
+#'
+#' @param idxs Should the names of the columns storing indices of source and
+#' target nodes in the edges table (i.e. the from and to columns) be included?
+#' Defaults to \code{FALSE}.
+#'
+#' @param geom Should the geometry column be included? Defaults to \code{TRUE}.
+#'
+#' @return A character vector.
+#'
+#' @name colnames
+#' @importFrom igraph vertex_attr_names
+#' @noRd
+node_colnames = function(x, geom = TRUE) {
+ attrs = vertex_attr_names(x)
+ if (! geom) {
+ attrs = attrs[attrs != node_geom_colname(x)]
+ }
+ attrs
+}
+
+#' @name colnames
+#' @importFrom igraph edge_attr_names
+#' @noRd
+edge_colnames = function(x, idxs = FALSE, geom = TRUE) {
+ attrs = edge_attr_names(x)
+ if (idxs) {
+ attrs = c("from", "to", attrs)
+ }
+ if (! geom) {
+ geom_colname = edge_geom_colname(x)
+ if (! is.null(geom_colname)) {
+ attrs = attrs[attrs != geom_colname]
+ }
+ }
+ attrs
+}
+
+#' Query specific attribute column names in the node or edge data
+#'
+#' This function is not meant to be called directly, but used inside other
+#' functions that accept a attribute column query.
+#'
+#' @param data An object of class \code{\link{sfnetwork}}.
+#'
+#' @param query The query that defines for which attribute column names to
+#' extract, defused into a \code{\link[dplyr:topic-quosure]{quosure}}. The
+#' query is evaluated as a \code{\link[dplyr]{dplyr_tidy_select}} argument.
+#'
+#' @note The geometry column and any index column (e.g. from, to, or the
+#' tidygraph index columns added during morphing) are not considered attribute
+#' columns.
+#'
+#' @returns A character vector of queried attribute column names.
+#'
+#' @name evaluate_attribute_query
+#' @importFrom sf st_drop_geometry
+#' @importFrom tidyselect eval_select
+#' @noRd
+evaluate_node_attribute_query = function(x, query) {
+ nodes = st_drop_geometry(nodes_as_sf(x))
+ exclude = c(
+ ".tidygraph_node_index",
+ ".tidygraph_edge_index",
+ ".tidygraph_index",
+ ".tbl_graph_index",
+ ".sfnetwork_index"
+ )
+ node_attrs = nodes[, !(names(nodes) %in% exclude)]
+ names(node_attrs)[eval_select(query, node_attrs)]
+}
+
+#' @name evaluate_attribute_query
+#' @importFrom sf st_drop_geometry
+#' @importFrom tidyselect eval_select
+#' @noRd
+evaluate_edge_attribute_query = function(x, query) {
+ edges = st_drop_geometry(edge_data(x))
+ exclude = c(
+ "from",
+ "to",
+ ".tidygraph_node_index",
+ ".tidygraph_edge_index",
+ ".tidygraph_index",
+ ".tbl_graph_index",
+ ".sfnetwork_index"
+ )
+ edge_attrs = edges[, !(names(edges) %in% exclude)]
+ names(edge_attrs)[eval_select(query, edge_attrs)]
+}
+
+#' Set or replace node or edge data in a spatial network
+#'
+#' @param x An object of class \code{\link{sfnetwork}}.
+#'
+#' @param value A table in which each column is an attribute to be set. For the
+#' nodes this table has to be of class \code{\link[sf]{sf}}. For the edges it
+#' can also be a \code{\link{data.frame}} or \code{\link[tibble]{tibble}}.
+#'
+#' @return An object of class \code{\link{sfnetwork}} with updated attributes.
+#'
+#' @details This function is only meant to update attributes of nodes or edges
+#' and not to change the graph morphology. This means that when setting edge
+#' data the columns storing indices of start and end nodes (i.e. the from and
+#' to columns) should not be included. The geometry column, however, should be.
+#'
+#' @name set_data
+#' @importFrom igraph vertex_attr<-
+#' @noRd
+`node_data<-` = function(x, value) {
+ vertex_attr(x) = as.list(value)
+ x
+}
+
+#' @name set_data
+#' @importFrom igraph edge_attr<-
+#' @noRd
+`edge_data<-` = function(x, value) {
+ edge_attr(x) = as.list(value[, !names(value) %in% c("from", "to")])
+ x
+}
+
+#' Add original data to merged features
+#'
+#' When morphing networks into a different structure, groups of nodes or edges
+#' may be merged into a single feature. In those cases, there is always the
+#' option to store the data of the original features in a column named
+#' \code{.orig_data}.
+#'
+#' @param x The new network as object of class \code{\link{sfnetwork}}.
+#'
+#' @param orig The original features as object of class \code{\link[sf]{sf}}.
+#'
+#' @return The new network with the original data stored in a column named
+#' \code{.orig_data}.
+#'
+#' @name add_original_data
+#' @noRd
+add_original_node_data = function(x, orig) {
+ # Store the original node data in a .orig_data column.
+ new_nodes = node_data(x, focused = FALSE)
+ orig$.tidygraph_node_index = NULL
+ copy_data = function(i) orig[i, , drop = FALSE]
+ new_nodes$.orig_data = lapply(new_nodes$.tidygraph_node_index, copy_data)
+ node_data(x) = new_nodes
+ x
+}
+
+#' @name add_original_data
+#' @noRd
+add_original_edge_data = function(x, orig) {
+ # Store the original edge data in a .orig_data column.
+ new_edges = edge_data(x, focused = FALSE)
+ orig$.tidygraph_edge_index = NULL
+ copy_data = function(i) orig[i, , drop = FALSE]
+ new_edges$.orig_data = lapply(new_edges$.tidygraph_edge_index, copy_data)
+ edge_data(x) = new_edges
+ x
+}
diff --git a/R/dodgr.R b/R/dodgr.R
new file mode 100644
index 00000000..cae2448a
--- /dev/null
+++ b/R/dodgr.R
@@ -0,0 +1,143 @@
+#' Conversion between dodgr streetnets and sfnetworks
+#'
+#' The \code{\link[dodgr:dodgr-package]{dodgr}} package is designed for routing
+#' on directed graphs, and is known for its fast computations of cost matrices,
+#' shortest paths, and more. In sfnetwork, dodgr can be chosen as a routing
+#' backend.
+#'
+#' @param x For the conversion to sfnetwork: an object of class
+#' \code{\link[dodgr]{dodgr_streetnet}}. For the conversion from sfnetwork: an
+#' object of class \code{\link{sfnetwork}}.
+#'
+#' @param edges_as_lines Should the created edges be spatially explicit, i.e.
+#' have \code{LINESTRING} geometries stored in a geometry list column? Defaults
+#' to \code{TRUE}.
+#'
+#' @param weights The edge weights to be stored in the dodgr streetnet.
+#' Evaluated by \code{\link{evaluate_weight_spec}}. The default is
+#' \code{\link{edge_length}}, which computes the geographic lengths of the
+#' edges. Dual-weights can be provided through \code{\link{dual_weights}}.
+#'
+#' @param time Are the provided weights time values? If \code{TRUE}, they will
+#' be stored in a column named 'time' rather than 'd'. Defaults to \code{FALSE}.
+#'
+#' @note The \code{\link[dodgr:dodgr-package]{dodgr}} package is designed for
+#' directed graphs. If the provided \code{\link{sfnetwork}} object is
+#' undirected, it is made directed by duplicating and reversing each edge.
+#'
+#' @return For the conversion to sfnetwork: An object of class
+#' \code{\link{sfnetwork}}. For the conversion from sfnetwork: an object of
+#' class \code{\link[dodgr]{dodgr_streetnet}}.
+#'
+#' @name sfnetwork_to_dodgr
+NULL
+
+#' @name sfnetwork_to_dodgr
+#' @importFrom rlang check_installed
+#' @export
+dodgr_to_sfnetwork = function(x, edges_as_lines = TRUE) {
+ check_installed("dodgr") # Package dodgr is required for this function.
+ as_sfnetwork(
+ dodgr::dodgr_to_tidygraph(x),
+ force = TRUE,
+ coords = c("x", "y"),
+ crs = 4326,
+ edges_as_lines = edges_as_lines
+ )
+}
+
+#' @name sfnetwork_to_dodgr
+#' @importFrom igraph is_directed
+#' @importFrom rlang enquo
+#' @importFrom sf st_coordinates st_drop_geometry st_transform
+#' @export
+sfnetwork_to_dodgr = function(x, weights = edge_length(), time = FALSE) {
+ # Extract node geometries and edge data.
+ # Note that dodgr requires coordinates to be in EPSG:4326.
+ node_geom = st_transform(pull_node_geom(x), 4326)
+ node_coords = st_coordinates(node_geom)
+ edges = st_drop_geometry(edge_data(x, focused = FALSE))
+ # Parse the given edge weights.
+ # Dual-weights can be given through a dual-weights object.
+ weights = evaluate_weight_spec(x, enquo(weights))
+ if (inherits(weights, "dual_weights")) {
+ dual = TRUE
+ d = weights$reported
+ w = weights$actual
+ } else {
+ dual = FALSE
+ d = weights
+ }
+ # Initialize the output data frame.
+ # If x is undirected:
+ # --> It is made directed by duplicating and reversing each edge.
+ if (is_directed(x)) {
+ fids = edges$from
+ tids = edges$to
+ out = edges[, -c(1, 2)]
+ } else {
+ fids = c(edges$from, edges$to)
+ tids = c(edges$to, edges$from)
+ d = rep(d, 2)
+ if (dual) w = rep(w, 2)
+ out = edges[rep(seq_len(nrow(edges)), 2), -c(1, 2)]
+ }
+ # Fill the output data frame.
+ if (time) {
+ if (dual) out$time_weighted = w
+ out$time = d
+ } else {
+ if (dual) out$d_weighted = w
+ out$d = d
+ }
+ out$to_lat = node_coords[, "Y"][tids]
+ out$to_lon = node_coords[, "X"][tids]
+ out$to_id = as.character(tids)
+ out$from_lat = node_coords[, "Y"][fids]
+ out$from_lon = node_coords[, "X"][fids]
+ out$from_id = as.character(fids)
+ # Invert column order.
+ out = out[, order(ncol(out):1)]
+ # Return as a dodgr_streetnet.
+ class(out) = c("dodgr_streetnet", "data.frame")
+ out
+}
+
+#' @importFrom igraph as_edgelist is_directed
+sfnetwork_to_minimal_dodgr = function(x, weights, direction = "out") {
+ edgelist = as_edgelist(x, names = FALSE)
+ if (inherits(weights, "dual_weights")) {
+ dual = TRUE
+ d = weights$reported
+ w = weights$actual
+ } else {
+ dual = FALSE
+ d = weights
+ }
+ if (!is_directed(x) | direction == "all") {
+ x_dodgr = data.frame(
+ from = as.character(c(edgelist[, 1], edgelist[, 2])),
+ to = as.character(c(edgelist[, 2], edgelist[, 1])),
+ d = rep(d, 2)
+ )
+ if (dual) x_dodgr$w = rep(w, 2)
+ } else {
+ if (direction == "out") {
+ x_dodgr = data.frame(
+ from = as.character(edgelist[, 1]),
+ to = as.character(edgelist[, 2]),
+ d = d
+ )
+ } else if (direction == "in") {
+ x_dodgr = data.frame(
+ from = as.character(edgelist[, 2]),
+ to = as.character(edgelist[, 1]),
+ d = d
+ )
+ } else {
+ raise_unknown_input("direction", direction, c("out", "in", "all"))
+ }
+ if (dual) x_dodgr$w = w
+ }
+ x_dodgr
+}
\ No newline at end of file
diff --git a/R/edge.R b/R/edge.R
index 6c93ec1e..d2cd7c5a 100644
--- a/R/edge.R
+++ b/R/edge.R
@@ -1,8 +1,6 @@
#' Query spatial edge measures
#'
-#' These functions are a collection of specific spatial edge measures, that
-#' form a spatial extension to edge measures in
-#' \code{\link[tidygraph:tidygraph-package]{tidygraph}}.
+#' These functions are a collection of edge measures in spatial networks.
#'
#' @details Just as with all query functions in tidygraph, spatial edge
#' measures are meant to be called inside tidygraph verbs such as
@@ -32,12 +30,12 @@ NULL
#'
#' net = as_sfnetwork(roxel)
#'
-#' net %>%
-#' activate("edges") %>%
+#' net |>
+#' activate(edges) |>
#' mutate(azimuth = edge_azimuth())
#'
-#' net %>%
-#' activate("edges") %>%
+#' net |>
+#' activate(edges) |>
#' mutate(azimuth = edge_azimuth(degrees = TRUE))
#'
#' @importFrom lwgeom st_geod_azimuth
@@ -47,7 +45,7 @@ NULL
edge_azimuth = function(degrees = FALSE) {
require_active_edges()
x = .G()
- bounds = edge_boundary_nodes(x)
+ bounds = edge_incident_geoms(x, focused = TRUE)
values = st_geod_azimuth(bounds)[seq(1, length(bounds), 2)]
if (degrees) values = set_units(values, "degrees")
values
@@ -62,8 +60,8 @@ edge_azimuth = function(degrees = FALSE) {
#' \code{NaN} instead of \code{Inf}? Defaults to \code{FALSE}.
#'
#' @examples
-#' net %>%
-#' activate("edges") %>%
+#' net |>
+#' activate(edges) |>
#' mutate(circuity = edge_circuity())
#'
#' @importFrom sf st_length
@@ -73,20 +71,30 @@ edge_azimuth = function(degrees = FALSE) {
edge_circuity = function(Inf_as_NaN = FALSE) {
require_active_edges()
x = .G()
- # Calculate circuity.
- values = st_length(pull_edge_geom(x)) / straight_line_distance(x)
- # Drop units since circuity is unitless (it is a ratio of m/m).
- if (inherits(values, "units")) values = drop_units(values)
- # Replace Inf values by NaN if requested.
- if (Inf_as_NaN) values[is.infinite(values)] = NaN
+ if (has_explicit_edges(x)) {
+ # Compute circuity as the ratio between length and displacement.
+ length = st_length(pull_edge_geom(x, focused = TRUE))
+ sldist = straight_line_distance(x)
+ values = length / sldist
+ # Drop units since circuity is unitless (it is a ratio of m/m).
+ if (inherits(values, "units")) values = drop_units(values)
+ # Replace Inf values by NaN if requested.
+ if (Inf_as_NaN) values[is.infinite(values)] = NaN
+ } else {
+ # Implicit edges are always straight lines, i.e. circuity = 0.
+ values = rep(0, n_edges(x, focused = TRUE))
+ }
values
}
#' @describeIn spatial_edge_measures The length of an edge linestring geometry
-#' as calculated by \code{\link[sf]{st_length}}.
+#' as calculated by \code{\link[sf]{st_length}}. If edges are spatially
+#' implicit, the straight-line distance between its boundary nodes is computed
+#' instead, using \code{\link[sf]{st_distance}}.
+#'
#' @examples
-#' net %>%
-#' activate("edges") %>%
+#' net |>
+#' activate(edges) |>
#' mutate(length = edge_length())
#'
#' @importFrom sf st_length
@@ -96,7 +104,7 @@ edge_length = function() {
require_active_edges()
x = .G()
if (has_explicit_edges(x)) {
- st_length(pull_edge_geom(x))
+ st_length(pull_edge_geom(x, focused = TRUE))
} else {
straight_line_distance(x)
}
@@ -104,17 +112,17 @@ edge_length = function() {
#' @describeIn spatial_edge_measures The straight-line distance between the two
#' boundary nodes of an edge, as calculated by \code{\link[sf]{st_distance}}.
+#'
#' @examples
-#' net %>%
-#' activate("edges") %>%
+#' net |>
+#' activate(edges) |>
#' mutate(displacement = edge_displacement())
#'
#' @importFrom tidygraph .G
#' @export
edge_displacement = function() {
require_active_edges()
- x = .G()
- straight_line_distance(x)
+ straight_line_distance(.G())
}
#' @importFrom sf st_distance
@@ -123,39 +131,60 @@ straight_line_distance = function(x) {
nodes = pull_node_geom(x)
# Get the indices of the boundary nodes of each edge.
# Returns a matrix with source ids in column 1 and target ids in column 2.
- idxs = edge_boundary_node_indices(x, matrix = TRUE)
+ idxs = edge_incident_ids(x, focused = TRUE, matrix = TRUE)
# Calculate distances pairwise.
st_distance(nodes[idxs[, 1]], nodes[idxs[, 2]], by_element = TRUE)
}
+#' @describeIn spatial_edge_measures The number of segments contained in the
+#' linestring geometry of an edge. Segments are those parts of a linestring
+#' geometry that do not contain any interior points.
+#'
+#' @examples
+#' net |>
+#' activate(edges) |>
+#' mutate(n_segs = edge_segment_count())
+#'
+#' @importFrom tidygraph .G
+#' @export
+edge_segment_count = function() {
+ require_active_edges()
+ geoms = pull_edge_geom(.G(), focused = TRUE)
+ lengths(geoms) / 2 - 1
+}
+
#' Query edges with spatial predicates
#'
#' These functions allow to interpret spatial relations between edges and
#' other geospatial features directly inside \code{\link[tidygraph]{filter}}
#' and \code{\link[tidygraph]{mutate}} calls. All functions return a logical
#' vector of the same length as the number of edges in the network. Element i
-#' in that vector is \code{TRUE} whenever \code{any(predicate(x[i], y[j]))} is
-#' \code{TRUE}. Hence, in the case of using \code{edge_intersects}, element i
-#' in the returned vector is \code{TRUE} when edge i intersects with any of
-#' the features given in y.
+#' in that vector is \code{TRUE} whenever the chosen spatial predicate applies
+#' to the spatial relation between the i-th edge and any of the features in
+#' \code{y}.
#'
#' @param y The geospatial features to test the edges against, either as an
#' object of class \code{\link[sf]{sf}} or \code{\link[sf]{sfc}}.
#'
#' @param ... Arguments passed on to the corresponding spatial predicate
-#' function of sf. See \code{\link[sf]{geos_binary_pred}}.
+#' function of sf. See \code{\link[sf]{geos_binary_pred}}. The argument
+#' \code{sparse} should not be set.
#'
#' @return A logical vector of the same length as the number of edges in the
#' network.
#'
#' @details See \code{\link[sf]{geos_binary_pred}} for details on each spatial
-#' predicate. Just as with all query functions in tidygraph, these functions
-#' are meant to be called inside tidygraph verbs such as
-#' \code{\link[tidygraph]{mutate}} or \code{\link[tidygraph]{filter}}, where
-#' the network that is currently being worked on is known and thus not needed
-#' as an argument to the function. If you want to use an algorithm outside of
-#' the tidygraph framework you can use \code{\link[tidygraph]{with_graph}} to
-#' set the context temporarily while the algorithm is being evaluated.
+#' predicate. The function \code{edge_is_nearest} instead wraps around
+#' \code{\link[sf]{st_nearest_feature}} and returns \code{TRUE} for element i
+#' if the i-th edge is the nearest edge to any of the features in \code{y}.
+#'
+#' Just as with all query functions in tidygraph, these functions are meant to
+#' be called inside tidygraph verbs such as \code{\link[tidygraph]{mutate}} or
+#' \code{\link[tidygraph]{filter}}, where the network that is currently being
+#' worked on is known and thus not needed as an argument to the function. If
+#' you want to use an algorithm outside of the tidygraph framework you can use
+#' \code{\link[tidygraph]{with_graph}} to set the context temporarily while the
+#' algorithm is being evaluated.
#'
#' @note Note that \code{edge_is_within_distance} is a wrapper around the
#' \code{st_is_within_distance} predicate from sf. Hence, it is based on
@@ -166,7 +195,7 @@ straight_line_distance = function(x) {
#' library(tidygraph, quietly = TRUE)
#'
#' # Create a network.
-#' net = as_sfnetwork(roxel) %>%
+#' net = as_sfnetwork(roxel) |>
#' st_transform(3035)
#'
#' # Create a geometry to test against.
@@ -175,13 +204,13 @@ straight_line_distance = function(x) {
#' p3 = st_point(c(4151756, 3207506))
#' p4 = st_point(c(4151774, 3208031))
#'
-#' poly = st_multipoint(c(p1, p2, p3, p4)) %>%
-#' st_cast('POLYGON') %>%
+#' poly = st_multipoint(c(p1, p2, p3, p4)) |>
+#' st_cast('POLYGON') |>
#' st_sfc(crs = 3035)
#'
#' # Use predicate query function in a filter call.
-#' intersects = net %>%
-#' activate(edges) %>%
+#' intersects = net |>
+#' activate(edges) |>
#' filter(edge_intersects(poly))
#'
#' oldpar = par(no.readonly = TRUE)
@@ -191,118 +220,435 @@ straight_line_distance = function(x) {
#' par(oldpar)
#'
#' # Use predicate query function in a mutate call.
-#' net %>%
-#' activate(edges) %>%
-#' mutate(disjoint = edge_is_disjoint(poly)) %>%
+#' net |>
+#' activate(edges) |>
+#' mutate(disjoint = edge_is_disjoint(poly)) |>
#' select(disjoint)
#'
+#' # Use predicate query function directly.
+#' intersects = with_graph(net, edge_intersects(poly))
+#' head(intersects)
+#'
#' @name spatial_edge_predicates
NULL
#' @name spatial_edge_predicates
#' @importFrom sf st_intersects
+#' @importFrom tidygraph .G
#' @export
edge_intersects = function(y, ...) {
require_active_edges()
- x = .G()
- lengths(st_intersects(pull_edge_geom(x), y, ...)) > 0
+ evaluate_edge_predicate(st_intersects, .G(), y, ...)
}
#' @name spatial_edge_predicates
#' @importFrom sf st_disjoint
+#' @importFrom tidygraph .G
#' @export
edge_is_disjoint = function(y, ...) {
require_active_edges()
- x = .G()
- lengths(st_disjoint(pull_edge_geom(x), y, ...)) > 0
+ evaluate_edge_predicate(st_disjoint, .G(), y, ...)
}
#' @name spatial_edge_predicates
#' @importFrom sf st_touches
+#' @importFrom tidygraph .G
#' @export
edge_touches = function(y, ...) {
require_active_edges()
- x = .G()
- lengths(st_touches(pull_edge_geom(x), y, ...)) > 0
+ evaluate_edge_predicate(st_touches, .G(), y, ...)
}
#' @name spatial_edge_predicates
#' @importFrom sf st_crosses
+#' @importFrom tidygraph .G
#' @export
edge_crosses = function(y, ...) {
require_active_edges()
- x = .G()
- lengths(st_crosses(pull_edge_geom(x), y, ...)) > 0
+ evaluate_edge_predicate(st_crosses, .G(), y, ...)
}
#' @name spatial_edge_predicates
#' @importFrom sf st_within
+#' @importFrom tidygraph .G
#' @export
edge_is_within = function(y, ...) {
require_active_edges()
- x = .G()
- lengths(st_within(pull_edge_geom(x), y, ...)) > 0
+ evaluate_edge_predicate(st_within, .G(), y, ...)
}
#' @name spatial_edge_predicates
#' @importFrom sf st_contains
+#' @importFrom tidygraph .G
#' @export
edge_contains = function(y, ...) {
require_active_edges()
- x = .G()
- lengths(st_contains(pull_edge_geom(x), y, ...)) > 0
+ evaluate_edge_predicate(st_contains, .G(), y, ...)
}
#' @name spatial_edge_predicates
#' @importFrom sf st_contains_properly
+#' @importFrom tidygraph .G
#' @export
edge_contains_properly = function(y, ...) {
require_active_edges()
- x = .G()
- lengths(st_contains_properly(pull_edge_geom(x), y, ...)) > 0
+ evaluate_edge_predicate(st_contains_properly, .G(), y, ...)
}
#' @name spatial_edge_predicates
#' @importFrom sf st_overlaps
+#' @importFrom tidygraph .G
#' @export
edge_overlaps = function(y, ...) {
require_active_edges()
- x = .G()
- lengths(st_overlaps(pull_edge_geom(x), y, ...)) > 0
+ evaluate_edge_predicate(st_overlaps, .G(), y, ...)
}
#' @name spatial_edge_predicates
#' @importFrom sf st_equals
+#' @importFrom tidygraph .G
#' @export
edge_equals = function(y, ...) {
require_active_edges()
- x = .G()
- lengths(st_equals(pull_edge_geom(x), y, ...)) > 0
+ evaluate_edge_predicate(st_equals, .G(), y, ...)
}
#' @name spatial_edge_predicates
#' @importFrom sf st_covers
+#' @importFrom tidygraph .G
#' @export
edge_covers = function(y, ...) {
require_active_edges()
- x = .G()
- lengths(st_covers(pull_edge_geom(x), y, ...)) > 0
+ evaluate_edge_predicate(st_covers, .G(), y, ...)
}
#' @name spatial_edge_predicates
#' @importFrom sf st_covered_by
+#' @importFrom tidygraph .G
#' @export
edge_is_covered_by = function(y, ...) {
require_active_edges()
- x = .G()
- lengths(st_covered_by(pull_edge_geom(x), y, ...)) > 0
+ evaluate_edge_predicate(st_covered_by, .G(), y, ...)
}
#' @name spatial_edge_predicates
#' @importFrom sf st_is_within_distance
+#' @importFrom tidygraph .G
#' @export
edge_is_within_distance = function(y, ...) {
+ require_active_edges()
+ evaluate_edge_predicate(st_is_within_distance, .G(), y, ...)
+}
+
+#' @name spatial_edge_predicates
+#' @importFrom tidygraph .G
+#' @export
+edge_is_nearest = function(y) {
require_active_edges()
x = .G()
- lengths(st_is_within_distance(pull_edge_geom(x), y, ...)) > 0
+ vec = rep(FALSE, n_edges(x))
+ vec[nearest_edge_ids(x, y, focused = FALSE)] = TRUE
+ vec[edge_ids(x, focused = TRUE)]
+}
+
+evaluate_edge_predicate = function(predicate, x, y, ...) {
+ E = pull_edge_geom(x, focused = TRUE)
+ lengths(predicate(E, y, sparse = TRUE, ...)) > 0
+}
+
+#' Convert undirected edges into directed edges based on their geometries
+#'
+#' This function converts an undirected network to a directed network following
+#' the direction given by the linestring geometries of the edges.
+#'
+#' @param x An object of class \code{\link{sfnetwork}}.
+#'
+#' @details In undirected spatial networks it is required that the boundary of
+#' edge geometries contain their incident node geometries. However, it is not
+#' required that their start point equals their specified *from* node and their
+#' end point their specified *to* node. Instead, it may be vice versa. This is
+#' because for undirected networks *from* and *to* indices are always swopped
+#' if the *to* index is lower than the *from* index. Therefore, the direction
+#' given by the *from* and *to* indices does not necessarily match the
+#' direction given by the edge geometries.
+#'
+#' @note If the network is already directed it is returned unmodified.
+#'
+#' @return A directed network as object of class \code{\link{sfnetwork}}.
+#'
+#' @importFrom igraph is_directed
+#' @export
+make_edges_directed = function(x) {
+ if (is_directed(x)) return (x)
+ # Retrieve the nodes and edges from the network.
+ nodes = nodes_as_sf(x)
+ edges = edges_as_sf(x)
+ # Get the node indices that correspond to the geometries of the edge bounds.
+ idxs = edge_boundary_ids(x, matrix = TRUE)
+ from = idxs[, 1]
+ to = idxs[, 2]
+ # Update the from and to columns of the edges such that:
+ # --> The from node matches the startpoint of the edge.
+ # --> The to node matches the endpoint of the edge.
+ edges$from = from
+ edges$to = to
+ # Recreate the network as a directed one.
+ sfnetwork_(nodes, edges, directed = TRUE) %preserve_network_attrs% x
+}
+
+#' Make some edges directed and some undirected
+#'
+#' This function creates a mixed network, meaning that some edges are directed,
+#' and some are undirected. In practice this is implemented as a directed
+#' network in which those edges that are meant to be undirected are duplicated
+#' and reversed.
+#'
+#' @param x An object of class \code{\link{sfnetwork}}.
+#'
+#' @param directed An integer vector of edge indices specifying those edges
+#' that should be directed.
+#'
+#' @return A mixed network as object of class \code{\link{sfnetwork}}.
+#'
+#' @importFrom dplyr arrange bind_rows
+#' @importFrom sf st_reverse
+#' @export
+make_edges_mixed = function(x, directed) {
+ # First make the network directed.
+ x = make_edges_directed(x)
+ # Extract edges from the network
+ edges = edge_data(x, focused = FALSE)
+ edge_ids = seq_len(nrow(edges))
+ # Keep track of the original edge index.
+ # This is used to later sort the edges table.
+ if (".sfnetwork_index" %in% names(edges)) {
+ raise_reserved_attr(".sfnetwork_index")
+ }
+ edges$.sfnetwork_index = edge_ids
+ # Define which edges should be undirected.
+ undirected = setdiff(edge_ids, directed)
+ # Duplicate undirected edges.
+ duplicates = edges[undirected, ]
+ # Reverse the duplicated undirected edges.
+ from = duplicates$from
+ to = duplicates$to
+ duplicates$from = to
+ duplicates$to = from
+ if (is_sf(edges)) {
+ duplicates = st_reverse(duplicates)
+ }
+ # Bind duplicated and reversed edges to the original edges.
+ new_edges = bind_rows(edges, duplicates)
+ # Use original indices to sort the new edges table.
+ new_edges = arrange(new_edges, .sfnetwork_index)
+ new_edges$.sfnetwork_index = NULL
+ # Create a new network with the updated edges.
+ x_new = sfnetwork_(nodes_as_sf(x), new_edges, directed = TRUE)
+ x_new %preserve_network_attrs% x
+}
+
+#' Construct edge geometries for spatially implicit networks
+#'
+#' This function turns spatially implicit networks into spatially explicit
+#' networks by adding a geometry column to the edge data.
+#'
+#' @param x An object of class \code{\link{sfnetwork}}.
+#'
+#' @param ... Arguments forwarded to \code{\link[sf]{st_as_sf}} to directly
+#' convert the edges table into a sf object. If no arguments are given, the
+#' edges are made explicit by simply drawing straight lines between the start
+#' and end node of each edge.
+#'
+#' @note If the network is already spatially explicit it is returned
+#' unmodified.
+#'
+#' @return An object of class \code{\link{sfnetwork}} with spatially explicit
+#' edges.
+#'
+#' @importFrom rlang dots_n
+#' @importFrom sf st_as_sf st_crs st_sfc
+#' @export
+make_edges_explicit = function(x, ...) {
+ # Return x unmodified if edges are already spatially explicit.
+ if (has_explicit_edges(x)) return(x)
+ # Add an empty geometry column if there are no edges.
+ if (n_edges(x) == 0) return(mutate_edge_geom(x, st_sfc(crs = st_crs(x))))
+ # In any other case:
+ # --> If ... is specified use it to convert edges table to sf.
+ # --> Otherwise simply draw straight lines between the incident nodes.
+ if (dots_n(...) > 0) {
+ edges = edge_data(x, focused = FALSE)
+ new_edges = st_as_sf(edges, ...)
+ x_new = x
+ edge_data(x_new) = new_edges
+ } else {
+ bounds = edge_incident_geoms(x, list = TRUE)
+ x_new = mutate_edge_geom(x, draw_lines(bounds[[1]], bounds[[2]]))
+ }
+ x_new
+}
+
+#' Drop edge geometries of spatially explicit networks
+#'
+#' This function turns spatially explicit networks into spatially implicit
+#' networks by dropping the geometry column of the edge data.
+#'
+#' @param x An object of class \code{\link{sfnetwork}}.
+#'
+#' @note If the network is already spatially implicit it is returned
+#' unmodified.
+#'
+#' @return An object of class \code{\link{sfnetwork}} with spatially implicit
+#' edges.
+#'
+#' @export
+make_edges_implicit = function(x) {
+ drop_edge_geom(x)
+}
+
+#' Match the direction of edge geometries to their specified incident nodes
+#'
+#' This function updates edge geometries in undirected networks such that they
+#' are guaranteed to start at their specified *from* node and end at their
+#' specified *to* node.
+#'
+#' @param x An object of class \code{\link{sfnetwork}}.
+#'
+#' @details In undirected spatial networks it is required that the boundary of
+#' edge geometries contain their incident node geometries. However, it is not
+#' required that their start point equals their specified *from* node and their
+#' end point their specified *to* node. Instead, it may be vice versa. This is
+#' because for undirected networks *from* and *to* indices are always swopped
+#' if the *to* index is lower than the *from* index.
+#'
+#' This function reverses edge geometries if they start at the *to* node and
+#' end at the *from* node, such that in the resulting network it is guaranteed
+#' that edge boundary points exactly match their incident node geometries. In
+#' directed networks, there will be no change.
+#'
+#' @return An object of class \code{\link{sfnetwork}} with updated edge
+#' geometries.
+#'
+#' @importFrom sf st_reverse
+#' @export
+make_edges_follow_indices = function(x) {
+ # Extract geometries of edges and subsequently their start points.
+ edges = pull_edge_geom(x)
+ start_points = linestring_start_points(edges)
+ # Extract the geometries of the nodes that should be at their start.
+ start_nodes = edge_source_geoms(x)
+ # Reverse edge geometries for which start point does not equal start node.
+ to_be_reversed = ! have_equal_geometries(start_points, start_nodes)
+ edges[to_be_reversed] = st_reverse(edges[to_be_reversed])
+ mutate_edge_geom(x, edges)
+}
+
+#' Match edge geometries to their incident node locations
+#'
+#' This function makes invalid edges valid by modifying either edge or node
+#' geometries such that the boundary points of edge linestring geometries
+#' always match the point geometries of the nodes that are specified as their
+#' incident nodes by the *from* and *to* columns.
+#'
+#' @param x An object of class \code{\link{sfnetwork}}.
+#'
+#' @param preserve_geometries Should the edge geometries remain unmodified?
+#' Defaults to \code{FALSE}. See Details.
+#'
+#' @details If geometries should be preserved, edges are made valid by adding
+#' edge boundary points that do not equal their corresponding node geometry as
+#' new nodes to the network, and updating the *from* and *to* indices to match
+#' this newly added nodes. If \code{FALSE}, edges are made valid by modifying
+#' their geometries, i.e. edge boundary points that do not equal their
+#' corresponding node geometry are replaced by that node geometry.
+#'
+#' @note This function works only if the edge geometries are meant to start at
+#' their specified *from* node and end at their specified *to* node. In
+#' undirected networks this is not necessarily the case, since edge geometries
+#' are allowed to start at their specified *to* node and end at their specified
+#' *from* node. Therefore, in undirected networks those edges first have to be
+#' reversed before running this function. Use
+#' \code{\link{make_edges_follow_indices}} for this.
+#'
+#' @return An object of class \code{\link{sfnetwork}} with corrected edge
+#' geometries.
+#'
+#' @export
+make_edges_valid = function(x, preserve_geometries = FALSE) {
+ if (preserve_geometries) {
+ add_invalid_edge_boundaries(x)
+ } else {
+ replace_invalid_edge_boundaries(x)
+ }
+}
+
+#' @importFrom dplyr bind_rows
+#' @importFrom igraph is_directed
+#' @importFrom sf st_geometry st_sf
+add_invalid_edge_boundaries = function(x) {
+ # Extract node and edge data.
+ nodes = nodes_as_sf(x)
+ edges = edges_as_sf(x)
+ # Check which edge boundary points do not match their specified nodes.
+ boundary_points = linestring_boundary_points(edges)
+ boundary_node_ids = edge_incident_ids(x)
+ boundary_nodes = st_geometry(nodes)[boundary_node_ids]
+ no_match = !have_equal_geometries(boundary_points, boundary_nodes)
+ # For boundary points that do not match their node:
+ # Boundary points that don't match their node become new nodes themselves.
+ new_nodes = list()
+ new_nodes[node_geom_colname(x)] = list(boundary_points[which(no_match)])
+ new_nodes = st_sf(new_nodes)
+ all_nodes = bind_rows(nodes, new_nodes)
+ # Update the from and to columns of the edges accordingly.
+ n_nodes = nrow(nodes)
+ n_new_nodes = nrow(new_nodes)
+ boundary_node_ids[no_match] = c((n_nodes + 1):(n_nodes + n_new_nodes))
+ n_boundaries = length(boundary_node_ids)
+ edges$from = boundary_node_ids[seq(1, n_boundaries - 1, 2)]
+ edges$to = boundary_node_ids[seq(2, n_boundaries, 2)]
+ # Return a new network with the added nodes and updated edges.
+ sfnetwork_(all_nodes, edges, is_directed(x)) %preserve_network_attrs% x
+}
+
+#' @importFrom sfheaders sfc_to_df
+replace_invalid_edge_boundaries = function(x) {
+ # Extract geometries of edges.
+ edges = pull_edge_geom(x)
+ # Extract the geometries of the nodes that should be at their ends.
+ nodes = edge_incident_geoms(x)
+ # Decompose the edges into the points that shape them.
+ # Convert the correct boundary nodes into the same structure.
+ E = sfc_to_df(edges)
+ N = sfc_to_df(nodes)
+ # Define for each edge point if it is a boundary point.
+ is_start = ! duplicated(E$linestring_id)
+ is_end = ! duplicated(E$linestring_id, fromLast = TRUE)
+ is_bound = is_start | is_end
+ # Update the coordinates of the edge boundary points.
+ # They should match the coordinates of their boundary nodes.
+ E_new = list()
+ if (! is.null(E$x)) {
+ x_new = E$x
+ x_new[is_bound] = N$x
+ E_new$x = x_new
+ }
+ if (! is.null(E$y)) {
+ y_new = E$y
+ y_new[is_bound] = N$y
+ E_new$y = y_new
+ }
+ if (! is.null(E$z)) {
+ z_new = E$z
+ z_new[is_bound] = N$z
+ E_new$z = z_new
+ }
+ if (! is.null(E$m)) {
+ m_new = E$m
+ m_new[is_bound] = N$m
+ E_new$m = m_new
+ }
+ E_new$id = E$linestring_id
+ # Update the geometries of the edges table.
+ mutate_edge_geom(x, df_to_lines(as.data.frame(E_new), edges, id_col = "id"))
}
diff --git a/R/faces.R b/R/faces.R
new file mode 100644
index 00000000..e84ac0c6
--- /dev/null
+++ b/R/faces.R
@@ -0,0 +1,46 @@
+#' Extract the faces of a spatial network
+#'
+#' The faces of a spatial network are the areas bounded by edges, without any
+#' other edge crossing it. A special face is the outer face, which is the area
+#' not bounded by any set of edges.
+#'
+#' @param x An object of class \code{\link{sfnetwork}}.
+#'
+#' @param boundary The boundary used for the outer face, as an object of class
+#' \code{\link[sf]{sf}} or \code{\link[sf]{sfc}} containing a single
+#' \code{POLYGON} geometry. Note that this boundary should always be larger
+#' than the bounding box of the network. If \code{NULL} (the default) the
+#' network bounding box extended by 0.1 times its diameter is used.
+#'
+#' @returns An object of class \code{\link[sf]{sfc}} with \code{POLYGON}
+#' geometries, in which each feature represents one face of the network.
+#'
+#' @examples
+#' library(sf, quietly = TRUE)
+#'
+#' oldpar = par(no.readonly = TRUE)
+#' par(mar = c(1,1,1,1))
+#'
+#' pts = st_transform(mozart, 3035)
+#' net = as_sfnetwork(pts, "delaunay")
+#'
+#' faces = st_network_faces(net)
+#'
+#' plot(faces, col = sf.colors(length(faces), categorical = TRUE))
+#' plot(net, add = TRUE)
+#'
+#' par(oldpar)
+#'
+#' @export
+st_network_faces = function(x, boundary = NULL) {
+ UseMethod("st_network_faces")
+}
+
+#' @importFrom lwgeom st_split
+#' @importFrom sf st_as_sfc st_collection_extract st_geometry
+#' @export
+st_network_faces.sfnetwork = function(x, boundary = NULL) {
+ if (is.null(boundary)) boundary = st_as_sfc(extended_network_bbox(x, 0.1))
+ splits = st_split(st_geometry(boundary), pull_edge_geom(x))
+ st_collection_extract(splits, "POLYGON")
+}
\ No newline at end of file
diff --git a/R/geom.R b/R/geom.R
index 0702eb6a..9ab9a4e1 100644
--- a/R/geom.R
+++ b/R/geom.R
@@ -1,4 +1,4 @@
-#' Get or set the sf_column attribute of the active element of a sfnetwork
+#' Get or set the geometry column name of the active element of a sfnetwork
#'
#' @param x An object of class \code{\link{sfnetwork}}.
#'
@@ -18,7 +18,7 @@ geom_colname = function(x, active = NULL) {
active,
nodes = node_geom_colname(x),
edges = edge_geom_colname(x),
- raise_unknown_input(active)
+ raise_invalid_active(active)
)
}
@@ -28,9 +28,10 @@ geom_colname = function(x, active = NULL) {
node_geom_colname = function(x) {
col = attr(vertex_attr(x), "sf_column")
if (is.null(col)) {
- # Take the name of the first sfc column.
- sfc_idx = which(vapply(vertex_attr(x), is.sfc, FUN.VALUE = logical(1)))[1]
- col = vertex_attr_names(x)[sfc_idx]
+ # Take the name of the first sfc column with point geometries.
+ is_sfc = vapply(vertex_attr(x), is_sfc_point, FUN.VALUE = logical(1))
+ sfc_idx = which(is_sfc)[1]
+ if (! is.na(sfc_idx)) col = vertex_attr_names(x)[sfc_idx]
}
col
}
@@ -40,10 +41,12 @@ node_geom_colname = function(x) {
#' @noRd
edge_geom_colname = function(x) {
col = attr(edge_attr(x), "sf_column")
- if (is.null(col) && has_explicit_edges(x)) {
- # Take the name of the first sfc column.
- sfc_idx = which(vapply(edge_attr(x), is.sfc, FUN.VALUE = logical(1)))[1]
- col = edge_attr_names(x)[sfc_idx]
+ if (is.null(col)) {
+ # Take the name of the first sfc column with linestring geometries.
+ # If this does not exist (implicit edges) col stays NULL.
+ is_sfc = vapply(edge_attr(x), is_sfc_linestring, FUN.VALUE = logical(1))
+ sfc_idx = which(is_sfc)[1]
+ if (! is.na(sfc_idx)) col = edge_attr_names(x)[sfc_idx]
}
col
}
@@ -58,7 +61,7 @@ edge_geom_colname = function(x) {
active,
nodes = `node_geom_colname<-`(x, value),
edges = `edge_geom_colname<-`(x, value),
- raise_unknown_input(active)
+ raise_invalid_active(active)
)
}
@@ -85,37 +88,44 @@ edge_geom_colname = function(x) {
#' @param active Either 'nodes' or 'edges'. If \code{NULL}, the currently
#' active element of x will be used.
#'
+#' @param focused Should only features that are in focus be pulled? Defaults
+#' to \code{FALSE}. See \code{\link[tidygraph]{focus}} for more information on
+#' focused networks.
+#'
#' @return An object of class \code{\link[sf]{sfc}}.
#'
#' @noRd
-pull_geom = function(x, active = NULL) {
+pull_geom = function(x, active = NULL, focused = FALSE) {
if (is.null(active)) {
active = attr(x, "active")
}
switch(
active,
- nodes = pull_node_geom(x),
- edges = pull_edge_geom(x),
- raise_unknown_input(active)
+ nodes = pull_node_geom(x, focused = focused),
+ edges = pull_edge_geom(x, focused = focused),
+ raise_invalid_active(active)
)
}
#' @name pull_geom
#' @importFrom igraph vertex_attr
#' @noRd
-pull_node_geom = function(x) {
+pull_node_geom = function(x, focused = FALSE) {
geom = vertex_attr(x, node_geom_colname(x))
- if (! is.sfc(geom)) raise_invalid_sf_column()
+ if (! is_sfc(geom)) raise_invalid_sf_column()
+ if (focused && is_focused(x)) geom = geom[node_ids(x, focused = TRUE)]
geom
}
#' @name pull_geom
#' @importFrom igraph edge_attr
#' @noRd
-pull_edge_geom = function(x) {
- require_explicit_edges(x)
- geom = edge_attr(x, edge_geom_colname(x))
- if (! is.sfc(geom)) raise_invalid_sf_column()
+pull_edge_geom = function(x, focused = FALSE) {
+ geom_colname = edge_geom_colname(x)
+ if (is.null(geom_colname)) raise_require_explicit()
+ geom = edge_attr(x, geom_colname)
+ if (! is_sfc(geom)) raise_invalid_sf_column()
+ if (focused && is_focused(x)) geom = geom[edge_ids(x, focused = TRUE)]
geom
}
@@ -128,6 +138,16 @@ pull_edge_geom = function(x) {
#' @param active Either 'nodes' or 'edges'. If \code{NULL}, the currently
#' active element of x will be used.
#'
+#' @param focused Should only features that are in focus be mutated? Defaults
+#' to \code{FALSE}. See \code{\link[tidygraph]{focus}} for more information on
+#' focused networks.
+#'
+#' @param name The name that should be given to the geometry column. This is
+#' mainly intended for cases in which a new geometry column is added to
+#' spatially implicit edges. Defaults to \code{NULL}, meaning that the current
+#' geometry column name is preserved if present, or the name "geometry" is
+#' given when there was no present geometry column.
+#'
#' @return An object of class \code{\link{sfnetwork}}.
#'
#' @details Note that the returned network will not be checked for a valid
@@ -135,37 +155,48 @@ pull_edge_geom = function(x) {
#' method for sfnetwork object.
#'
#' @noRd
-mutate_geom = function(x, y, active = NULL) {
+mutate_geom = function(x, y, active = NULL, focused = FALSE) {
if (is.null(active)) {
active = attr(x, "active")
}
switch(
active,
- nodes = mutate_node_geom(x, y),
- edges = mutate_edge_geom(x, y),
- raise_unknown_input(active)
+ nodes = mutate_node_geom(x, y, focused = focused),
+ edges = mutate_edge_geom(x, y, focused = focused),
+ raise_invalid_active(active)
)
}
#' @name mutate_geom
-#' @importFrom igraph vertex_attr<-
-#' @importFrom sf st_geometry
+#' @importFrom sf st_geometry<-
#' @noRd
-mutate_node_geom = function(x, y) {
+mutate_node_geom = function(x, y, focused = FALSE) {
nodes = nodes_as_sf(x)
- st_geometry(nodes) = y
- node_attribute_values(x) = nodes
+ if (focused && is_focused(x)) {
+ st_geometry(nodes[node_ids(x, focused = TRUE), ]) = y
+ } else {
+ st_geometry(nodes) = y
+ }
+ node_data(x) = nodes
x
}
#' @name mutate_geom
-#' @importFrom igraph edge_attr<-
-#' @importFrom sf st_geometry
+#' @importFrom sf st_geometry<-
#' @noRd
-mutate_edge_geom = function(x, y) {
- edges = edges_as_table(x)
- st_geometry(edges) = y
- edge_attribute_values(x) = edges
+mutate_edge_geom = function(x, y, focused = FALSE) {
+ edges = edge_data(x, focused = FALSE)
+ is_new = !is_sf(edges)
+ if (focused && is_focused(x)) {
+ st_geometry(edges[edge_ids(x, focused = TRUE), ]) = y
+ } else {
+ st_geometry(edges) = y
+ }
+ if (is_new) {
+ # Use the same geometry column name as for the nodes.
+ st_geometry(edges) = node_geom_colname(x)
+ }
+ edge_data(x) = edges
x
}
@@ -188,7 +219,7 @@ drop_geom = function(x, active = NULL) {
active,
nodes = drop_node_geom(x),
edges = drop_edge_geom(x),
- raise_unknown_input(active)
+ raise_invalid_active(active)
)
}
@@ -208,11 +239,83 @@ drop_node_geom = function(x) {
#' @noRd
drop_edge_geom = function(x) {
geom_col = edge_geom_colname(x)
- if (is.null(geom_col)) {
- stop("Edges are already spatially implicit", call. = FALSE)
- }
+ if (is.null(geom_col)) return(x)
x_new = delete_edge_attr(x, edge_geom_colname(x))
edge_geom_colname(x_new) = NULL
- edge_agr(x_new) = NULL
x_new
}
+
+#' Extract for each edge in a spatial network the geometries of incident nodes
+#'
+#' @param x An object of class \code{\link{sfnetwork}}.
+#'
+#' @param focused Should only edges that are in focus be considered? Defaults
+#' to \code{FALSE}. See \code{\link[tidygraph]{focus}} for more information on
+#' focused networks.
+#'
+#' @param list Should te result be returned as a two-element list? Defaults
+#' to \code{FALSE}.
+#'
+#' @return When extracting both source and target node geometries, an object of
+#' class \code{\link[sf]{sfc}} with \code{POINT} geometries of length equal to
+#' twice the number of edges in x, and ordered as [source of edge 1, target of
+#' edge 1, source of edge 2, target of edge 2, ...]. If \code{list = TRUE}, a
+#' list of length two is returned instead. The first element contains the
+#' source node geometries and the second element the target node geometries.
+#'
+#' When only extracting source or target node geometries, an object of class
+#' \code{\link[sf]{sfc}} with \code{POINT} geometries, of length equal to the
+#' number of edges in x.
+#'
+#' @details \code{edge_incident_geoms} obtains the geometries of incident nodes
+#' using the *from* and *to* columns in the edges table.
+#' \code{edge_boundary_geoms} instead obtains the boundary points of the edge
+#' linestring geometries, and check which node geometries are equal to those
+#' points. In a valid spatial network structure, the incident geometries should
+#' be equal to the boundary geometries (in directed networks) or the incident
+#' geometries of each edge should contain the boundary geometries of that edge
+#' (in undirected networks).
+#'
+#' @importFrom igraph ends
+#' @noRd
+edge_incident_geoms = function(x, focused = FALSE, list = FALSE) {
+ nodes = pull_node_geom(x)
+ ids = ends(x, edge_ids(x, focused = focused), names = FALSE)
+ if (list) {
+ list(nodes[ids[, 1]], nodes[ids[, 2]])
+ } else {
+ nodes[as.vector(t(ids))]
+ }
+}
+
+#' @name edge_incident_geoms
+#' @importFrom igraph ends
+#' @noRd
+edge_source_geoms = function(x, focused = FALSE) {
+ nodes = pull_node_geom(x)
+ id_mat = ends(x, edge_ids(x, focused = focused), names = FALSE)
+ nodes[id_mat[, 1]]
+}
+
+#' @name edge_incident_geoms
+#' @importFrom igraph ends
+#' @noRd
+edge_target_geoms = function(x, focused = FALSE) {
+ nodes = pull_node_geom(x)
+ id_mat = ends(x, edge_ids(x, focused = focused), names = FALSE)
+ nodes[id_mat[, 2]]
+}
+
+#' @name edge_incident_geoms
+#' @noRd
+edge_boundary_geoms = function(x, focused = FALSE, list = FALSE) {
+ edges = pull_edge_geom(x, focused = focused)
+ points = linestring_boundary_points(edges)
+ if (list) {
+ starts = points[seq(1, length(points), 2)]
+ ends = points[seq(2, length(points), 2)]
+ list(starts, ends)
+ } else {
+ points
+ }
+}
diff --git a/R/group.R b/R/group.R
new file mode 100644
index 00000000..b1e28ba1
--- /dev/null
+++ b/R/group.R
@@ -0,0 +1,72 @@
+#' Group nodes based on spatial distance
+#'
+#' These functions forms a spatial extension to the
+#' \code{\link[tidygraph:group_graph]{grouping}} functions in tidygraph,
+#' allowing to detect communities with spatial clustering algorithms.
+#'
+#' @param use_network_distance Should the distance between nodes be computed as
+#' the distance over the network (using \code{\link{st_network_distance}}?
+#' Defaults to \code{TRUE}. If set to \code{FALSE}, the straight-line distance
+#' (using \code{\link[sf]{st_distance}}) is computed instead.
+#'
+#' @param ... Additional arguments passed on to the clustering algorithm.
+#'
+#' @details Just as with all grouping functions in tidygraph, spatial grouping
+#' functions are meant to be called inside tidygraph verbs such as
+#' \code{\link[tidygraph]{mutate}} or \code{\link[tidygraph]{filter}}, where
+#' the network that is currently being worked on is known and thus not needed
+#' as an argument to the function. If you want to use an algorithm outside of
+#' the tidygraph framework you can use \code{\link[tidygraph]{with_graph}} to
+#' set the context temporarily while the algorithm is being evaluated.
+#'
+#' @returns A numeric vector with the membership for each node in the network.
+#' The enumeration happens in order based on group size progressing from the
+#' largest to the smallest group.
+#'
+#' @examples
+#' library(tidygraph, quietly = TRUE)
+#'
+#' play_geometric(10, 0.5) |>
+#' activate(nodes) |>
+#' mutate(group = group_spatial_dbscan(0.25))
+#'
+#' @name group_spatial
+NULL
+
+#' @describeIn group_spatial Uses density-based spatial clustering as
+#' implemented in the \code{\link[dbscan]{dbscan}} function of the dbscan
+#' package. This requires the dbscan package to be installed. Each node marked
+#' as noise will form its own cluster.
+#'
+#' @param epsilon The value of the epsilon parameter for the DBSCAN clustering
+#' algorithm, defining the radius of the neighborhood of a node.
+#'
+#' @param min_pts The value of the minPts parameter for the DBSCAN clustering
+#' algorithm, defining the minimum number of points in the neighborhood to be
+#' considered a core point.
+#'
+#' @importFrom rlang check_installed
+#' @export
+group_spatial_dbscan = function(epsilon, min_pts = 1,
+ use_network_distance = TRUE, ...) {
+ check_installed("dbscan") # Package dbscan is required for this function.
+ require_active_nodes()
+ dists = make_distance_matrix(.G(), use_network_distance)
+ groups = dbscan::dbscan(dists, eps = epsilon, minPts = min_pts, ...)$cluster
+ desc_enumeration(groups)
+}
+
+#' @importFrom stats as.dist
+make_distance_matrix = function(x, use_network_distance = TRUE) {
+ if (use_network_distance) {
+ as.dist(st_network_distance(x))
+ } else {
+ as.dist(st_distance(x))
+ }
+}
+
+# From https://github.com/thomasp85/tidygraph/blob/main/R/group.R
+# Take an integer vector and recode it so the most prevalent integer is 1, etc.
+desc_enumeration = function(group) {
+ match(group, as.integer(names(sort(table(group), decreasing = TRUE))))
+}
diff --git a/R/ids.R b/R/ids.R
new file mode 100644
index 00000000..5f01defb
--- /dev/null
+++ b/R/ids.R
@@ -0,0 +1,304 @@
+#' Extract all node or edge indices from a spatial network
+#'
+#' @param x An object of class \code{\link{sfnetwork}}.
+#'
+#' @param focused Should only the indices of features that are in focus be
+#' extracted? Defaults to \code{TRUE}. See \code{\link[tidygraph]{focus}} for
+#' more information on focused networks.
+#'
+#' @details The indices in these objects are always integers that correspond to
+#' rownumbers in respectively the nodes or edges table.
+#'
+#' @return A vector of integers.
+#'
+#' @examples
+#' net = as_sfnetwork(roxel[1:10, ])
+#' node_ids(net)
+#' edge_ids(net)
+#'
+#' @name ids
+#' @importFrom rlang %||%
+#' @export
+node_ids = function(x, focused = TRUE) {
+ if (focused) {
+ attr(x, "nodes_focus_index") %||% seq_len(n_nodes(x))
+ } else {
+ seq_len(n_nodes(x))
+ }
+}
+
+#' @name ids
+#' @importFrom rlang %||%
+#' @export
+edge_ids = function(x, focused = TRUE) {
+ if (focused) {
+ attr(x, "edges_focus_index") %||% seq_len(n_edges(x))
+ } else {
+ seq_len(n_edges(x))
+ }
+}
+
+#' Query specific node indices from a spatial network
+#'
+#' This function is not meant to be called directly, but used inside other
+#' functions that accept a node query.
+#'
+#' @param data An object of class \code{\link{sfnetwork}}.
+#'
+#' @param query The query that defines for which nodes to extract indices,
+#' defused into a \code{\link[rlang:topic-quosure]{quosure}}. See Details for
+#' the different ways in which node queries can be formulated.
+#'
+#' @details There are multiple ways in which node indices can be queried in
+#' sfnetworks. The query can be formatted as follows:
+#'
+#' \itemize{
+#' \item As spatial features: Spatial features can be given as object of
+#' class \code{\link[sf]{sf}} or \code{\link[sf]{sfc}}. The nearest node to
+#' each feature is found by calling \code{\link[sf]{st_nearest_feature}}.
+#' \item As node type query function: A
+#' \link[tidygraph:node_types]{node type query function} defines for each
+#' node if it is of a given type or not. Nodes that meet the criterium are
+#' queried.
+#' \item As node predicate query function: A
+#' \link[=spatial_node_predicates]{node predicate query function} defines
+#' for each node if a given spatial predicate applies to the spatial relation
+#' between that node and other spatial features. Nodes that meet the
+#' criterium are queried.
+#' \item As column name: The referenced column is expected to have logical
+#' values defining for each node if it should be queried or not. Note that
+#' tidy evaluation is used and hence the column name should be unquoted.
+#' \item As integers: Integers are interpreted as node indices. A node index
+#' corresponds to a row-number in the nodes table of the network.
+#' \item As characters: Characters are interpreted as node names. A node name
+#' corresponds to a value in a column named "name" in the the nodes table of
+#' the network. Note that this column is expected to store unique names
+#' without any duplicated values.
+#' \item As logicals: Logicals should define for each node if it should be
+#' queried or not.
+#' }
+#'
+#' Queries that can not be evaluated in any of the ways described above will be
+#' forcefully converted to integers using \code{\link{as.integer}}.
+#'
+#' @return A vector of queried node indices.
+#'
+#' @importFrom cli cli_abort
+#' @importFrom igraph vertex_attr
+#' @importFrom rlang eval_tidy
+#' @importFrom tidygraph .N .register_graph_context
+#' @export
+evaluate_node_query = function(data, query) {
+ .register_graph_context(data, free = TRUE)
+ nodes = eval_tidy(query, .N())
+ if (is_sf(nodes) | is_sfc(nodes)) {
+ nodes = nearest_node_ids(data, nodes)
+ } else if (is.logical(nodes)) {
+ nodes = which(nodes)
+ } else if (is.character(nodes)) {
+ names = vertex_attr(data, "name")
+ if (is.null(names)) {
+ cli_abort(c(
+ "Failed to match node names.",
+ "x" = "There is no node attribute {.field name}.",
+ "i" = paste(
+ "When querying nodes using names it is expected that these",
+ "names are stored in a node attribute named {.field name}"
+ )
+ ))
+ }
+ nodes = match(nodes, names)
+ }
+ if (! is.integer(nodes)) nodes = as.integer(nodes)
+ nodes
+}
+
+#' Query specific edge indices from a spatial network
+#'
+#' This function is not meant to be called directly, but used inside other
+#' functions that accept an edge query.
+#'
+#' @param data An object of class \code{\link{sfnetwork}}.
+#'
+#' @param query The query that defines for which edges to extract indices,
+#' defused into a \code{\link[rlang:topic-quosure]{quosure}}. See Details for
+#' the different ways in which edge queries can be formulated.
+#'
+#' @details There are multiple ways in which edge indices can be queried in
+#' sfnetworks. The query can be formatted as follows:
+#'
+#' \itemize{
+#' \item As spatial features: Spatial features can be given as object of
+#' class \code{\link[sf]{sf}} or \code{\link[sf]{sfc}}. The nearest edge to
+#' each feature is found by calling \code{\link[sf]{st_nearest_feature}}.
+#' \item As edge type query function: A
+#' \link[tidygraph:edge_types]{edge type query function} defines for each
+#' edge if it is of a given type or not. Nodes that meet the criterium are
+#' queried.
+#' \item As edge predicate query function: A
+#' \link[=spatial_edge_predicates]{edge predicate query function} defines
+#' for each edge if a given spatial predicate applies to the spatial relation
+#' between that edge and other spatial features. Nodes that meet the
+#' criterium are queried.
+#' \item As column name: The referenced column is expected to have logical
+#' values defining for each edge if it should be queried or not. Note that
+#' tidy evaluation is used and hence the column name should be unquoted.
+#' \item As integers: Integers are interpreted as edge indices. A edge index
+#' corresponds to a row-number in the edges table of the network.
+#' \item As characters: Characters are interpreted as edge names. A edge name
+#' corresponds to a value in a column named "name" in the the edges table of
+#' the network. Note that this column is expected to store unique names
+#' without any duplicated values.
+#' \item As logicals: Logicals should define for each edge if it should be
+#' queried or not.
+#' }
+#'
+#' Queries that can not be evaluated in any of the ways described above will be
+#' forcefully converted to integers using \code{\link{as.integer}}.
+#'
+#' @return A vector of queried edge indices.
+#'
+#' @importFrom cli cli_abort
+#' @importFrom igraph edge_attr
+#' @importFrom rlang eval_tidy
+#' @importFrom tidygraph .E .register_graph_context
+#' @export
+evaluate_edge_query = function(data, query) {
+ .register_graph_context(data, free = TRUE)
+ edges = eval_tidy(query, .E())
+ if (is_sf(edges) | is_sfc(edges)) {
+ edges = nearest_edge_ids(data, edges)
+ } else if (is.logical(edges)) {
+ edges = which(edges)
+ } else if (is.character(edges)) {
+ names = edge_attr(data, "name")
+ if (is.null(names)) {
+ cli_abort(c(
+ "Failed to match edge names.",
+ "x" = "There is no edge attribute {.field name}.",
+ "i" = paste(
+ "When querying edges using names it is expected that these",
+ "names are stored in a edge attribute named {.field name}"
+ )
+ ))
+ }
+ edges = match(edges, names)
+ }
+ if (! is.integer(edges)) edges = as.integer(edges)
+ edges
+}
+
+#' Extract for each edge in a spatial network the indices of incident nodes
+#'
+#' @param x An object of class \code{\link{sfnetwork}}.
+#'
+#' @param focused Should only edges that are in focus be considered? Defaults
+#' to \code{FALSE}. See \code{\link[tidygraph]{focus}} for more information on
+#' focused networks.
+#'
+#' @param matrix Should te result be returned as a two-column matrix? Defaults
+#' to \code{FALSE}.
+#'
+#' @return When extracting both source and target node indices, a numeric
+#' vector of length equal to twice the number of edges in x, and ordered as
+#' [source of edge 1, target of edge 1, source of edge 2, target of edge 2,
+#' ...]. If \code{matrix = TRUE}, a two-column matrix is returned instead, with
+#' the number of rows equal to the number of edges in the network. The first
+#' column contains the indices of the source nodes and the second column the
+#' indices of the target nodes.
+#'
+#' When only extracting source or target node indices, a numeric vector of
+#' length equal to the number of edges in x.
+#'
+#' @details \code{edge_incident_ids} obtains the indices of incident nodes
+#' using the *from* and *to* columns in the edges table.
+#' \code{edge_boundary_ids} instead obtains the boundary points of the edge
+#' linestring geometries, and check which node geometries are equal to those
+#' points. In a valid spatial network structure, the incident indices should be
+#' equal to the boundary indices (in directed networks) or the incident indices
+#' of each edge should contain the boundary indices of that edge (in undirected
+#' networks).
+#'
+#' @importFrom igraph ends
+#' @noRd
+edge_incident_ids = function(x, focused = FALSE, matrix = FALSE) {
+ ends = ends(x, edge_ids(x, focused = focused), names = FALSE)
+ if (matrix) ends else as.vector(t(ends))
+}
+
+#' @name edge_incident_ids
+#' @importFrom igraph ends
+#' @noRd
+edge_source_ids = function(x, focused = FALSE, matrix = FALSE) {
+ ends(x, edge_ids(x, focused = focused), names = FALSE)[, 1]
+}
+
+#' @name edge_incident_ids
+#' @importFrom igraph ends
+#' @noRd
+edge_target_ids = function(x, focused = FALSE, matrix = FALSE) {
+ ends(x, edge_ids(x, focused = focused), names = FALSE)[, 2]
+}
+
+#' @name edge_indicent_ids
+#' @importFrom sf st_equals
+#' @noRd
+edge_boundary_ids = function(x, focused = FALSE, matrix = FALSE) {
+ nodes = pull_node_geom(x)
+ edges = edges_as_sf(x, focused = focused)
+ idlist = st_equals(linestring_boundary_points(edges), nodes)
+ idvect = do.call("c", idlist)
+ # In most networks the location of a node will be unique.
+ # However, this is not a requirement.
+ # There may be cases where multiple nodes share the same geometry.
+ # Then some more processing is needed to find the correct indices.
+ if (length(idvect) != n_edges(x, focused = focused) * 2) {
+ n = length(idlist)
+ from = idlist[seq(1, n - 1, 2)]
+ to = idlist[seq(2, n, 2)]
+ pids = mapply(c, from, to, SIMPLIFY = FALSE)
+ nids = mapply(c, edges$from, edges$to, SIMPLIFY = FALSE)
+ find_indices = function(a, b) {
+ ids = a[a %in% b]
+ if (length(ids) > 2) b else ids
+ }
+ idlist = mapply(find_indices, pids, nids, SIMPLIFY = FALSE)
+ idvect = do.call("c", idlist)
+ }
+ if (matrix) t(matrix(idvect, nrow = 2)) else idvect
+}
+
+#' Add or drop original feature index columns
+#'
+#' When morphing networks into a different structure, groups of nodes or edges
+#' may be merged into a single feature, or individual nodes or edges may be
+#' split into multiple features. In those cases, tidygraph and sfnetworks keep
+#' track of the original node and edge indices by creating columns named
+#' \code{.tidygraph_node_index} and \code{.tidygraph_edge_index}.
+#'
+#' @param x An object of class \code{\link{sfnetwork}}.
+#'
+#' @returns An object of class \code{\link{sfnetwork}}.
+#'
+#' @name original_ids
+#' @importFrom igraph edge_attr<- edge_attr_names vertex_attr<-
+#' vertex_attr_names
+#' @noRd
+add_original_ids = function(x) {
+ if (! ".tidygraph_node_index" %in% vertex_attr_names(x)) {
+ vertex_attr(x, ".tidygraph_node_index") = seq_len(n_nodes(x))
+ }
+ if (! ".tidygraph_edge_index" %in% edge_attr_names(x)) {
+ edge_attr(x, ".tidygraph_edge_index") = seq_len(n_edges(x))
+ }
+ x
+}
+
+#' @name original_ids
+#' @importFrom igraph delete_edge_attr delete_vertex_attr
+#' @noRd
+drop_original_ids = function(x) {
+ x = delete_vertex_attr(x, ".tidygraph_node_index")
+ x = delete_edge_attr(x, ".tidygraph_edge_index")
+ x
+}
diff --git a/R/iso.R b/R/iso.R
new file mode 100644
index 00000000..84e89a0c
--- /dev/null
+++ b/R/iso.R
@@ -0,0 +1,138 @@
+#' Compute isolines around nodes in a spatial network
+#'
+#' Isolines are curves along which a function has a constant value. In spatial
+#' networks, they are used to delineate areas that are reachable from a given
+#' node within a given travel cost. If the travel cost is distance, they are
+#' known as isodistances, while if the travel cost is time, they are known as
+#' isochrones. This function finds all network nodes that lie inside an isoline
+#' around a specified node.
+#'
+#' @param x An object of class \code{\link{sfnetwork}}.
+#'
+#' @param node The node around which the isolines will be drawn. Evaluated by
+#' \code{\link{evaluate_node_query}}. When multiple nodes are given, only the
+#' first one is used.
+#'
+#' @param cost The constant cost value of the isoline. Should be a numeric
+#' value in the same units as the given edge weights. Alternatively, units can
+#' be specified explicitly by providing a \code{\link[units]{units}} object.
+#' Multiple values may be given, which will result in multiple isolines being
+#' drawn.
+#'
+#' @param weights The edge weights to be used in the shortest path calculation.
+#' Evaluated by \code{\link{evaluate_weight_spec}}. The default is
+#' \code{\link{edge_length}}, which computes the geographic lengths of the
+#' edges.
+#'
+#' @param ... Additional arguments passed on to \code{\link{st_network_cost}}
+#' to compute the cost matrix from the specified node to all other nodes in the
+#' network.
+#'
+#' @param delineate Should the nodes inside the isoline be delineated? If
+#' \code{FALSE}, the nodes inside the isoline are returned as a
+#' \code{MULTIPOINT} geometry. If \code{TRUE}, the concave hull of that
+#' geometry is returned instead. Defaults to \code{TRUE}.
+#'
+#' @param ratio The ratio of the concave hull. Defaults to \code{1}, meaning
+#' that the convex hull is computed. See \code{\link[sf]{st_concave_hull}} for
+#' details. Ignored if \code{delineate = FALSE}. Setting this to a value
+#' smaller than 1 requires a GEOS version of at least 3.11.
+#'
+#' @param allow_holes May the concave hull have holes? Defaults to \code{FALSE}.
+#' Ignored if \code{delineate = FALSE}.
+#'
+#' @returns An object of class \code{\link[sf]{sf}} with one row per requested
+#' isoline. The object contains the following columns:
+#'
+#' \itemize{
+#' \item \code{cost}: The constant cost value of the isoline.
+#' \item \code{geometry}: If \code{delineate = TRUE}, the concave hull of all
+#' nodes that lie inside the isoline. Otherwise, those nodes combined into a
+#' single \code{MULTIPOINT} geometry.
+#' }
+#'
+#' @examples
+#' library(sf, quietly = TRUE)
+#'
+#' oldpar = par(no.readonly = TRUE)
+#' par(mar = c(1,1,1,1))
+#'
+#' center = st_centroid(st_combine(st_geometry(roxel)))
+#'
+#' net = as_sfnetwork(roxel, directed = FALSE)
+#'
+#' iso = net |>
+#' st_network_iso(node_is_nearest(center), c(1000, 500, 250))
+#'
+#' colors = c("#fee6ce90", "#fdae6b90", "#e6550d90")
+#'
+#' plot(net)
+#' plot(st_geometry(iso), col = colors, add = TRUE)
+#'
+#' # The level of detail can be increased with the ratio argument.
+#' # This requires GEOS >= 3.11.
+#' if (compareVersion(sf_extSoftVersion()[["GEOS"]], "3.11.0") > -1) {
+#'
+#' iso = net |>
+#' st_network_iso(node_is_nearest(center), c(1000, 500, 250), ratio = 0.3)
+#'
+#' colors = c("#fee6ce90", "#fdae6b90", "#e6550d90")
+#'
+#' plot(net)
+#' plot(st_geometry(iso), col = colors, add = TRUE)
+#' }
+#'
+#' par(oldpar)
+#'
+#' @export
+st_network_iso = function(x, node, cost, weights = edge_length(), ...,
+ delineate = TRUE, ratio = 1, allow_holes = FALSE) {
+ UseMethod("st_network_iso")
+}
+
+#' @importFrom methods hasArg
+#' @importFrom rlang enquo
+#' @importFrom sf st_combine st_concave_hull st_convex_hull st_sf
+#' @importFrom units as_units deparse_unit
+#' @export
+st_network_iso.sfnetwork = function(x, node, cost, weights = edge_length(),
+ ..., delineate = TRUE, ratio = 1,
+ allow_holes = FALSE) {
+ # Evaluate the given node query.
+ # Always only the first node is used.
+ node = evaluate_node_query(x, enquo(node))
+ if (length(node) > 1) raise_multiple_elements("node"); node = node[1]
+ # Evaluate the given weights specification.
+ weights = evaluate_weight_spec(x, enquo(weights))
+ # If the "to" nodes are also given this query has to be evaluated as well.
+ # Otherwise it defaults to all nodes in the network.
+ if (hasArg("to")) {
+ to = evaluate_node_query(x, enquo(to))
+ } else {
+ to = node_ids(x, focused = FALSE)
+ }
+ # Compute the cost matrix from the specified node to all other nodes.
+ matrix = compute_costs(x, node, to, weights = weights, ...)
+ # Parse the given cost values.
+ if (inherits(matrix, "units") && ! inherits(cost, "units")) {
+ cost = as_units(cost, deparse_unit(matrix))
+ }
+ # For each given cost:
+ # --> Define which nodes are inside the isoline.
+ # --> Extract and combine the geometries of those nodes.
+ node_geom = pull_node_geom(x)
+ get_single_iso = function(k) {
+ in_iso = matrix[1, ] <= k
+ iso = st_combine(node_geom[in_iso])
+ if (delineate) {
+ if (ratio == 1) {
+ iso = st_convex_hull(iso)
+ } else {
+ iso = st_concave_hull(iso, ratio = ratio, allow_holes = allow_holes)
+ }
+ }
+ iso
+ }
+ geoms = do.call("c", lapply(cost, get_single_iso))
+ st_sf(cost = cost, geometry = geoms)
+}
\ No newline at end of file
diff --git a/R/join.R b/R/join.R
index b19d10c2..4d8b880c 100644
--- a/R/join.R
+++ b/R/join.R
@@ -1,8 +1,7 @@
#' Join two spatial networks based on equality of node geometries
#'
#' A spatial network specific join function which makes a spatial full join on
-#' the geometries of the nodes data, based on the \code{\link[sf]{st_equals}}
-#' spatial predicate. Edge data are combined using a
+#' the geometries of the nodes data. Edge data are combined using a
#' \code{\link[dplyr]{bind_rows}} semantic, meaning that data are matched by
#' column name and values are filled with \code{NA} if missing in either of
#' the networks. The \code{from} and \code{to} columns in the edge data are
@@ -15,31 +14,42 @@
#'
#' @param ... Arguments passed on to \code{\link[tidygraph]{graph_join}}.
#'
+#' @note By default sfnetworks rounds coordinates to 12 decimal places to
+#' determine spatial equality. You can influence this behavior by explicitly
+#' setting the precision of the networks using
+#' \code{\link[sf]{st_set_precision}}.
+#'
#' @return The joined networks as an object of class \code{\link{sfnetwork}}.
#'
#' @examples
#' library(sf, quietly = TRUE)
#'
-#' node1 = st_point(c(0, 0))
-#' node2 = st_point(c(1, 0))
-#' node3 = st_point(c(1,1))
-#' node4 = st_point(c(0,1))
-#' edge1 = st_sfc(st_linestring(c(node1, node2)))
-#' edge2 = st_sfc(st_linestring(c(node2, node3)))
-#' edge3 = st_sfc(st_linestring(c(node3, node4)))
+#' oldpar = par(no.readonly = TRUE)
+#' par(mar = c(1,1,1,1), mfrow = c(1,2))
#'
-#' net1 = as_sfnetwork(c(edge1, edge2))
-#' net2 = as_sfnetwork(c(edge2, edge3))
+#' # Create two networks.
+#' n1 = st_point(c(0, 0))
+#' n2 = st_point(c(1, 0))
+#' n3 = st_point(c(1,1))
+#' n4 = st_point(c(0,1))
#'
-#' joined = st_network_join(net1, net2)
-#' joined
+#' e1 = st_sfc(st_linestring(c(n1, n2)))
+#' e2 = st_sfc(st_linestring(c(n2, n3)))
+#' e3 = st_sfc(st_linestring(c(n3, n4)))
+#'
+#' neta = as_sfnetwork(c(e1, e2))
+#' netb = as_sfnetwork(c(e2, e3))
+#'
+#' # Join the networks based on spatial equality of nodes.
+#' net = st_network_join(neta, netb)
+#' net
+#'
+#' # Plot.
+#' plot(neta, pch = 15, cex = 2, lwd = 4)
+#' plot(netb, col = "orange", pch = 18, cex = 2, lty = 3, lwd = 4, add = TRUE)
+#
+#' plot(net, cex = 2, lwd = 4)
#'
-#' ## Plot results.
-#' oldpar = par(no.readonly = TRUE)
-#' par(mar = c(1,1,1,1), mfrow = c(1,2))
-#' plot(net1, pch = 15, cex = 2, lwd = 4)
-#' plot(net2, col = "red", pch = 18, cex = 2, lty = 3, lwd = 4, add = TRUE)
-#' plot(joined, cex = 2, lwd = 4)
#' par(oldpar)
#'
#' @export
@@ -47,25 +57,62 @@ st_network_join = function(x, y, ...) {
UseMethod("st_network_join")
}
+#' @importFrom cli cli_abort
+#' @importFrom tidygraph unfocus
#' @export
st_network_join.sfnetwork = function(x, y, ...) {
- if (! is.sfnetwork(y)) y = as_sfnetwork(y)
- stopifnot(have_equal_crs(x, y))
- stopifnot(have_equal_edge_type(x, y))
+ if (! is_sfnetwork(y)) y = as_sfnetwork(y)
+ x = unfocus(x)
+ y = unfocus(y)
+ if (! have_equal_edge_type(x, y)) {
+ cli_abort(c(
+ paste(
+ "{.arg x} and {.arg y} should have the same type of edges",
+ "(spatially explicit or spatially implicit)"
+ ),
+ "i" = "Call {.fn sfnetworks::to_spatial_explicit} to explicitize edges.",
+ "i" = "Call {.fn sf::st_drop_geometry} to drop edge geometries."
+ ))
+ }
+ if (! have_equal_crs(x, y)) {
+ cli_abort(c(
+ "{.arg x} and {.arg y} should have the same CRS.",
+ "i" = "Call {.fn sf::st_transform} to transform to a different CRS."
+ ))
+ }
spatial_join_network(x, y, ...)
}
+#' @importFrom dplyr join_by
+#' @importFrom igraph delete_vertex_attr vertex_attr vertex_attr<-
+#' vertex_attr_names
#' @importFrom tidygraph as_tbl_graph graph_join
spatial_join_network = function(x, y, ...) {
- # Retrieve names of node geometry columns of x and y.
- x_geom_colname = node_geom_colname(x)
- y_geom_colname = node_geom_colname(y)
- # Regular graph join based on geometry columns.
- x_new = graph_join(
- x = as_tbl_graph(x),
- y = as_tbl_graph(y),
- by = structure(names = x_geom_colname, .Data = y_geom_colname),
- ...
- )
- x_new %preserve_network_attrs% x
+ # Extract node geometry column names from x and y.
+ x_geomcol = node_geom_colname(x)
+ y_geomcol = node_geom_colname(y)
+ # Assess which node geometries in the union of x and y are equal.
+ # This will create a vertex of unique node indices in the union of x and y.
+ N_x = vertex_attr(x, x_geomcol)
+ N_y = vertex_attr(y, y_geomcol)
+ N = c(N_x, N_y)
+ uid = st_match_points(N)
+ # Store the unique node indices as node attributes in both x and y.
+ if (".sfnetwork_index" %in% c(vertex_attr_names(x), vertex_attr_names(y))) {
+ raise_reserved_attr(".sfnetwork_index")
+ }
+ vertex_attr(x, ".sfnetwork_index") = uid[1:length(N_x)]
+ vertex_attr(y, ".sfnetwork_index") = uid[(length(N_x) + 1):length(uid)]
+ # Join x and y based on the unique node indices using tidygraphs graph_join.
+ # Perform this join without the geometry column.
+ # Otherwise the geometry columns of x and y are seen as regular attributes.
+ # Meaning that they get stored separately in the joined network.
+ x_tbg = as_tbl_graph(delete_vertex_attr(x, x_geomcol))
+ y_tbg = as_tbl_graph(delete_vertex_attr(y, y_geomcol))
+ x_new = graph_join(x_tbg, y_tbg, by = join_by(.sfnetwork_index), ...)
+ # Add the corresponding node geometries to the joined network.
+ N_new = N[!duplicated(uid)][vertex_attr(x_new, ".sfnetwork_index")]
+ vertex_attr(x_new, x_geomcol) = N_new
+ # Return after removing the unique node index attribute.
+ delete_vertex_attr(x_new, ".sfnetwork_index") %preserve_network_attrs% x
}
diff --git a/R/messages.R b/R/messages.R
index a65e7790..d0a9fafd 100644
--- a/R/messages.R
+++ b/R/messages.R
@@ -1,68 +1,228 @@
# Errors, warnings and messages that occur at multiple locations
+#' @importFrom cli cli_warn
raise_assume_constant = function(caller) {
- warning(
- caller,
- " assumes attributes are constant over geometries",
- call. = FALSE
- )
+ cli_warn(c(
+ "{.fn {caller}} assumes all attributes are constant over geometries.",
+ "!" = "Not all attributes are labelled as being constant.",
+ "i" = "You can label attribute-geometry relations using {.fn sf::st_set_agr}."
+ ))
}
-raise_assume_planar = function(caller) {
- warning(
- "Although coordinates are longitude/latitude, ",
- caller,
- " assumes that they are planar",
- call. = FALSE
- )
+#' @importFrom cli cli_warn
+raise_assume_projected = function(caller) {
+ cli_warn(c(
+ "{.fn {caller}} assumes coordinates are projected.",
+ "!" = paste(
+ "The provided coordinates are geographic,",
+ "which may lead to inaccurate results."
+ ),
+ "i" = "You can transform to a projected CRS using {.fn sf::st_transform}."
+ ))
+}
+
+#' @importFrom cli cli_abort
+raise_invalid_active = function(value) {
+ cli_abort(c(
+ "Unknown value for argument {.arg active}: {value}.",
+ "i" = "Supported values are: nodes, edges."
+ ))
+}
+
+#' @importFrom cli cli_abort
+raise_invalid_sf_column = function() {
+ cli_abort(c(
+ "Attribute {.field sf_column} does not point to a geometry column.",
+ "i" = paste(
+ "Did you rename the geometry column without setting",
+ "{.code st_geometry(x) = 'newname'}?"
+ )
+ ))
}
+#' @importFrom cli cli_warn
raise_multiple_elements = function(arg) {
- warning(
- "Although argument ",
- arg,
- " has length > 1, only the first element is used",
- call. = FALSE
- )
+ cli_warn("Only the first element of {.arg {arg}} is used.")
}
+#' @importFrom cli cli_abort
raise_na_values = function(arg) {
- stop(
- "NA values present in argument ",
- arg,
- call. = FALSE
- )
+ cli_abort("{.arg {arg}} should not contain NA values.")
}
+#' @importFrom cli cli_warn
raise_overwrite = function(value) {
- warning(
- "Overwriting column(s): ",
- value,
- call. = FALSE
- )
+ cli_warn("Overwriting column {.field {value}}.")
+}
+
+#' @importFrom cli cli_abort
+raise_require_explicit = function() {
+ cli_abort(c(
+ "This call requires spatially explicit edges.",
+ "i" = "Call {.fn tidygraph::activate} to activate nodes instead.",
+ "i" = "Call {.fn sfnetworks::to_spatial_explicit} to explicitize edges."
+ ))
}
+#' @importFrom cli cli_abort
raise_reserved_attr = function(value) {
- stop(
- "The attribute name '",
- value,
- "' is reserved",
- call. = FALSE
+ cli_abort("The attribute name {.field value} is reserved.")
+}
+
+#' @importFrom cli cli_abort
+raise_unknown_input = function(arg, value, options = NULL) {
+ if (is.null(options)) {
+ cli_abort("Unknown value for argument {.arg {arg}}: {value}.")
+ } else {
+ cli_abort(c(
+ "Unknown value for argument {.arg {arg}}: {value}.",
+ "i" = "Supported values are: {paste(options, collapse = ', ')}."
+ ))
+ }
+}
+
+#' @importFrom cli cli_abort
+raise_unknown_summarizer = function(value) {
+ cli_abort(c(
+ "Unknown attribute summary function: {value}.",
+ "i" = "For supported values see {.fn igraph::attribute.combination}."
+ ))
+}
+
+#' @importFrom cli cli_abort
+raise_unsupported_arg = function(arg, replacement = NULL) {
+ if (is.null(replacement)) {
+ cli_abort("Setting argument {.arg {arg}} is not supported")
+ } else {
+ cli_abort(c(
+ "Setting argument {.arg {arg}} is not supported.",
+ "i" = "Use {.arg {replacement}} instead."
+ ))
+ }
+}
+
+#' @importFrom lifecycle deprecate_stop
+deprecate_length_as_weight = function(caller) {
+ switch(
+ caller,
+ sfnetwork = deprecate_stop(
+ when = "v1.0",
+ what = "sfnetwork(length_as_weight)",
+ with = "sfnetwork(compute_length)"
+ ),
+ as_sfnetwork.sf = deprecate_stop(
+ when = "v1.0",
+ what = "as_sfnetwork.sf(length_as_weight)",
+ with = "as_sfnetwork.sf(compute_length)",
+ details = c(
+ i = paste(
+ "The sf method of `as_sfnetwork()` now forwards `...` to",
+ "`create_from_spatial_lines()` for linestring geometries",
+ "and to `create_from_spatial_points()` for point geometries."
+ )
+ )
+ ),
+ raise_unknown_input("caller", caller)
)
}
-raise_unknown_input = function(value) {
- stop(
- "Unknown input: ",
- value,
- call. = FALSE
+#' @importFrom lifecycle deprecate_stop
+deprecate_edges_as_lines = function() {
+ deprecate_stop(
+ when = "v1.0",
+ what = paste(
+ "as_sfnetwork.sf(edges_as_lines = 'is deprecated for",
+ "linestring geometries')"
+ ),
+ details = c(
+ i = paste(
+ "The sf method of `as_sfnetwork()` now forwards `...` to",
+ "`create_from_spatial_lines()` for linestring geometries."
+ ),
+ i = paste(
+ "An sfnetwork created from linestring geometries will now",
+ "always have spatially explicit edges."
+ )
+ )
)
}
-raise_invalid_sf_column = function() {
- stop(
- "Attribute 'sf_column' does not point to a geometry column.\n",
- "Did you rename it, without setting st_geometry(x) = 'newname'?",
- call. = FALSE
+#' @importFrom lifecycle deprecate_stop
+deprecate_type = function() {
+ deprecate_stop(
+ when = "v1.0",
+ what = "st_network_paths(type)",
+ details = c(
+ i = "To compute all shortest paths, set `all = TRUE`.",
+ i = paste(
+ "Computing all simple paths is not supported anymore, but you can now",
+ "compute k shortest paths by setting `k` to an integer higher than 1."
+ )
+ )
)
}
+
+#' @importFrom lifecycle deprecate_warn
+deprecate_weights_is_string = function() {
+ deprecate_warn(
+ when = "v1.0",
+ what = paste0(
+ "evaluate_weight_spec",
+ "(weights = 'uses tidy evaluation')"
+ ),
+ details = c(
+ i = paste(
+ "This means you can forward column names without quotations, e.g.",
+ "`weights = length` instead of `weights = 'length'`. Quoted column",
+ "names are currently still supported for backward compatibility,",
+ "but this may be removed in future versions."
+ )
+ )
+ )
+}
+
+#' @importFrom lifecycle deprecate_warn
+deprecate_weights_is_null = function() {
+ deprecate_warn(
+ when = "v1.0",
+ what = paste0(
+ "evaluate_weight_spec",
+ "(weights = 'if set to NULL means no edge weights are used')"
+ ),
+ details = c(
+ i = paste(
+ "If you want to use geographic length as edge weights, use",
+ "`weights = edge_length()` or provide a column in which the edge",
+ "lengths are stored, e.g. `weights = length`."
+ ),
+ i = paste(
+ "If you want to use the weight column for edge weights, specify",
+ "this explicitly through `weights = weight`."
+ )
+ )
+ )
+}
+
+#' @importFrom lifecycle deprecate_warn
+deprecate_from = function() {
+ deprecate_warn(
+ when = "v1.0",
+ what = "to_spatial_neighborhood(from)",
+ with = "to_spatial_neighborhood(direction)",
+ details = c(
+ i = paste(
+ "If `from = FALSE` this will for now be automatically translated into",
+ "`direction = 'in'`, but this may be removed in future versions."
+ )
+ )
+ )
+}
+
+#' @importFrom lifecycle deprecate_warn
+deprecate_sa = function(caller) {
+ deprecate_warn(
+ when = "v1.0",
+ what = paste0(caller, "(summarise_attributes)"),
+ with = paste0(caller, "(attribute_summary)")
+ )
+}
\ No newline at end of file
diff --git a/R/morphers.R b/R/morphers.R
index 86f52376..9869745c 100644
--- a/R/morphers.R
+++ b/R/morphers.R
@@ -1,64 +1,65 @@
-#' Spatial morphers for sfnetworks
+#' Morph spatial networks into a different structure
#'
#' Spatial morphers form spatial add-ons to the set of
#' \code{\link[tidygraph]{morphers}} provided by \code{tidygraph}. These
-#' functions are not meant to be called directly. They should either be passed
-#' into \code{\link[tidygraph]{morph}} to create a temporary alternative
-#' representation of the input network. Such an alternative representation is a
-#' list of one or more network objects. Single elements of that list can be
-#' extracted directly as a new network by passing the morpher to
-#' \code{\link[tidygraph]{convert}} instead, to make the changes lasting rather
-#' than temporary. Alternatively, if the morphed state contains multiple
-#' elements, all of them can be extracted together inside a
-#' \code{\link[tibble]{tbl_df}} by passing the morpher to
-#' \code{\link[tidygraph]{crystallise}}.
+#' functions change the existing structure of the network.
#'
#' @param x An object of class \code{\link{sfnetwork}}.
#'
-#' @param ... Arguments to be passed on to other functions. See the description
-#' of each morpher for details.
+#' @param protect Nodes or edges to be protected from being changed in
+#' structure. Evaluated by \code{\link{evaluate_node_query}} in the case of
+#' nodes and by \code{\link{evaluate_edge_query}} in the case of edges.
+#' Defaults to \code{NULL}, meaning that no features are protected.
#'
-#' @param store_original_data Whenever multiple features (i.e. nodes and/or
-#' edges) are merged into a single feature during morphing, should the data of
-#' the original features be stored as an attribute of the new feature, in a
-#' column named \code{.orig_data}. This is in line with the design principles
-#' of \code{tidygraph}. Defaults to \code{FALSE}.
-#'
-#' @param summarise_attributes Whenever multiple features (i.e. nodes and/or
-#' edges) are merged into a single feature during morphing, how should their
-#' attributes be combined? Several options are possible, see
+#' @param attribute_summary Whenever groups of nodes or edges are merged
+#' into a single feature during morphing, how should their attributes be
+#' summarized? There are several options, see
#' \code{\link[igraph]{igraph-attribute-combination}} for details.
#'
+#' @param summarise_attributes Deprecated, use \code{attribute_summary} instead.
+#'
+#' @param store_original_data Whenever groups of nodes or edges are merged
+#' into a single feature during morphing, should the data of the original
+#' features be stored as an attribute of the new feature, in a column named
+#' \code{.orig_data}. This is in line with the design principles of
+#' \code{tidygraph}. Defaults to \code{FALSE}.
+#'
+#' @param ... Arguments to be passed on to other functions. See the description
+#' of each morpher for details.
+#'
#' @return Either a \code{morphed_sfnetwork}, which is a list of one or more
#' \code{\link{sfnetwork}} objects, or a \code{morphed_tbl_graph}, which is a
#' list of one or more \code{\link[tidygraph]{tbl_graph}} objects. See the
#' description of each morpher for details.
#'
-#' @details It also possible to create your own morphers. See the documentation
-#' of \code{\link[tidygraph]{morph}} for the requirements for custom morphers.
+#' @details Morphers are not meant to be called directly. Instead, they should
+#' be called inside the \code{\link[tidygraph]{morph}} verb to change the
+#' network structure temporarily. Depending on the chosen morpher, this results
+#' in a list of one or more network objects. Single elements of that list can
+#' be extracted directly as a new network by calling the morpher inside the
+#' \code{\link[tidygraph]{convert}} verb instead, to make the changes lasting
+#' rather than temporary.
#'
-#' @seealso The vignette on
-#' \href{https://luukvdmeer.github.io/sfnetworks/articles/sfn05_morphers.html}{spatial morphers}.
+#' It also possible to create your own morphers. See the documentation of
+#' \code{\link[tidygraph]{morph}} for the requirements for custom morphers.
#'
#' @examples
#' library(sf, quietly = TRUE)
#' library(tidygraph, quietly = TRUE)
#'
-#' net = as_sfnetwork(roxel, directed = FALSE) %>%
+#' net = as_sfnetwork(roxel, directed = FALSE) |>
#' st_transform(3035)
#'
#' # Temporary changes with morph and unmorph.
-#' net %>%
-#' activate("edges") %>%
-#' mutate(weight = edge_length()) %>%
-#' morph(to_spatial_shortest_paths, from = 1, to = 10) %>%
-#' mutate(in_paths = TRUE) %>%
+#' net |>
+#' activate(edges) |>
+#' morph(to_spatial_shortest_paths, from = 1, to = 10) |>
+#' mutate(in_paths = TRUE) |>
#' unmorph()
#'
#' # Lasting changes with convert.
-#' net %>%
-#' activate("edges") %>%
-#' mutate(weight = edge_length()) %>%
+#' net |>
+#' activate(edges) |>
#' convert(to_spatial_shortest_paths, from = 1, to = 10)
#'
#' @name spatial_morphers
@@ -66,261 +67,52 @@ NULL
#' @describeIn spatial_morphers Combine groups of nodes into a single node per
#' group. \code{...} is forwarded to \code{\link[dplyr]{group_by}} to
-#' create the groups. The centroid of the group of nodes will be used as
-#' geometry of the contracted node. If edge are spatially explicit, edge
+#' create the groups. The centroid of such a group will be used by default as
+#' geometry of the contracted node. If edges are spatially explicit, edge
#' geometries are updated accordingly such that the valid spatial network
#' structure is preserved. Returns a \code{morphed_sfnetwork} containing a
#' single element of class \code{\link{sfnetwork}}.
#'
-#' @param simplify Should the network be simplified after contraction? This
-#' means that multiple edges and loop edges will be removed. Multiple edges
-#' are introduced by contraction when there are several connections between
-#' the same groups of nodes. Loop edges are introduced by contraction when
-#' there are connections within a group. Note however that setting this to
-#' \code{TRUE} also removes multiple edges and loop edges that already
-#' existed before contraction. Defaults to \code{FALSE}.
+#' @param simplify Should the network be simplified after contraction? Defaults
+#' to \code{TRUE}. This means that multiple edges and loop edges will be
+#' removed. Multiple edges are introduced by contraction when there are several
+#' connections between the same groups of nodes. Loop edges are introduced by
+#' contraction when there are connections within a group. Note however that
+#' setting this to \code{TRUE} also removes multiple edges and loop edges that
+#' already existed before contraction.
+#'
+#' @param compute_centroids Should the new geometry of each contracted group of
+#' nodes be the centroid of all group members? Defaults to \code{TRUE}. If set
+#' to \code{FALSE}, the geometry of the first node in each group will be used
+#' instead, which requires considerably less computing time.
#'
-#' @importFrom dplyr group_by group_indices group_size group_split
-#' @importFrom igraph contract delete_edges delete_vertex_attr which_loop
-#' which_multiple
-#' @importFrom sf st_as_sf st_cast st_centroid st_combine st_geometry
-#' st_geometry<- st_intersects
-#' @importFrom tibble as_tibble
-#' @importFrom tidygraph as_tbl_graph
+#' @importFrom lifecycle deprecated is_present
+#' @importFrom dplyr group_by group_indices
+#' @importFrom sf st_drop_geometry
#' @export
-to_spatial_contracted = function(x, ..., simplify = FALSE,
- summarise_attributes = "ignore",
+to_spatial_contracted = function(x, ..., simplify = TRUE,
+ compute_centroids = TRUE,
+ attribute_summary = "ignore",
+ summarise_attributes = deprecated(),
store_original_data = FALSE) {
- if (will_assume_planar(x)) raise_assume_planar("to_spatial_contracted")
- # Retrieve nodes from the network.
- nodes = nodes_as_sf(x)
- geom_colname = attr(nodes, "sf_column")
- ## =======================
- # STEP I: GROUP THE NODES
- # Group the nodes table by forwarding ... to dplyr::group_by.
- # Each group of nodes will later be contracted into a single node.
- ## =======================
- nodes = group_by(nodes, ...)
- # If no group contains more than one node simply return x.
- if (all(group_size(nodes) == 1)) return(list(contracted = x))
- ## =======================
- # STEP II: EXTRACT GROUPS
- # Split the nodes table into the created groups.
- # Store the indices that map each node to their respective group.
- # Subset the groups that contain more than one node.
- # --> These are the groups that are going to be contracted.
- ## =======================
- all_group_idxs = group_indices(nodes)
- all_groups = group_split(nodes)
- cnt_group_idxs = which(as.numeric(table(all_group_idxs)) > 1)
- cnt_groups = all_groups[cnt_group_idxs]
- ## ===========================
- # STEP III: CONTRACT THE NODES
- # Contract the nodes in the network using igraph::contract.
- # Use the extracted group indices as mapping.
- # Attributes will be summarised as defined by argument summarise_attributes.
- # Igraph does not know the geometry column is not an attribute:
- # --> We should temporarily remove the geometry column before contracting.
- ## ===========================
- # Remove the geometry list column for the time being.
- x_tmp = delete_vertex_attr(x, geom_colname)
- # Update the attribute summary instructions.
- # During morphing tidygraph add the tidygraph node index column.
- # Since it is added internally it is not referenced in summarise_attributes.
- # We need to include it manually.
- # They should be concatenated into a vector.
- if (! inherits(summarise_attributes, "list")) {
- summarise_attributes = list(summarise_attributes)
- }
- summarise_attributes[".tidygraph_node_index"] = "concat"
- # Contract with igraph::contract.
- x_new = as_tbl_graph(contract(x_tmp, all_group_idxs, summarise_attributes))
- ## ======================================================
- # STEP IV: UPDATE THE NODE DATA OF THE CONTRACTED NETWORK
- # Add the following information to the nodes table:
- # --> The geometries of the new nodes.
- # --> If requested the original node data in tibble format.
- ## ======================================================
- # Extract the nodes from the contracted network.
- new_nodes = as_tibble(x_new, "nodes")
- # Add geometries to the new nodes.
- # For each node that was not contracted:
- # --> Use its original geometry.
- # For each node that was contracted:
- # --> Use the centroid of the geometries of the group members.
- new_node_geoms = st_geometry(nodes)[!duplicated(all_group_idxs)]
- get_centroid = function(i) {
- comb = st_combine(st_geometry(i))
- suppressWarnings(st_centroid(comb))
- }
- cnt_node_geoms = do.call("c", lapply(cnt_groups, get_centroid))
- new_node_geoms[cnt_group_idxs] = cnt_node_geoms
- new_nodes[geom_colname] = list(new_node_geoms)
- # If requested, store original node data in a .orig_data column.
- if (store_original_data) {
- drop_index = function(i) { i$.tidygraph_node_index = NULL; i }
- new_nodes$.orig_data = lapply(cnt_groups, drop_index)
- }
- # Update the nodes table of the contracted network.
- new_nodes = st_as_sf(new_nodes, sf_column_name = geom_colname)
- node_attribute_values(x_new) = new_nodes
- # Convert in a sfnetwork.
- x_new = tbg_to_sfn(x_new)
- ## ===============================================================
- # STEP V: RECONNECT THE EDGE GEOMETRIES OF THE CONTRACTED NETWORK
- # The geometries of the contracted nodes are updated.
- # This means the edge geometries of their incidents also need an update.
- # Otherwise the valid spatial network structure is not preserved.
- ## ===============================================================
- # First we will remove multiple edges and loop edges if this was requested.
- # Multiple edges occur when there are several connections between groups.
- # Loop edges occur when there are connections within groups.
- # Note however that original multiple and loop edges are also removed.
- if (simplify) {
- x_new = delete_edges(x_new, which(which_multiple(x_new)))
- x_new = delete_edges(x_new, which(which_loop(x_new)))
- x_new = x_new %preserve_all_attrs% x_new
- }
- # Secondly we will update the geometries of the remaining affected edges.
- if (has_explicit_edges(x)) {
- # Extract the edges and their geometries from the contracted network.
- new_edges = edges_as_sf(x_new)
- new_edge_geoms = st_geometry(new_edges)
- # Define functions to:
- # --> Append a point at the start of an edge linestring.
- # --> Append a point at the end of an edge linestring.
- # --> Append the same point at both ends of an edge linestring.
- append_source = function(i, j) {
- l = new_edge_geoms[i]
- p = new_node_geoms[j]
- l_pts = st_cast(l, "POINT")
- st_cast(st_combine(c(p, l_pts)), "LINESTRING")
- }
- append_target = function(i, j) {
- l = new_edge_geoms[i]
- p = new_node_geoms[j]
- l_pts = st_cast(l, "POINT")
- st_cast(st_combine(c(l_pts, p)), "LINESTRING")
- }
- append_boundaries = function(j, i) {
- l = new_edge_geoms[j]
- p = new_node_geoms[i]
- l_pts = st_cast(l, "POINT")
- st_cast(st_combine(c(p, l_pts, p)), "LINESTRING")
- }
- # Find the indices of the nodes at the boundaries of each edge.
- bounds = edge_boundary_node_indices(x_new, matrix = TRUE)
- # Mask out those indices of nodes that were not contracted.
- # Only edge boundaries at contracted nodes have to be updated.
- bounds[!(bounds %in% cnt_group_idxs)] = NA
- from = bounds[, 1]
- to = bounds[, 2]
- # Define for each edge if it:
- # --> Starts and ends at the same contracted node, i.e. is a loop.
- # --> Comes from a contracted node.
- # --> Goes to a contracted node.
- is_loop = (!is.na(from) & !is.na(to)) & (from == to)
- is_from = !is_loop & !is.na(from)
- is_to = !is_loop & !is.na(to)
- # First handle loop edges (if not removed yet through simplification).
- # Find the indices of:
- # --> Each loop edge.
- # --> The node at the start and end of each loop edge.
- # For each detected loop edge:
- # --> Append the node geometry at each end of the edge geometry.
- if (any(is_loop)) {
- E1 = which(is_loop)
- N1 = from[is_loop]
- geoms = do.call("c", mapply(append_boundaries, E1, N1, SIMPLIFY = FALSE))
- new_edge_geoms[E1] = geoms
- }
- # For from and to edges directed and undirected networks are different.
- # In directed networks:
- # --> The from node geometry is always the start of the edge linestring.
- # --> The to node geometry is always at the end of the edge linestring.
- # In undirected networks, this is not always the case.
- # We first need to define which node is at the start and end of the edge.
- if (is_directed(x_new)) {
- # Find the indices of:
- # --> Each from edge.
- # --> The node at the start of each from edge.
- # For each detected from edge:
- # --> Append the node geometry at the start of the edge geometry.
- if (any(is_from)) {
- E2 = which(is_from)
- N2 = from[is_from]
- geoms = do.call("c", mapply(append_source, E2, N2, SIMPLIFY = FALSE))
- new_edge_geoms[E2] = geoms
- }
- # Find the indices of:
- # --> Each to edge.
- # --> The node at the end of each to edge.
- # For each detected to edge:
- # --> Append the node geometry at the end of the edge geometry.
- if (any(is_to)) {
- E3 = which(is_to)
- N3 = to[is_to]
- geoms = do.call("c", mapply(append_target, E3, N3, SIMPLIFY = FALSE))
- new_edge_geoms[E3] = geoms
- }
- } else {
- # The edges defined before as from/to are incident to contracted nodes.
- # However, we don't know yet if the come from or go to it.
- is_incident = is_from | is_to
- if (any(is_incident)) {
- # Combine the original node geometries for each group.
- # This gives us a set of all original node geometries in each group.
- combine_geoms = function(i) st_combine(st_geometry(i))
- all_group_geoms = do.call("c", lapply(all_groups, combine_geoms))
- # For each indicent edge, find:
- # --> The geometries of its startpoint.
- # --> The group index corresponding to that startpoint geometry.
- # --> The index of the contracted node at its boundary.
- bnd_geoms = linestring_boundary_points(new_edge_geoms[is_incident])
- src_geoms = bnd_geoms[seq(1, length(bnd_geoms) - 1, 2)]
- src_idxs = suppressMessages(st_intersects(src_geoms, all_group_geoms))
- bnd_idxs = bounds[is_incident, ]
- bnd_idxs = lapply(seq_len(nrow(bnd_idxs)), function(i) bnd_idxs[i, ])
- # Initially, assume that:
- # --> All incident edges are 'to' edges.
- is_to = matrix(c(is_from, is_to), ncol = 2)
- is_from = matrix(rep(FALSE, length(bounds)), nrow = nrow(bounds))
- # Update the initial phase such that edges are changed to 'from' if:
- # --> The contracted node index equals the startpoint group index.
- is_from[is_incident, ] = t(mapply(`%in%`, bnd_idxs, src_idxs))
- is_to[is_from] = FALSE
- # Now we have updated the 'from' and 'to' information.
- # Find the indices of:
- # --> Each from edge.
- # --> The node at the start of each from edge.
- # For each detected from edge:
- # --> Append the node geometry at the start of the edge geometry.
- if (any(is_from)) {
- E2 = which(apply(is_from, 1, any))
- N2 = t(bounds)[t(is_from)]
- geoms = do.call("c", mapply(append_source, E2, N2, SIMPLIFY = FALSE))
- new_edge_geoms[E2] = geoms
- }
- # Find the indices of:
- # --> Each to edge.
- # --> The node at the end of each to edge.
- # For each detected to edge:
- # --> Append the node geometry at the end of the edge geometry.
- if (any(is_to)) {
- E3 = which(apply(is_to, 1, any))
- N3 = t(bounds)[t(is_to)]
- geoms = do.call("c", mapply(append_target, E3, N3, SIMPLIFY = FALSE))
- new_edge_geoms[E3] = geoms
- }
- }
- }
- # Update the edges table of the contracted network.
- st_geometry(new_edges) = new_edge_geoms
- edge_attribute_values(x_new) = new_edges
- }
+ if (is_present(summarise_attributes)) deprecate_sa("to_spatial_contracted")
+ # Create groups.
+ groups = group_by(st_drop_geometry(nodes_as_sf(x)), ...)
+ group_ids = group_indices(groups)
+ # Contract.
+ x_new = contract_nodes(
+ x = x,
+ groups = group_ids,
+ simplify = simplify,
+ compute_centroids = compute_centroids,
+ reconnect_edges = TRUE,
+ attribute_summary = attribute_summary,
+ store_original_ids = TRUE,
+ store_original_data = store_original_data
+ )
# Return in a list.
list(
- contracted = x_new %preserve_network_attrs% x
+ contracted = x_new
)
}
@@ -332,28 +124,10 @@ to_spatial_contracted = function(x, ..., simplify = FALSE,
#' the linestring geometries. Returns a \code{morphed_sfnetwork} containing a
#' single element of class \code{\link{sfnetwork}}. This morpher requires edges
#' to be spatially explicit. If not, use \code{\link[tidygraph]{to_directed}}.
-#' @importFrom igraph is_directed
#' @export
to_spatial_directed = function(x) {
- require_explicit_edges(x)
- if (is_directed(x)) return (x)
- # Retrieve the nodes and edges from the network.
- nodes = nodes_as_sf(x)
- edges = edges_as_sf(x)
- # Get the node indices that correspond to the geometries of the edge bounds.
- idxs = edge_boundary_point_indices(x, matrix = TRUE)
- from = idxs[, 1]
- to = idxs[, 2]
- # Update the from and to columns of the edges such that:
- # --> The from node matches the startpoint of the edge.
- # --> The to node matches the endpoint of the edge.
- edges$from = from
- edges$to = to
- # Recreate the network as a directed one.
- x_new = sfnetwork_(nodes, edges, directed = TRUE)
- # Return in a list.
list(
- directed = x_new %preserve_network_attrs% x
+ directed = make_edges_directed(x)
)
}
@@ -365,185 +139,212 @@ to_spatial_directed = function(x) {
#' drawn between the source and target node of each edge. Returns a
#' \code{morphed_sfnetwork} containing a single element of class
#' \code{\link{sfnetwork}}.
-#' @importFrom sf st_as_sf
#' @export
to_spatial_explicit = function(x, ...) {
- # Workflow:
- # --> If ... is given, convert edges to sf by forwarding ... to st_as_sf.
- # --> If ... is not given, draw straight lines from source to target nodes.
- args = list(...)
- if (length(args) > 0) {
- edges = edges_as_table(x)
- new_edges = st_as_sf(edges, ...)
- x_new = x
- edge_attribute_values(x_new) = new_edges
- } else {
- x_new = explicitize_edges(x)
- }
- # Return in a list.
list(
- explicit = x_new
+ explicit = make_edges_explicit(x, ...)
)
}
-#' @describeIn spatial_morphers Limit a network to the spatial neighborhood of
-#' a specific node. \code{...} is forwarded to
-#' \code{\link[tidygraph]{node_distance_from}} (if \code{from} is \code{TRUE})
-#' or \code{\link[tidygraph]{node_distance_to}} (if \code{from} is
-#' \code{FALSE}). Returns a \code{morphed_sfnetwork} containing a single
-#' element of class \code{\link{sfnetwork}}.
+#' @describeIn spatial_morphers Drop edge geometries from the network. Returns
+#' a \code{morphed_sfnetwork} containing a single element of class
+#' \code{\link{sfnetwork}}.
+#' @export
+to_spatial_implicit = function(x) {
+ list(
+ implict = make_edges_implicit(x)
+ )
+}
+
+#' @describeIn spatial_morphers Construct a mixed network in which some edges
+#' are directed, and some are undirected. In practice this is implemented as a
+#' directed network in which those edges that are meant to be undirected are
+#' duplicated and reversed. Returns a \code{morphed_sfnetwork} containing a
+#' single element of class \code{\link{sfnetwork}}.
#'
-#' @param node The geospatial point for which the neighborhood will be
-#' calculated. Can be an integer, referring to the index of the node for which
-#' the neighborhood will be calculated. Can also be an object of class
-#' \code{\link[sf]{sf}} or \code{\link[sf]{sfc}}, containing a single feature.
-#' In that case, this point will be snapped to its nearest node before
-#' calculating the neighborhood. When multiple indices or features are given,
-#' only the first one is taken.
+#' @param directed Which edges should be directed? Evaluated by
+#' \code{\link{evaluate_edge_query}}.
#'
-#' @param threshold The threshold distance to be used. Only nodes within the
-#' threshold distance from the reference node will be included in the
-#' neighborhood. Should be a numeric value in the same units as the weight
-#' values used for distance calculation.
+#' @importFrom rlang enquo
+#' @export
+to_spatial_mixed = function(x, directed) {
+ directed = evaluate_edge_query(x, enquo(directed))
+ list(
+ mixed = make_edges_mixed(x, directed)
+ )
+}
+
+#' @describeIn spatial_morphers Limit a network to the spatial neighborhood of
+#' a specific node. \code{...} is forwarded to \code{\link{st_network_cost}} to
+#' compute the travel cost from the specified node to all other nodes in the
+#' network. Returns a \code{morphed_sfnetwork} that may contain multiple
+#' elements of class \code{\link{sfnetwork}}, depending on the number of given
+#' thresholds. When unmorphing only the first instance of both the node and
+#' edge data will be used, as the the same node and/or edge can be present in
+#' multiple neighborhoods.
#'
-#' @param weights The edge weights used to calculate distances on the network.
-#' Can be a numeric vector giving edge weights, or a column name referring to
-#' an attribute column in the edges table containing those weights. If set to
-#' \code{NULL}, the values of a column named \code{weight} in the edges table
-#' will be used automatically, as long as this column is present. If not, the
-#' geographic edge lengths will be calculated internally and used as weights.
+#' @param node The node for which the neighborhood will be calculated.
+#' Evaluated by \code{\link{evaluate_node_query}}. When multiple nodes are
+#' given, only the first one is used.
#'
-#' @param from Should distances be calculated from the reference node towards
-#' the other nodes? Defaults to \code{TRUE}. If set to \code{FALSE}, distances
-#' will be calculated from the other nodes towards the reference node instead.
+#' @param threshold The threshold cost to be used. Only nodes reachable within
+#' this threshold cost from the reference node will be included in the
+#' neighborhood. Should be a numeric value in the same units as the given edge
+#' weights. Alternatively, units can be specified explicitly by providing a
+#' \code{\link[units]{units}} object. Multiple threshold values may be given,
+#' which will result in mutliple neigborhoods being returned.
+#'
+#' @param weights The edge weights to be used for travel cost computation.
+#' Evaluated by \code{\link{evaluate_weight_spec}}. The default is
+#' \code{\link{edge_length}}, which computes the geographic lengths of the
+#' edges.
#'
#' @importFrom igraph induced_subgraph
-#' @importFrom tidygraph node_distance_from node_distance_to with_graph
+#' @importFrom methods hasArg
+#' @importFrom rlang enquo
+#' @importFrom units as_units deparse_unit
+#' @export
+to_spatial_neighborhood = function(x, node, threshold, weights = edge_length(),
+ ...) {
+ # Evaluate the given node query.
+ # Always only the first node is used.
+ node = evaluate_node_query(x, enquo(node))
+ if (length(node) > 1) raise_multiple_elements("node"); node = node[1]
+ # Evaluate the given weights specification.
+ weights = evaluate_weight_spec(x, enquo(weights))
+ # If the "to" nodes are also given this query has to be evaluated as well.
+ # Otherwise it defaults to all nodes in the network.
+ if (hasArg("to")) {
+ to = evaluate_node_query(x, enquo(to))
+ } else {
+ to = node_ids(x, focused = FALSE)
+ }
+ # Compute the cost matrix from the source node.
+ # By calling st_network_cost with the given arguments.
+ if (hasArg("from")) {
+ # Deprecate the former "from" argument specifying routing direction.
+ deprecate_from()
+ if (isFALSE(list(...)$from)) {
+ costs = compute_costs(x, node, to, weights, direction = "in", ...)
+ } else {
+ costs = compute_costs(x, node, to, weights, ...)
+ }
+ } else {
+ costs = compute_costs(x, node, to, weights, ...)
+ }
+ # Parse the given threshold values.
+ if (inherits(costs, "units") && ! inherits(threshold, "units")) {
+ threshold = as_units(threshold, deparse_unit(costs))
+ }
+ # For each given threshold:
+ # --> Define which nodes are in the neighborhood.
+ # --> Subset the network to keep only the nodes in the neighborhood.
+ get_single_neighborhood = function(k) {
+ in_neighborhood = costs[1, ] <= k
+ induced_subgraph(x, in_neighborhood) %preserve_all_attrs% x
+ }
+ lapply(threshold, get_single_neighborhood)
+}
+
+#' @describeIn spatial_morphers Reverse the direction of edges. Returns a
+#' \code{morphed_sfnetwork} containing a single element of class
+#' \code{\link{sfnetwork}}.
+#' @importFrom igraph is_directed reverse_edges
+#' @importFrom rlang enquo try_fetch
+#' @importFrom sf st_reverse
#' @export
-to_spatial_neighborhood = function(x, node, threshold, weights = NULL,
- from = TRUE, ...) {
- # Parse node argument.
- # If 'node' is given as a geometry, find the index of the nearest node.
- # When multiple nodes are given only the first one is taken.
- if (is.sf(node) | is.sfc(node)) node = get_nearest_node_index(x, node)
- if (length(node) > 1) raise_multiple_elements("node")
- # Parse weights argument.
- # This can be done equal to setting weights for path calculations.
- weights = set_path_weights(x, weights)
- # Calculate the distances from/to the reference node to/from all other nodes.
- # Use the provided weights as edge weights in the distance calculation.
- if (from) {
- dist = with_graph(x, node_distance_from(node, weights = weights, ...))
+to_spatial_reversed = function(x, protect = NULL) {
+ # Define which edges should be reversed.
+ if (try_fetch(is.null(protect), error = \(e) FALSE)) {
+ reverse = edge_ids(x, focused = FALSE)
} else {
- dist = with_graph(x, node_distance_to(node, weights = weights, ...))
+ protect = evaluate_edge_query(x, enquo(protect))
+ reverse = setdiff(edge_ids(x, focused = FALSE), protect)
+ }
+ # Reverse the from and to indices of those edges.
+ # This will have no effect on undirected networks.
+ x_new = reverse_edges(x, eids = reverse) %preserve_all_attrs% x
+ # Reverse the geometries of those edges.
+ if (has_explicit_edges(x)) {
+ edge_geom = pull_edge_geom(x)
+ edge_geom[reverse] = st_reverse(edge_geom)[reverse]
+ x_new = mutate_edge_geom(x_new, edge_geom)
}
- # Use the given threshold to define which nodes are in the neighborhood.
- in_neighborhood = dist <= threshold
- # Subset the network to keep only the nodes in the neighborhood.
- x_new = induced_subgraph(x, in_neighborhood)
# Return in a list.
list(
- neighborhood = x_new %preserve_all_attrs% x
+ reversed = x_new
)
}
#' @describeIn spatial_morphers Limit a network to those nodes and edges that
#' are part of the shortest path between two nodes. \code{...} is evaluated in
-#' the same manner as \code{\link{st_network_paths}} with
-#' \code{type = 'shortest'}. Returns a \code{morphed_sfnetwork} that may
-#' contain multiple elements of class \code{\link{sfnetwork}}, depending on
-#' the number of requested paths. When unmorphing only the first instance of
-#' both the node and edge data will be used, as the the same node and/or edge
-#' can be present in multiple paths.
-#' @importFrom igraph delete_edges delete_vertices edge_attr vertex_attr
+#' the same manner as \code{\link{st_network_paths}}. Returns a
+#' \code{morphed_sfnetwork} that may contain multiple elements of class
+#' \code{\link{sfnetwork}}, depending on the number of requested paths. When
+#' unmorphing only the first instance of both the node and edge data will be
+#' used, as the the same node and/or edge can be present in multiple paths.
+#' @importFrom igraph is_directed
#' @export
to_spatial_shortest_paths = function(x, ...) {
- args = list(...)
- args$x = x
- args$type = "shortest"
# Call st_network_paths with the given arguments.
- paths = do.call("st_network_paths", args)
+ paths = st_network_paths(
+ x,
+ ...,
+ use_names = FALSE,
+ return_cost = FALSE,
+ return_geometry = FALSE
+ )
# Retrieve original node and edge indices from the network.
- orig_node_idxs = vertex_attr(x, ".tidygraph_node_index")
- orig_edge_idxs = edge_attr(x, ".tidygraph_edge_index")
+ nodes = nodes_as_sf(x)
+ edges = edge_data(x, focused = FALSE)
# Subset the network for each computed shortest path.
get_single_path = function(i) {
- edge_idxs = as.integer(paths$edge_paths[[i]])
- node_idxs = as.integer(paths$node_paths[[i]])
- x_new = delete_edges(x, orig_edge_idxs[-edge_idxs])
- x_new = delete_vertices(x_new, orig_node_idxs[-node_idxs])
- x_new %preserve_all_attrs% x
+ if (paths[i, ]$path_found) {
+ node_ids = paths$node_path[[i]]
+ edge_ids = paths$edge_path[[i]]
+ N = nodes[node_ids, ]
+ E = edges[edge_ids, ]
+ E$from = c(1:(length(node_ids) - 1))
+ E$to = c(2:length(node_ids))
+ } else {
+ N = nodes[0, ]
+ E = edges[0, ]
+ }
+ sfnetwork_(N, E, directed = is_directed(x))
}
lapply(seq_len(nrow(paths)), get_single_path)
}
-#' @describeIn spatial_morphers Remove loop edges and/or merges multiple edges
-#' into a single edge. Multiple edges are edges that have the same source and
-#' target nodes (in directed networks) or edges that are incident to the same
-#' nodes (in undirected networks). When merging them into a single edge, the
-#' geometry of the first edge is preserved. The order of the edges can be
-#' influenced by calling \code{\link[dplyr]{arrange}} before simplifying.
-#' Returns a \code{morphed_sfnetwork} containing a single element of class
-#' \code{\link{sfnetwork}}.
+#' @describeIn spatial_morphers Construct a simple version of the network. A
+#' simple network is defined as a network without loop edges and multiple
+#' edges. A loop edge is an edge that starts and ends at the same node.
+#' Multiple edges are different edges between the same node pair. When merging
+#' them into a single edge, the geometry of the first edge is preserved. The
+#' order of the edges can be influenced by calling \code{\link[dplyr]{arrange}}
+#' before simplifying. Returns a \code{morphed_sfnetwork} containing a single
+#' element of class \code{\link{sfnetwork}}.
#'
#' @param remove_multiple Should multiple edges be merged into one. Defaults
#' to \code{TRUE}.
#'
#' @param remove_loops Should loop edges be removed. Defaults to \code{TRUE}.
#'
-#' @importFrom igraph simplify
-#' @importFrom sf st_as_sf st_crs st_crs<- st_precision st_precision<- st_sfc
-#' @importFrom tibble as_tibble
-#' @importFrom tidygraph as_tbl_graph
+#' @importFrom lifecycle deprecated is_present
#' @export
to_spatial_simple = function(x, remove_multiple = TRUE, remove_loops = TRUE,
- summarise_attributes = "first",
+ attribute_summary = "first",
+ summarise_attributes = deprecated(),
store_original_data = FALSE) {
- # Define if the network has spatially explicit edges.
- # This influences some of the processes to come.
- spatial = if (has_explicit_edges(x)) TRUE else FALSE
- # Update the attribute summary instructions.
- # In the summarise attributes only real attribute columns were referenced.
- # On top of those, we need to include:
- # --> The geometry column, if present.
- # --> The tidygraph edge index column added by tidygraph::morph.
- if (! inherits(summarise_attributes, "list")) {
- summarise_attributes = list(summarise_attributes)
- }
- if (spatial) {
- # We always take the first geometry.
- geom_colname = edge_geom_colname(x)
- summarise_attributes[geom_colname] = "first"
- }
- # The edge indices should be concatenated into a vector.
- summarise_attributes[".tidygraph_edge_index"] = "concat"
- # Simplify the network.
- x_new = simplify(
- x,
- remove.multiple = remove_multiple,
- remove.loops = remove_loops,
- edge.attr.comb = summarise_attributes
- ) %preserve_network_attrs% x
- # Igraph does not know about geometry list columns.
- # Summarizing them results in a list of sfg objects.
- # We should reconstruct the sfc geometry list column out of that.
- if (spatial) {
- new_edges = as_tibble(as_tbl_graph(x_new), "edges")
- new_edges[geom_colname] = list(st_sfc(new_edges[[geom_colname]]))
- new_edges = st_as_sf(new_edges, sf_column_name = geom_colname)
- st_crs(new_edges) = st_crs(x)
- st_precision(new_edges) = st_precision(x)
- edge_attribute_values(x_new) = new_edges
- }
- # If requested, original edge data should be stored in a .orig_data column.
- if (store_original_data) {
- edges = edges_as_table(x)
- edges$.tidygraph_edge_index = NULL
- new_edges = edges_as_table(x_new)
- copy_data = function(i) edges[i, , drop = FALSE]
- new_edges$.orig_data = lapply(new_edges$.tidygraph_edge_index, copy_data)
- edge_attribute_values(x_new) = new_edges
- }
+ if (is_present(summarise_attributes)) deprecate_sa("to_spatial_simple")
+ # Simplify.
+ x_new = simplify_network(
+ x = x,
+ remove_loops = remove_loops,
+ remove_multiple = remove_multiple,
+ attribute_summary = attribute_summary,
+ store_original_ids = TRUE,
+ store_original_data = store_original_data
+ )
# Return in a list.
list(
simple = x_new
@@ -561,407 +362,36 @@ to_spatial_simple = function(x, remove_multiple = TRUE, remove_loops = TRUE,
#' pseudo node. Returns a \code{morphed_sfnetwork} containing a single element
#' of class \code{\link{sfnetwork}}.
#'
-#' @param protect Nodes to be protected from being removed, no matter if they
-#' are a pseudo node or not. Can be given as a numeric vector containing node
-#' indices or a character vector containing node names. Can also be a set of
-#' geospatial features as object of class \code{\link[sf]{sf}} or
-#' \code{\link[sf]{sfc}}. In that case, for each of these features its nearest
-#' node in the network will be protected. Defaults to \code{NULL}, meaning that
-#' none of the nodes is protected.
-#'
-#' @param require_equal Should nodes only be removed when the attribute values
-#' of their incident edges are equal? Defaults to \code{FALSE}. If \code{TRUE},
-#' only pseudo nodes that have incident edges with equal attribute values are
-#' removed. May also be given as a vector of attribute names. In that case only
-#' those attributes are checked for equality. Equality tests are evaluated
-#' using the \code{==} operator.
+#' @param require_equal Which attributes of its incident edges should be equal
+#' in order for a pseudo node to be removed? Evaluated as a
+#' \code{\link[dplyr]{dplyr_tidy_select}} argument. Defaults to \code{NULL},
+#' meaning that attribute equality is not considered for pseudo node removal.
#'
-#' @importFrom igraph adjacent_vertices decompose degree delete_vertices
-#' edge_attr edge.attributes get.edge.ids igraph_opt igraph_options
-#' incident_edges induced_subgraph is_directed vertex_attr
-#' @importFrom sf st_as_sf st_cast st_combine st_crs st_equals st_is
-#' st_line_merge
+#' @importFrom lifecycle deprecated is_present
+#' @importFrom rlang enquo try_fetch
#' @export
-to_spatial_smooth = function(x,
- protect = NULL,
- summarise_attributes = "ignore",
- require_equal = FALSE,
+to_spatial_smooth = function(x, protect = NULL, require_equal = NULL,
+ attribute_summary = "ignore",
+ summarise_attributes = deprecated(),
store_original_data = FALSE) {
- # Change default igraph options.
- # This prevents igraph returns node or edge indices as formatted sequences.
- # We only need the "raw" integer indices.
- # Changing this option can lead to quiet a performance improvement.
- default_igraph_opt = igraph_opt("return.vs.es")
- igraph_options(return.vs.es = FALSE)
- on.exit(igraph_options(return.vs.es = default_igraph_opt))
- # Retrieve nodes and edges from the network.
- nodes = nodes_as_sf(x)
- edges = edges_as_table(x)
- # For later use:
- # --> Check if x is directed.
- # --> Check if x has spatially explicit edges.
- # --> Retrieve the name of the geometry column of the edges in x.
- directed = is_directed(x)
- spatial = is.sf(edges)
- geom_colname = attr(edges, "sf_column")
- ## ==========================
- # STEP I: DETECT PSEUDO NODES
- # The first step is to detect which nodes in x are pseudo nodes.
- # In directed networks, we define a pseudo node as follows:
- # --> A node with only one incoming and one outgoing edge.
- # In undirected networks, we define a pseudo node as follows:
- # --> A node with only two connections.
- ## ==========================
- if (directed) {
- pseudo = degree(x, mode = "in") == 1 & degree(x, mode = "out") == 1
- } else {
- pseudo = degree(x) == 2
- }
- if (! any(pseudo)) return (x)
- ## ===========================
- # STEP II: FILTER PSEUDO NODES
- # Users can define additional requirements for a node to be smoothed:
- # --> It should not be listed in the provided set of protected nodes.
- # --> Its incident edges should have equal values for some attributes.
- # In these cases we need to filter the set of detected pseudo nodes.
- ## ===========================
- # Detected pseudo nodes that are protected should be filtered out.
- if (! is.null(protect)) {
- # Parse the protect parameter values.
- # If protect is given as character vector:
- # --> Find the node indices belonging to these node names.
- # If protect is given as geospatial features:
- # --> First find the nearest node to each of these features.
- if (is.character(protect)) {
- # Obtain node names.
- # They should be stored in a node attribute column named "name".
- node_names = vertex_attr(x, "name")
- if (is.null(node_names)) {
- stop(
- "Node names should be stored in an attribute column called ",
- sQuote("name"),
- call. = FALSE
- )
- }
- # Match node names to node indices.
- matched_names = match(protect, node_names)
- if (any(is.na(matched_names))) {
- stop(
- "Unknown node names: ",
- paste(sQuote(protect[is.na(matched_names)]), collapse = " and "),
- ". Make sure node names are stored in an attribute column called ",
- sQuote("name"),
- call. = FALSE
- )
- }
- protect = matched_names
- } else if (is.sf(protect) | is.sfc(protect)) {
- protect = get_nearest_node_index(x, protect)
- }
- # Mark all protected nodes as not being a pseudo node.
- pseudo[protect] = FALSE
- if (! any(pseudo)) return (x)
+ if (is_present(summarise_attributes)) deprecate_sa("to_spatial_smooth")
+ # Evaluate the node query of the protect argument.
+ if (! try_fetch(is.null(protect), error = \(e) FALSE)) {
+ protect = evaluate_node_query(x, enquo(protect))
}
- # Check for equality of certain attributes between incident edges.
- # Detected pseudo nodes that fail this check should be filtered out.
- if (! isFALSE(require_equal)) {
- # If require_equal is TRUE all attributes will be checked for equality.
- # In other cases only a subset of attributes will be checked.
- if (isTRUE(require_equal)) {
- require_equal = edge_attribute_names(x)
- } else {
- # Check if all given attributes exist in the edges table of x.
- attr_exists = require_equal %in% edge_attribute_names(x)
- if (! all(attr_exists)) {
- stop(
- "Unknown edge attributes: ",
- paste(sQuote(require_equal[!attr_exists]), collapse = " and "),
- call. = FALSE
- )
- }
- }
- # Get the node indices of the detected pseudo nodes.
- pseudo_idxs = which(pseudo)
- # Get the edge indices of the incident edges of each pseudo node.
- # Combine them into a single numerical vector.
- # Note the + 1 since incident_edges returns indices starting from 0.
- incident_idxs = incident_edges(x, pseudo_idxs, mode = "all")
- incident_idxs = do.call("c", incident_idxs) + 1
- # Define for each of the incident edges if they are incoming or outgoing.
- # In undirected networks this can be read instead as "first or second".
- is_in = seq(1, 2 * length(pseudo_idxs), by = 2)
- is_out = seq(2, 2 * length(pseudo_idxs), by = 2)
- # Obtain the attributes to be checked for each of the incident edges.
- incident_attrs = edge.attributes(x, incident_idxs)[require_equal]
- # For each of these attributes:
- # --> Check if its value is equal for both incident edges of a pseudo node.
- check_equality = function(A) {
- # Check equality for each pseudo node.
- # NOTE:
- # --> Operator == is used because element-wise comparisons are needed.
- # --> Not sure if this approach works with identical() or all.equal().
- are_equal = A[is_in] == A[is_out]
- # If one of the two values is NA or NaN:
- # --> The result of the element-wise comparison is always NA.
- # --> This means the two elements are certainly not equal.
- # --> Hence the result of this comparison can be set to FALSE.
- are_equal[is.na(are_equal)] = FALSE
- are_equal
- }
- tests = lapply(incident_attrs, check_equality)
- # If one or more equality tests failed for a detected pseudo node:
- # --> Mark this pseudo node as FALSE, i.e. not being a pseudo node.
- failed = rowSums(do.call("cbind", tests)) != length(require_equal)
- pseudo[pseudo_idxs[failed]] = FALSE
- if (! any(pseudo)) return (x)
+ # Evaluate the edge attribute column query of the require equal argument.
+ if (! try_fetch(is.null(require_equal), error = \(e) FALSE)) {
+ require_equal = evaluate_edge_attribute_query(x, enquo(require_equal))
}
- ## ====================================
- # STEP II: INITIALIZE REPLACEMENT EDGES
- # When removing pseudo nodes their incident edges get removed to.
- # To preserve the network connectivity we need to:
- # --> Find the two adjacent nodes of a pseudo node.
- # --> Connect these by merging the incident edges of the pseudo node.
- # An adjacent node of a pseudo node can also be another pseudo node.
- # Instead of processing each pseudo node on its own, we will:
- # --> Find connected sets of pseudo nodes.
- # --> Find the adjacent non-pseudo nodes (junction or pendant) to that set.
- # --> Connect them by merging the edges in the set plus its incident edges.
- ## ====================================
- # Subset x to only contain pseudo nodes and the edges between them.
- # Decompose this subgraph to find connected sets of pseudo nodes.
- pseudo_sets = decompose(induced_subgraph(x, pseudo))
- # For each set of connected pseudo nodes:
- # --> Find the indices of the adjacent nodes.
- # --> Find the indices of the edges that need to be merged.
- # The workflow for this is different for directed and undirected networks.
- if (directed) {
- initialize_replacement_edge = function(S) {
- # Retrieve the original node indices of the pseudo nodes in this set.
- # Retrieve the original edge indices of the edges that connect them.
- N = vertex_attr(S, ".tidygraph_node_index")
- E = edge_attr(S, ".tidygraph_edge_index")
- # Find the following:
- # --> The index of the pseudo node where an edge comes into the set.
- # --> The index of the pseudo node where an edge goes out of the set.
- n_i = N[degree(S, mode = "in") == 0]
- n_o = N[degree(S, mode = "out") == 0]
- # If these nodes do not exists:
- # --> We are dealing with a loop of connected pseudo nodes.
- # --> The loop is by definition not connected to the rest of the network.
- # --> Hence, there is no need to create a new edge.
- # --> Therefore we should not return a path.
- if (length(n_i) == 0) return (NULL)
- # Find the following:
- # --> The index of the edge that comes in to the pseudo node set.
- # --> The index of the non-pseudo node at the other end of that edge.
- # We'll call this the source node and source edge of the set.
- # Note the + 1 since adjacent_vertices returns indices starting from 0.
- source_node = adjacent_vertices(x, n_i, mode = "in")[[1]] + 1
- source_edge = get.edge.ids(x, c(source_node, n_i))
- # Find the following:
- # --> The index of the edge that goes out of the pseudo node set.
- # --> The index of the non-pseudo node at the other end of that edge.
- # We'll call this the sink node and sink edge of the set.
- # Note the + 1 since adjacent_vertices returns indices starting from 0.
- sink_node = adjacent_vertices(x, n_o, mode = "out")[[1]] + 1
- sink_edge = get.edge.ids(x, c(n_o, sink_node))
- # List indices of all edges that will be merged into the replacement edge.
- edge_idxs = c(source_edge, E, sink_edge)
- # Return all retrieved information in a list.
- list(
- from = as.integer(source_node),
- to = as.integer(sink_node),
- .tidygraph_edge_index = as.integer(edge_idxs)
- )
- }
- } else {
- initialize_replacement_edge = function(S) {
- # Retrieve the original node indices of the pseudo nodes in this set.
- # Retrieve the original edge indices of the edges that connect them.
- N = vertex_attr(S, ".tidygraph_node_index")
- E = edge_attr(S, ".tidygraph_edge_index")
- # Find the following:
- # --> The two adjacent non-pseudo nodes to the set.
- # --> The edges that connect these nodes to the set.
- # We'll call these the adjacent nodes and incident edges of the set.
- # --> The adjacent node with the lowest index will be the source node.
- # --> The adjacent node with the higest index will be the sink node.
- if (length(N) == 1) {
- # When we have a single pseudo node that forms a set:
- # --> It will be adjacent to both adjacent nodes of the set.
- # Note the + 1 since adjacent_vertices returns indices starting from 0.
- adjacent = adjacent_vertices(x, N)[[1]] + 1
- if (length(adjacent) == 1) {
- # If there is only one adjacent node to the pseudo node:
- # --> The two adjacent nodes of the set are the same node.
- # --> We only have to query for incident edges of the set once.
- incident = get.edge.ids(x, c(adjacent, N))
- source_node = adjacent
- source_edge = incident[1]
- sink_node = adjacent
- sink_edge = incident[2]
- } else {
- # If there are two adjacent nodes to the pseudo node:
- # --> The one with the lowest index will be source node.
- # --> The one with the highest index will be sink node.
- source_node = min(adjacent)
- source_edge = get.edge.ids(x, c(source_node, N))
- sink_node = max(adjacent)
- sink_edge = get.edge.ids(x, c(N, sink_node))
- }
- } else {
- # When we have a set of multiple pseudo nodes:
- # --> There are two pseudo nodes that form the boundary of the set.
- # --> These are the ones connected to only one other pseudo node.
- N_b = N[degree(S) == 1]
- # If these boundaries do not exist:
- # --> We are dealing with a loop of connected pseudo nodes.
- # --> The loop is by definition not connected to the rest of the network.
- # --> Hence, there is no need to create a new edge.
- # --> Therefore we should not return a path.
- if (length(N_b) == 0) return (NULL)
- # Find the adjacent nodes of the set.
- # These are the adjacent non-pseudo nodes to the boundaries of the set.
- # We find them iteratively for the two boundary nodes of the set:
- # --> A boundary connects to one pseudo node and one non-pseudo node.
- # --> The non-pseudo node is the one not present in the pseudo set.
- # Note the + 1 since adjacent_vertices returns indices starting from 0.
- get_set_neighbour = function(n) {
- all = adjacent_vertices(x, n)[[1]] + 1
- all[!(all %in% N)]
- }
- adjacent = do.call("c", lapply(N_b, get_set_neighbour))
- # The adjacent node with the lowest index will be source node.
- # The adjacent node with the highest index will be sink node.
- N_b = N_b[order(adjacent)]
- source_node = min(adjacent)
- source_edge = get.edge.ids(x, c(source_node, N_b[1]))
- sink_node = max(adjacent)
- sink_edge = get.edge.ids(x, c(N_b[2], sink_node))
- }
- # List indices of all edges that will be merged into the replacement edge.
- edge_idxs = c(source_edge, E, sink_edge)
- # Return all retrieved information in a list.
- list(
- from = as.integer(source_node),
- to = as.integer(sink_node),
- .tidygraph_edge_index = as.integer(edge_idxs)
- )
- }
- }
- new_idxs = lapply(pseudo_sets, initialize_replacement_edge)
- new_idxs = new_idxs[lengths(new_idxs) != 0] # Remove NULLs.
- ## ===================================
- # STEP III: SUMMARISE EDGE ATTRIBUTES
- # Each replacement edge replaces multiple original edges.
- # Their attributes should all be summarised in a single value.
- # The summary techniques to be used are given as summarise_attributes.
- ## ===================================
- # Obtain the attribute values of all original edges in the network.
- # These should not include the geometries and original edge indices.
- exclude = c(".tidygraph_edge_index", geom_colname)
- edge_attrs = edge.attributes(x)
- edge_attrs = edge_attrs[!(names(edge_attrs) %in% exclude)]
- # For each replacement edge:
- # --> Summarise the attributes of the edges it replaces into single values.
- merge_attrs = function(E) {
- orig_edges = E$.tidygraph_edge_index
- orig_attrs = lapply(edge_attrs, `[`, orig_edges)
- apply_summary_function = function(i) {
- # Store return value in a list.
- # This prevents automatic type promotion when rowbinding later on.
- list(get_summary_function(i, summarise_attributes)(orig_attrs[[i]]))
- }
- new_attrs = lapply(names(orig_attrs), apply_summary_function)
- names(new_attrs) = names(orig_attrs)
- new_attrs
- }
- new_attrs = lapply(new_idxs, merge_attrs)
- ## ===================================
- # STEP VI: CONCATENATE EDGE GEOMETRIES
- # If the edges to be replaced have geometries:
- # --> These geometries have to be concatenated into a single new geometry.
- # --> The new geometry should go from the defined source to sink node.
- ## ===================================
- if (spatial) {
- # Obtain geometries of all original edges and nodes in the network.
- edge_geoms = st_geometry(edges)
- node_geoms = st_geometry(nodes)
- # For each replacement edge:
- # --> Merge geometries of the edges it replaces into a single geometry.
- merge_geoms = function(E) {
- orig_edges = E$.tidygraph_edge_index
- orig_geoms = edge_geoms[orig_edges]
- new_geom = st_line_merge(st_combine(orig_geoms))
- # There are two situations where merging lines like this is problematic.
- # 1. When the source and sink node of the new edge are the same.
- # --> In this case the original edges to be replaced form a closed loop.
- # --> Any original endpoint can then be the startpoint of the new edge.
- # --> st_line_merge chooses the point with the lowest x coordinate.
- # --> This is not necessarily the source node we defined.
- # --> This behaviour comes from third partly libs and can not be tuned.
- # --> Hence, we manually need to reorder the points in the merged line.
- if (E$from == E$to && length(orig_edges) > 1) {
- pts = st_cast(new_geom, "POINT")
- from_idx = st_equals(node_geoms[E$from], pts)[[1]]
- if (length(from_idx) == 1) {
- n = length(pts)
- ordered_pts = c(pts[c(from_idx:n)], pts[c(2:from_idx)])
- new_geom = st_cast(st_combine(ordered_pts), "LINESTRING")
- }
- }
- # 2. When the new edge crosses itself.
- # --> In this case st_line_merge creates a multilinestring geometry.
- # --> We just want a regular linestring (even if this is invalid).
- if (any(st_is(new_geom, "MULTILINESTRING"))) {
- new_geom = multilinestrings_to_linestrings(new_geom)
- }
- new_geom
- }
- new_geoms = do.call("c", lapply(new_idxs, merge_geoms))
- }
- ## ============================================
- # STEP V: ADD REPLACEMENT EDGES TO THE NETWORK
- # The newly created edges should be added to the original network.
- # This must happen before removing the pseudo nodes.
- # Otherwise their from and to values do not match the correct node indices.
- ## ============================================
- # Create the data frame for the new edges.
- new_edges = cbind(
- data.frame(do.call("rbind", new_idxs)),
- data.frame(do.call("rbind", new_attrs))
+ # Smooth.
+ x_new = smooth_pseudo_nodes(
+ x = x,
+ protect = protect,
+ require_equal = require_equal,
+ attribute_summary = attribute_summary,
+ store_original_ids = TRUE,
+ store_original_data = store_original_data
)
- new_edges[geom_colname] = list(new_geoms)
- # Bind together with the original edges.
- # Merged edges may have list-columns for some attributes.
- # This requires a bit more complicated rowbinding.
- all_edges = bind_rows_list(edges, new_edges)
- if (spatial) all_edges = st_as_sf(all_edges, sf_column_name = geom_colname)
- # Recreate an sfnetwork.
- x_new = sfnetwork_(nodes, all_edges, directed = directed)
- ## ============================================
- # STEP VI: REMOVE PSEUDO NODES FROM THE NETWORK
- # Remove all the detected pseudo nodes from the original network.
- # This will automatically also remove their incident edges.
- # Remember that their replacement edges have already been added in step IV.
- # From and to indices will be updated automatically.
- ## ============================================
- x_new = delete_vertices(x_new, pseudo) %preserve_all_attrs% x
- ## ==============================================
- # STEP VII: STORE ORIGINAL EDGE DATA IF REQUESTED
- # Users can request to store the data of original edges in a special column.
- # This column will - by tidygraph design - be named .orig_data.
- # The value in this column is for each edge a tibble containing:
- # --> The data of the original edges that were merged into the new edge.
- ## ==============================================
- if (store_original_data) {
- # Store the original edge data in a .orig_data column.
- new_edges = edges_as_sf(x_new)
- edges$.tidygraph_edge_index = NULL
- copy_data = function(i) edges[i, , drop = FALSE]
- new_edges$.orig_data = lapply(new_edges$.tidygraph_edge_index, copy_data)
- edge_attribute_values(x_new) = new_edges
- }
# Return in a list.
list(
smooth = x_new
@@ -969,185 +399,48 @@ to_spatial_smooth = function(x,
}
#' @describeIn spatial_morphers Construct a subdivision of the network by
-#' subdividing edges at each interior point that is equal to any
-#' other interior or boundary point in the edges table. Interior points in this
-#' sense are those points that are included in their linestring geometry
-#' feature but are not endpoints of it, while boundary points are the endpoints
-#' of the linestrings. The network is reconstructed after subdivision such that
-#' edges are connected at the points of subdivision. Returns a
-#' \code{morphed_sfnetwork} containing a single element of class
-#' \code{\link{sfnetwork}}. This morpher requires edges to be spatially
-#' explicit and nodes to be spatially unique (i.e. not more than one node at
-#' the same spatial location).
-#' @importFrom igraph is_directed
-#' @importFrom sf st_crs st_crs<- st_geometry st_geometry<- st_precision
-#' st_precision<-
-#' @importFrom sfheaders sf_to_df sfc_linestring sfc_point
+#' subdividing edges at interior points. Subdividing means that a new node is
+#' added on an edge, and the edge is split in two at that location. Interior
+#' points are those points that shape a linestring geometry feature but are not
+#' endpoints of it. Returns a \code{morphed_sfnetwork} containing a single
+#' element of class \code{\link{sfnetwork}}. This morpher requires edges to be
+#' spatially explicit.
+#'
+#' @param all Should edges be subdivided at all their interior points? If set
+#' to \code{FALSE}, edges are only subdivided at those interior points that
+#' share their location with any other interior or boundary point (a node) in
+#' the edges table. Defaults to \code{FALSE}. By default sfnetworks rounds
+#' coordinates to 12 decimal places to determine spatial equality. You can
+#' influence this behavior by explicitly setting the precision of the network
+#' using \code{\link[sf]{st_set_precision}}.
+#'
+#' @param merge Should multiple subdivision points at the same location be
+#' merged into a single node, and should subdivision points at the same
+#' location as an existing node be merged into that node? Defaults to
+#' \code{TRUE}. If set to \code{FALSE}, each subdivision point is added
+#' separately as a new node to the network. By default sfnetworks rounds
+#' coordinates to 12 decimal places to determine spatial equality. You can
+#' influence this behavior by explicitly setting the precision of the network
+#' using \code{\link[sf]{st_set_precision}}.
+#'
+#' @importFrom rlang enquo try_fetch
#' @export
-to_spatial_subdivision = function(x) {
- require_explicit_edges(x)
- if (will_assume_constant(x)) raise_assume_constant("to_spatial_subdivision")
- # Retrieve nodes and edges from the network.
- nodes = nodes_as_sf(x)
- edges = edges_as_sf(x)
- # For later use:
- # --> Check wheter x is directed.
- directed = is_directed(x)
- ## ===========================
- # STEP I: DECOMPOSE THE EDGES
- # Decompose the edges linestring geometries into the points that shape them.
- ## ===========================
- # Extract all points from the linestring geometries of the edges.
- edge_pts = sf_to_df(edges)
- # Extract two subsets of information:
- # --> One with only the coordinates of the points
- # --> Another with indices describing to which edge a point belonged.
- edge_coords = edge_pts[names(edge_pts) %in% c("x", "y", "z", "m")]
- edge_idxs = edge_pts$linestring_id
- ## =======================================
- # STEP II: DEFINE WHERE TO SUBDIVIDE EDGES
- # Edges should be split at locations where:
- # --> An edge interior point is equal to a boundary point in another edge.
- # --> An edge interior point is equal to an interior point in another edge.
- # Hence, we need to split edges at point that:
- # --> Are interior points.
- # --> Have at least one duplicate among the other edge points.
- ## =======================================
- # Find which of the edge points is a boundary point.
- is_startpoint = !duplicated(edge_idxs)
- is_endpoint = !duplicated(edge_idxs, fromLast = TRUE)
- is_boundary = is_startpoint | is_endpoint
- # Find which of the edge points occur more than once.
- is_duplicate_desc = duplicated(edge_coords)
- is_duplicate_asc = duplicated(edge_coords, fromLast = TRUE)
- has_duplicate = is_duplicate_desc | is_duplicate_asc
- # Split points are those edge points satisfying both of the following rules:
- # --> 1) They have at least one duplicate among the other edge points.
- # --> 2) They are not edge boundary points themselves.
- is_split = has_duplicate & !is_boundary
- if (! any(is_split)) return (x)
- ## ================================
- # STEP III: DUPLICATE SPLIT POINTS
- # The split points are currently a single interior point in an edge.
- # They will become the endpoint of one edge *and* the startpoint of another.
- # Hence, each split point needs to be duplicated.
- ## ================================
- # Create the repetition vector:
- # --> This defines for each edge point if it should be duplicated.
- # --> A value of '1' means 'store once', i.e. don't duplicate.
- # --> A value of '2' means 'store twice', i.e. duplicate.
- # --> Split points will be part of two new edges and should be duplicated.
- reps = rep(1L, nrow(edge_coords))
- reps[is_split] = 2L
- # Create the new coordinate data frame by duplicating split points.
- new_edge_coords = data.frame(lapply(edge_coords, function(i) rep(i, reps)))
- ## ==========================================
- # STEP IV: CONSTRUCT THE NEW EDGES GEOMETRIES
- # With the new coords of the edge points we need to recreate linestrings.
- # First we need to know which edge points belong to which *new* edge.
- # Then we need to build a linestring geometry for each new edge.
- ## ==========================================
- # First assign each new edge point coordinate its *original* edge index.
- # --> Then increment those accordingly at each split point.
- orig_edge_idxs = rep(edge_idxs, reps)
- # Original edges are subdivided at each split point.
- # Therefore, a new edge originates from each split point.
- # Hence, to get the new edge indices:
- # --> Increment each original edge index by 1 at each split point.
- incs = integer(nrow(new_edge_coords)) # By default don't increment.
- incs[which(is_split) + 1:sum(is_split)] = 1L # Add 1 after each split.
- new_edge_idxs = orig_edge_idxs + cumsum(incs)
- new_edge_coords$edge_id = new_edge_idxs
- # Build the new edge geometries.
- new_edge_geoms = sfc_linestring(new_edge_coords, linestring_id = "edge_id")
- st_crs(new_edge_geoms) = st_crs(edges)
- st_precision(new_edge_geoms) = st_precision(edges)
- new_edge_coords$edge_id = NULL
- ## ===================================
- # STEP V: CONSTRUCT THE NEW EDGE DATA
- # We now have the geometries of the new edges.
- # However, the original edge attributes got lost.
- # We will restore them by:
- # --> Adding back the attributes to edges that were not split.
- # --> Duplicating original attributes within splitted edges.
- # Beware that from and to columns will remain unchanged at this stage.
- # We will update them later.
- ## ===================================
- # Find which *original* edge belongs to which *new* edge:
- # --> Use the lists of edge indices mapped to the new edge points.
- # --> There we already mapped each new edge point to its original edge.
- # --> First define which new edge points are startpoints of new edges.
- # --> Then retrieve the original edge index from these new startpoints.
- # --> This gives us a single original edge index for each new edge.
- is_new_startpoint = !duplicated(new_edge_idxs)
- orig_edge_idxs = orig_edge_idxs[is_new_startpoint]
- # Duplicate original edge data whenever needed.
- new_edges = edges[orig_edge_idxs, ]
- # Set the new edge geometries as geometries of these new edges.
- st_geometry(new_edges) = new_edge_geoms
- ## ==========================================
- # STEP VI: CONSTRUCT THE NEW NODE GEOMETRIES
- # All split points are now boundary points of new edges.
- # All edge boundaries become nodes in the network.
- ## ==========================================
- is_new_boundary = rep(is_split | is_boundary, reps)
- new_node_geoms = sfc_point(new_edge_coords[is_new_boundary, ])
- st_crs(new_node_geoms) = st_crs(nodes)
- st_precision(new_node_geoms) = st_precision(nodes)
- ## =====================================
- # STEP VII: CONSTRUCT THE NEW NODE DATA
- # We now have the geometries of the new nodes.
- # However, the original node attributes got lost.
- # We will restore them by:
- # --> Adding back the attributes to nodes that were already a node before.
- # --> Filling attribute values of newly added nodes with NA.
- # Beware at this stage the nodes are recreated from scratch.
- # That means each boundary point of the new edges is stored as separate node.
- # Boundaries with equal geometries will be merged into a single node later.
- ## =====================================
- # Find which of the *original* edge points equaled which *original* node.
- # If an edge point did not equal a node, store NA instead.
- node_idxs = rep(NA, nrow(edge_pts))
- if (directed) {
- node_idxs[is_boundary] = edge_boundary_node_indices(x)
- } else {
- node_idxs[is_boundary] = edge_boundary_point_indices(x)
+to_spatial_subdivision = function(x, protect = NULL, all = FALSE,
+ merge = TRUE) {
+ # Evaluate the edge query of the protect argument.
+ if (! try_fetch(is.null(protect), error = \(e) FALSE)) {
+ protect = evaluate_edge_query(x, enquo(protect))
}
- # Find which of the *original* nodes belong to which *new* edge boundary.
- # If a new edge boundary does not equal an original node, store NA instead.
- orig_node_idxs = rep(node_idxs, reps)[is_new_boundary]
- # Retrieve original node data for each new edge boundary.
- # Rows of newly added nodes will be NA.
- new_nodes = nodes[orig_node_idxs, ]
- # Set the new node geometries as geometries of these new nodes.
- st_geometry(new_nodes) = new_node_geoms
- ## ==================================================
- # STEP VIII: UPDATE FROM AND TO INDICES OF NEW EDGES
- # Now we updated the node data, the node indices changes.
- # Therefore we need to update the from and to columns of the edges as well.
- ## ==================================================
- # Define the indices of the new nodes.
- # Equal geometries should get the same index.
- new_node_idxs = st_match(new_node_geoms)
- # Map node indices to edges.
- is_source = rep(c(TRUE, FALSE), length(new_node_geoms) / 2)
- new_edges$from = new_node_idxs[is_source]
- new_edges$to = new_node_idxs[!is_source]
- ## =============================
- # STEP IX: UPDATE THE NEW NODES
- # We can now remove the duplicated node geometries from the new nodes data.
- # Then, each location is represented by a single node.
- ## =============================
- new_nodes = new_nodes[!duplicated(new_node_idxs), ]
- ## ============================
- # STEP X: RECREATE THE NETWORK
- # Use the new nodes data and the new edges data to create the new network.
- ## ============================
- # Create new network.
- x_new = sfnetwork_(new_nodes, new_edges, directed = directed)
+ # Subdivide.
+ x_new = subdivide_edges(
+ x = x,
+ protect = protect,
+ all = all,
+ merge = merge
+ )
# Return in a list.
list(
- subdivision = x_new %preserve_network_attrs% x
+ subdivision = x_new
)
}
@@ -1160,18 +453,21 @@ to_spatial_subdivision = function(x) {
#'
#' @param subset_by Whether to create subgraphs based on nodes or edges.
#'
+#' @importFrom cli cli_alert
#' @export
to_spatial_subset = function(x, ..., subset_by = NULL) {
+ # Subset.
if (is.null(subset_by)) {
subset_by = attr(x, "active")
- message("Subsetting by ", subset_by)
+ cli_alert("Subsetting by {subset_by}")
}
x_new = switch(
subset_by,
nodes = spatial_filter_nodes(x, ...),
edges = spatial_filter_edges(x, ...),
- raise_unknown_input(subset_by)
+ raise_unknown_input("subset_by", subset_by, c("nodes", "edges"))
)
+ # Return in a list.
list(
subset = x_new
)
@@ -1189,3 +485,32 @@ to_spatial_transformed = function(x, ...) {
transformed = st_transform(x, ...)
)
}
+
+#' @describeIn spatial_morphers Merge nodes with equal geometries into a single
+#' node. Returns a \code{morphed_sfnetwork} containing a single element of
+#' class \code{\link{sfnetwork}}. By default sfnetworks rounds coordinates to
+#' 12 decimal places to determine spatial equality. You can influence this
+#' behavior by explicitly setting the precision of the network using
+#' \code{\link[sf]{st_set_precision}}.
+#'
+#' @export
+to_spatial_unique = function(x, attribute_summary = "ignore",
+ store_original_data = FALSE) {
+ # Create groups.
+ group_ids = st_match_points(pull_node_geom(x))
+ # Contract.
+ x_new = contract_nodes(
+ x = x,
+ groups = group_ids,
+ simplify = FALSE,
+ compute_centroids = FALSE,
+ reconnect_edges = FALSE,
+ attribute_summary = attribute_summary,
+ store_original_ids = TRUE,
+ store_original_data = store_original_data
+ )
+ # Return in a list.
+ list(
+ unique = x_new
+ )
+}
diff --git a/R/mozart.R b/R/mozart.R
new file mode 100644
index 00000000..a622219a
--- /dev/null
+++ b/R/mozart.R
@@ -0,0 +1,19 @@
+#' Point locations for places about W. A. Mozart in Salzburg, Austria
+#'
+#' A dataset containing point locations (museums, sculptures, squares,
+#' universities, etc.) of places named after Wolfgang Amadeus Mozart
+#' in the city of Salzburg, Austria.
+#' The data are taken from OpenStreetMap.
+#' See `data-raw/mozart.R` for code on its creation.
+#'
+#' @format An object of class \code{\link[sf]{sf}} with \code{LINESTRING}
+#' geometries, containing 17 features and four columns:
+#' \describe{
+#' \item{name}{the name of the point location}
+#' \item{type}{the type of location, e.g. museum, artwork, cinema, etc.}
+#' \item{website}{the website URL for more information
+#' about the place, if available}
+#' \item{geometry}{the geometry list column}
+#' }
+#' @source \url{https://www.openstreetmap.org}
+"mozart"
diff --git a/R/nb.R b/R/nb.R
new file mode 100644
index 00000000..4a9f27ef
--- /dev/null
+++ b/R/nb.R
@@ -0,0 +1,165 @@
+#' Conversion between neighbor lists and sfnetworks
+#'
+#' Neighbor lists are sparse adjacency matrices in list format that specify for
+#' each node to which other nodes it is adjacent. They occur for example in the
+#' \pkg{sf} package as \code{\link[sf]{sgbp}} objects, and are also
+#' frequently used in the \pkg{spdep} package.
+#'
+#' @param x For the conversion to sfnetwork: a neighbor list, which is a list
+# with one element per node that holds the integer indices of the nodes it is
+#' adjacent to. For the conversion from sfnetwork: an object of class
+#' \code{\link{sfnetwork}}.
+#'
+#' @param nodes The nodes themselves as an object of class \code{\link[sf]{sf}}
+#' or \code{\link[sf]{sfc}} with \code{POINT} geometries.
+#'
+#' @param directed Should the constructed network be directed? Defaults to
+#' \code{TRUE}.
+#'
+#' @param edges_as_lines Should the created edges be spatially explicit, i.e.
+#' have \code{LINESTRING} geometries stored in a geometry list column? Defaults
+#' to \code{TRUE}.
+#'
+#' @param compute_length Should the geographic length of the edges be stored in
+#' a column named \code{length}? Defaults to \code{FALSE}.
+#'
+#' @param force Should validity checks be skipped? Defaults to \code{FALSE},
+#' meaning that network validity checks are executed when constructing the
+#' network. These checks make sure that the provided neighbor list has a valid
+#' structure, i.e. that its length is equal to the number of provided nodes and
+#' that its values are all integers referring to one of the nodes.
+#'
+#' @param direction The direction that defines if two nodes are neighbors.
+#' Defaults to \code{'out'}, meaning that the direction given by the network is
+#' followed and node j is only a neighbor of node i if there exists an edge
+#' i->j. May be set to \code{'in'}, meaning that the opposite direction is
+#' followed and node j is only a neighbor of node i if there exists an edge
+#' j->i. May also be set to \code{'all'}, meaning that the network is
+#' considered to be undirected. This argument is ignored for undirected
+#' networks.
+#'
+#' @return For the conversion to sfnetwork: An object of class
+#' \code{\link{sfnetwork}}. For the conversion from sfnetwork: a neighbor list,
+#' which is a list with one element per node that holds the integer indices of
+#' the nodes it is adjacent to.
+#'
+#' @name nb
+NULL
+
+#' @name nb
+#' @importFrom tibble tibble
+#' @export
+nb_to_sfnetwork = function(x, nodes, directed = TRUE, edges_as_lines = TRUE,
+ compute_length = FALSE, force = FALSE) {
+ if (! force) validate_nb(x, nodes)
+ # Define the edges by their from and to nodes.
+ # An edge will be created between each neighboring node pair.
+ edges = rbind(
+ rep(c(1:length(x)), lengths(x)),
+ do.call("c", x)
+ )
+ if (! directed && length(edges) > 0) {
+ # If the network is undirected:
+ # --> Edges i -> j and j -> i are the same.
+ # --> We create the network only with unique edges.
+ edges = unique(apply(edges, 2, sort), MARGIN = 2)
+ }
+ # Create the sfnetwork object.
+ sfnetwork(
+ nodes = nodes,
+ edges = tibble(from = edges[1, ], to = edges[2, ]),
+ directed = directed,
+ edges_as_lines = edges_as_lines,
+ compute_length = compute_length,
+ force = TRUE
+ )
+}
+
+#' @name nb
+#' @importFrom igraph as_adj_list igraph_opt igraph_options
+#' @export
+sfnetwork_to_nb = function(x, direction = "out") {
+ # Change default igraph options.
+ # This prevents igraph returns node or edge indices as formatted sequences.
+ # We only need the "raw" integer indices.
+ # Changing this option improves performance especially on large networks.
+ default_igraph_opt = igraph_opt("return.vs.es")
+ igraph_options(return.vs.es = FALSE)
+ on.exit(igraph_options(return.vs.es = default_igraph_opt))
+ # Return the neighbor list, without node names.
+ nb = as_adj_list(x, mode = direction, loops = "once", multiple = FALSE)
+ names(nb) = NULL
+ nb
+}
+
+#' Convert an adjacency matrix into a neighbor list
+#'
+#' Adjacency matrices of networks are n x n matrices with n being the number of
+#' nodes, and element Aij holding a \code{TRUE} value if node i is adjacent to
+#' node j, and a \code{FALSE} value otherwise. Neighbor lists are the sparse
+#' version of these matrices, coming in the form of a list with one element per
+#' node, holding the indices of the nodes it is adjacent to.
+#'
+#' @param x An adjacency matrix of class \code{\link{matrix}}. Non-logical
+#' matrices are first converted into logical matrices using
+#' \code{\link{as.logical}}.
+#'
+#' @return The sparse adjacency matrix as object of class \code{\link{list}}.
+#'
+#' @noRd
+adj_to_nb = function(x, force = FALSE) {
+ if (! is.logical(x)) {
+ apply(x, 1, \(x) which(as.logical(x)), simplify = FALSE)
+ } else {
+ apply(x, 1, which, simplify = FALSE)
+ }
+}
+
+#' Validate the structure of a neighbor list
+#'
+#' Neighbor lists are sparse adjacency matrices in list format that specify for
+#' each node to which other nodes it is adjacent.
+#'
+#' @param x Object to be validated.
+#'
+#' @param nodes The nodes that are referenced in the neighbor list as an object
+#' of class \code{\link[sf]{sf}} or \code{\link[sf]{sfc}} with \code{POINT}
+#' geometries.
+#'
+#' @return Nothing if the given object is a valid neighbor list referencing
+#' the given nodes. Otherwise, an error is thrown.
+#'
+#' @importFrom cli cli_abort
+#' @importFrom rlang try_fetch
+#' @importFrom sf st_geometry
+#' @noRd
+validate_nb = function(x, nodes) {
+ n_nodes = length(st_geometry(nodes))
+ # Check 1: Is the length of x equal to the number of provided nodes?
+ if (! length(x) == n_nodes) {
+ cli_abort(c(
+ "The length of the sparse matrix should match the number of nodes.",
+ "x" = paste(
+ "The provided matrix has length {length(x)},",
+ "while there are {n_nodes} nodes."
+ )
+ ))
+ }
+ # Check 2: Are all referenced node indices integers?
+ if (! all(vapply(x, is.integer, FUN.VALUE = logical(1)))) {
+ x = try_fetch(
+ lapply(x, as.integer),
+ error = function(e) {
+ cli_abort("The sparse matrix should contain integer node indices.")
+ }
+ )
+ }
+ # Check 3: Are all referenced node indices referring to a provided node?
+ ids_in_bounds = function(x) length(x) == 0 || all(x > 0 & x <= n_nodes)
+ if (! all(vapply(x, ids_in_bounds, FUN.VALUE = logical(1)))) {
+ cli_abort(c(
+ "The sparse matrix should contain valid node indices",
+ "x" = "Some of the given indices are out of bounds"
+ ))
+ }
+}
diff --git a/R/nearest.R b/R/nearest.R
new file mode 100644
index 00000000..d4db6ab3
--- /dev/null
+++ b/R/nearest.R
@@ -0,0 +1,98 @@
+#' Extract the nearest nodes or edges to given spatial features
+#'
+#' @param x An object of class \code{\link{sfnetwork}}.
+#'
+#' @param y Spatial features as object of class \code{\link[sf]{sf}} or
+#' \code{\link[sf]{sfc}}.
+#'
+#' @param focused Should only features that are in focus be extracted? Defaults
+#' to \code{TRUE}. See \code{\link[tidygraph]{focus}} for more information on
+#' focused networks.
+#'
+#' @details To determine the nearest node or edge to each feature in \code{y}
+#' the function \code{\link[sf]{st_nearest_feature}} is used. When extracting
+#' nearest edges, spatially explicit edges are required, i.e. the edges table
+#' should have a geometry column.
+#'
+#' @return An object of class \code{\link[sf]{sf}} with each row containing
+#' the nearest node or edge to the corresponding spatial features in \code{y}.
+#'
+#' @examples
+#' library(sf, quietly = TRUE)
+#'
+#' net = as_sfnetwork(roxel)
+#' pts = st_sample(st_bbox(roxel), 6)
+#'
+#' nodes = nearest_nodes(net, pts)
+#' edges = nearest_edges(net, pts)
+#'
+#' oldpar = par(no.readonly = TRUE)
+#' par(mar = c(1,1,1,1), mfrow = c(1,2))
+#'
+#' plot(net, main = "Nearest nodes")
+#' plot(pts, cex = 2, col = "red", pch = 20, add = TRUE)
+#' plot(st_geometry(nodes), cex = 2, col = "orange", pch = 20, add = TRUE)
+#'
+#' plot(net, main = "Nearest edges")
+#' plot(pts, cex = 2, col = "red", pch = 20, add = TRUE)
+#' plot(st_geometry(edges), lwd = 2, col = "orange", pch = 20, add = TRUE)
+#'
+#' par(oldpar)
+#'
+#' @name nearest
+#' @importFrom sf st_geometry st_nearest_feature
+#' @export
+nearest_nodes = function(x, y, focused = TRUE) {
+ nodes = nodes_as_sf(x, focused = focused)
+ nodes[st_nearest_feature(st_geometry(y), nodes), ]
+}
+
+#' @name nearest
+#' @importFrom sf st_geometry st_nearest_feature
+#' @export
+nearest_edges = function(x, y, focused = TRUE) {
+ edges = edges_as_sf(x, focused = focused)
+ edges[st_nearest_feature(st_geometry(y), edges), ]
+}
+
+#' Extract the indices of nearest nodes or edges to given spatial features
+#'
+#' @param x An object of class \code{\link{sfnetwork}}.
+#'
+#' @param y Spatial features as object of class \code{\link[sf]{sf}} or
+#' \code{\link[sf]{sfc}}.
+#'
+#' @param focused Should only the indices of features that are in focus be
+#' extracted? Defaults to \code{TRUE}. See \code{\link[tidygraph]{focus}} for
+#' more information on focused networks.
+#'
+#' @details To determine the nearest node or edge to each feature in \code{y}
+#' the function \code{\link[sf]{st_nearest_feature}} is used. When extracting
+#' nearest edges, spatially explicit edges are required, i.e. the edges table
+#' should have a geometry column.
+#'
+#' @return An integer vector with each element containing the index of the
+#' nearest node or edge to the corresponding spatial features in \code{y}.
+#'
+#' @examples
+#' library(sf, quietly = TRUE)
+#'
+#' net = as_sfnetwork(roxel)
+#' pts = st_sample(st_bbox(roxel), 6)
+#'
+#' nearest_node_ids(net, pts)
+#' nearest_edge_ids(net, pts)
+#'
+#' @name nearest_ids
+#' @importFrom sf st_geometry st_nearest_feature
+#' @export
+nearest_node_ids = function(x, y, focused = TRUE) {
+ st_nearest_feature(st_geometry(y), pull_node_geom(x, focused = focused))
+}
+
+#' @name nearest_ids
+#' @importFrom sf st_geometry st_nearest_feature
+#' @export
+nearest_edge_ids = function(x, y, focused = TRUE) {
+ st_nearest_feature(st_geometry(y), pull_edge_geom(x, focused = focused))
+}
diff --git a/R/node.R b/R/node.R
index 3549b0bc..530f27a6 100644
--- a/R/node.R
+++ b/R/node.R
@@ -1,3 +1,94 @@
+#' Query spatial node types
+#'
+#' These functions are a collection of node type queries that are commonly
+#' used in spatial network analysis, and form a spatial extension to
+#' \code{\link[tidygraph:node_types]{node type queries}} in tidygraph.
+#'
+#' @return A logical vector of the same length as the number of nodes in the
+#' network, indicating if each node is of the type in question.
+#'
+#' @details Just as with all query functions in tidygraph, these functions
+#' are meant to be called inside tidygraph verbs such as
+#' \code{\link[tidygraph]{mutate}} or \code{\link[tidygraph]{filter}}, where
+#' the network that is currently being worked on is known and thus not needed
+#' as an argument to the function. If you want to use an algorithm outside of
+#' the tidygraph framework you can use \code{\link[tidygraph]{with_graph}} to
+#' set the context temporarily while the algorithm is being evaluated.
+#'
+#' @examples
+#' library(sf, quietly = TRUE)
+#' library(tidygraph, quietly = TRUE)
+#'
+#' # Create a network.
+#' net = as_sfnetwork(mozart, "mst", directed = FALSE)
+#'
+#' # Use query function in a filter call.
+#' pseudos = net |>
+#' activate(nodes) |>
+#' filter(node_is_pseudo())
+#'
+#' danglers = net |>
+#' activate(nodes) |>
+#' filter(node_is_dangling())
+#'
+#' oldpar = par(no.readonly = TRUE)
+#' par(mar = c(1,1,1,1), mfrow = c(1,2))
+#' plot(net, main = "Pseudo nodes")
+#' plot(st_geometry(pseudos), pch = 20, cex = 1.2, col = "orange", add = TRUE)
+#' plot(net, main = "Dangling nodes")
+#' plot(st_geometry(danglers), pch = 20, cex = 1.2, col = "orange", add = TRUE)
+#' par(oldpar)
+#'
+#' # Use query function in a mutate call.
+#' net |>
+#' activate(nodes) |>
+#' mutate(pseudo = node_is_pseudo(), dangling = node_is_dangling())
+#'
+#' # Use query function directly.
+#' danglers = with_graph(net, node_is_dangling())
+#' head(danglers)
+#'
+#' @name spatial_node_types
+NULL
+
+#' @describeIn spatial_node_types Pseudo nodes in directed networks are those
+#' nodes with only one incoming and one outgoing edge. In undirected networks
+#' pseudo nodes are those nodes with only two incident edges, i.e. nodes of
+#' degree 2.
+#' @importFrom tidygraph .G
+#' @export
+node_is_pseudo = function() {
+ require_active_nodes()
+ x = .G()
+ is_pseudo = is_pseudo_node(x)
+ if (is_focused(x)) is_pseudo[node_ids(x, focused = TRUE)] else is_pseudo
+}
+
+#' @importFrom igraph degree is_directed
+is_pseudo_node = function(x) {
+ if (is_directed(x)) {
+ pseudo = degree(x, mode = "in") == 1 & degree(x, mode = "out") == 1
+ } else {
+ pseudo = degree(x) == 2
+ }
+}
+
+#' @describeIn spatial_node_types Dangling nodes are nodes with only one
+#' incident edge, i.e. nodes of degree 1.
+#' @importFrom tidygraph .G
+#' @export
+node_is_dangling = function() {
+ require_active_nodes()
+ x = .G()
+ is_dangling = is_dangling_node(x)
+ if (is_focused(x)) is_dangling[node_ids(x, focused = TRUE)] else is_dangling
+}
+
+#' @importFrom igraph degree
+is_dangling_node = function(x) {
+ degree(x) == 1
+}
+
#' Query node coordinates
#'
#' These functions allow to query specific coordinate values from the
@@ -25,8 +116,8 @@
#' net = as_sfnetwork(roxel)
#'
#' # Use query function in a filter call.
-#' filtered = net %>%
-#' activate("nodes") %>%
+#' filtered = net |>
+#' activate(nodes) |>
#' filter(node_X() > 7.54)
#'
#' oldpar = par(no.readonly = TRUE)
@@ -36,53 +127,57 @@
#' par(oldpar)
#'
#' # Use query function in a mutate call.
-#' net %>%
-#' activate("nodes") %>%
+#' net |>
+#' activate(nodes) |>
#' mutate(X = node_X(), Y = node_Y())
#'
+#' # Use query function directly.
+#' X = with_graph(net, node_X())
+#' head(X)
+#'
#' @name node_coordinates
NULL
#' @name node_coordinates
+#' @importFrom tidygraph .G
#' @export
node_X = function() {
require_active_nodes()
- x = .G()
- get_coords(pull_node_geom(x), "X")
+ extract_node_coords(.G(), "X")
}
#' @name node_coordinates
+#' @importFrom tidygraph .G
#' @export
node_Y = function() {
require_active_nodes()
- x = .G()
- get_coords(pull_node_geom(x), "Y")
+ extract_node_coords(.G(), "Y")
}
#' @name node_coordinates
+#' @importFrom tidygraph .G
#' @export
node_Z = function() {
require_active_nodes()
- x = .G()
- get_coords(pull_node_geom(x), "Z")
+ extract_node_coords(.G(), "Z")
}
#' @name node_coordinates
+#' @importFrom tidygraph .G
#' @export
node_M = function() {
require_active_nodes()
- x = .G()
- get_coords(pull_node_geom(x), "M")
+ extract_node_coords(.G(), "M")
}
-#' @importFrom igraph vcount
+#' @importFrom cli cli_warn
#' @importFrom sf st_coordinates
-get_coords = function(x, value) {
- all_coords = st_coordinates(x)
+extract_node_coords = function(x, value) {
+ all_coords = st_coordinates(pull_node_geom(x, focused = TRUE))
tryCatch(
all_coords[, value],
error = function(e) {
- warning(value, " coordinates are not available", call. = FALSE)
+ cli_warn("{value} coordinates are not available.")
rep(NA, length(x))
}
)
@@ -94,28 +189,32 @@ get_coords = function(x, value) {
#' other geospatial features directly inside \code{\link[tidygraph]{filter}}
#' and \code{\link[tidygraph]{mutate}} calls. All functions return a logical
#' vector of the same length as the number of nodes in the network. Element i
-#' in that vector is \code{TRUE} whenever \code{any(predicate(x[i], y[j]))} is
-#' \code{TRUE}. Hence, in the case of using \code{node_intersects}, element i
-#' in the returned vector is \code{TRUE} when node i intersects with any of
-#' the features given in y.
+#' in that vector is \code{TRUE} whenever the chosen spatial predicate applies
+#' to the spatial relation between the i-th node and any of the features in
+#' \code{y}.
#'
#' @param y The geospatial features to test the nodes against, either as an
#' object of class \code{\link[sf]{sf}} or \code{\link[sf]{sfc}}.
#'
#' @param ... Arguments passed on to the corresponding spatial predicate
-#' function of sf. See \code{\link[sf]{geos_binary_pred}}.
+#' function of sf. See \code{\link[sf]{geos_binary_pred}}. The argument
+#' \code{sparse} should not be set.
#'
#' @return A logical vector of the same length as the number of nodes in the
#' network.
#'
#' @details See \code{\link[sf]{geos_binary_pred}} for details on each spatial
-#' predicate. Just as with all query functions in tidygraph, these functions
-#' are meant to be called inside tidygraph verbs such as
-#' \code{\link[tidygraph]{mutate}} or \code{\link[tidygraph]{filter}}, where
-#' the network that is currently being worked on is known and thus not needed
-#' as an argument to the function. If you want to use an algorithm outside of
-#' the tidygraph framework you can use \code{\link[tidygraph]{with_graph}} to
-#' set the context temporarily while the algorithm is being evaluated.
+#' predicate. The function \code{node_is_nearest} instead wraps around
+#' \code{\link[sf]{st_nearest_feature}} and returns \code{TRUE} for element i
+#' if the i-th node is the nearest node to any of the features in \code{y}.
+#'
+#' Just as with all query functions in tidygraph, these functions are meant to
+#' be called inside tidygraph verbs such as \code{\link[tidygraph]{mutate}} or
+#' \code{\link[tidygraph]{filter}}, where the network that is currently being
+#' worked on is known and thus not needed as an argument to the function. If
+#' you want to use an algorithm outside of the tidygraph framework you can use
+#' \code{\link[tidygraph]{with_graph}} to set the context temporarily while the
+#' algorithm is being evaluated.
#'
#' @note Note that \code{node_is_within_distance} is a wrapper around the
#' \code{st_is_within_distance} predicate from sf. Hence, it is based on
@@ -128,7 +227,7 @@ get_coords = function(x, value) {
#' library(tidygraph, quietly = TRUE)
#'
#' # Create a network.
-#' net = as_sfnetwork(roxel) %>%
+#' net = as_sfnetwork(roxel) |>
#' st_transform(3035)
#'
#' # Create a geometry to test against.
@@ -137,18 +236,19 @@ get_coords = function(x, value) {
#' p3 = st_point(c(4151756, 3207506))
#' p4 = st_point(c(4151774, 3208031))
#'
-#' poly = st_multipoint(c(p1, p2, p3, p4)) %>%
-#' st_cast('POLYGON') %>%
+#' poly = st_multipoint(c(p1, p2, p3, p4)) |>
+#' st_cast('POLYGON') |>
#' st_sfc(crs = 3035)
#'
#' # Use predicate query function in a filter call.
-#' within = net %>%
-#' activate("nodes") %>%
+#' within = net |>
+#' activate(nodes) |>
#' filter(node_is_within(poly))
#'
-#' disjoint = net %>%
-#' activate("nodes") %>%
+#' disjoint = net |>
+#' activate(nodes) |>
#' filter(node_is_disjoint(poly))
+#'
#' oldpar = par(no.readonly = TRUE)
#' par(mar = c(1,1,1,1))
#' plot(net)
@@ -157,73 +257,92 @@ get_coords = function(x, value) {
#' par(oldpar)
#'
#' # Use predicate query function in a mutate call.
-#' net %>%
-#' activate("nodes") %>%
-#' mutate(within = node_is_within(poly)) %>%
+#' net |>
+#' activate(nodes) |>
+#' mutate(within = node_is_within(poly)) |>
#' select(within)
#'
+#' # Use predicate query function directly.
+#' within = with_graph(net, node_is_within(poly))
+#' head(within)
+#'
#' @name spatial_node_predicates
NULL
#' @name spatial_node_predicates
#' @importFrom sf st_intersects
+#' @importFrom tidygraph .G
#' @export
node_intersects = function(y, ...) {
require_active_nodes()
- x = .G()
- lengths(st_intersects(pull_node_geom(x), y, ...)) > 0
+ evaluate_node_predicate(st_intersects, .G(), y, ...)
}
#' @name spatial_node_predicates
#' @importFrom sf st_disjoint
+#' @importFrom tidygraph .G
#' @export
node_is_disjoint = function(y, ...) {
require_active_nodes()
- x = .G()
- lengths(st_disjoint(pull_node_geom(x), y, ...)) > 0
+ evaluate_node_predicate(st_disjoint, .G(), y, ...)
}
#' @name spatial_node_predicates
#' @importFrom sf st_touches
+#' @importFrom tidygraph .G
#' @export
node_touches = function(y, ...) {
require_active_nodes()
- x = .G()
- lengths(st_touches(pull_node_geom(x), y, ...)) > 0
+ evaluate_node_predicate(st_touches, .G(), y, ...)
}
#' @name spatial_node_predicates
#' @importFrom sf st_within
+#' @importFrom tidygraph .G
#' @export
node_is_within = function(y, ...) {
require_active_nodes()
- x = .G()
- lengths(st_within(pull_node_geom(x), y, ...)) > 0
+ evaluate_node_predicate(st_within, .G(), y, ...)
}
#' @name spatial_node_predicates
#' @importFrom sf st_equals
+#' @importFrom tidygraph .G
#' @export
node_equals = function(y, ...) {
require_active_nodes()
- x = .G()
- lengths(st_equals(pull_node_geom(x), y, ...)) > 0
+ evaluate_node_predicate(st_equals, .G(), y, ...)
}
#' @name spatial_node_predicates
#' @importFrom sf st_covered_by
+#' @importFrom tidygraph .G
#' @export
node_is_covered_by = function(y, ...) {
require_active_nodes()
- x = .G()
- lengths(st_covered_by(pull_node_geom(x), y, ...)) > 0
+ evaluate_node_predicate(st_covered_by, .G(), y, ...)
}
#' @name spatial_node_predicates
#' @importFrom sf st_is_within_distance
+#' @importFrom tidygraph .G
#' @export
node_is_within_distance = function(y, ...) {
+ require_active_nodes()
+ evaluate_node_predicate(st_is_within_distance, .G(), y, ...)
+}
+
+#' @name spatial_node_predicates
+#' @export
+node_is_nearest = function(y) {
require_active_nodes()
x = .G()
- lengths(st_is_within_distance(pull_node_geom(x), y, ...)) > 0
+ vec = rep(FALSE, n_nodes(x))
+ vec[nearest_node_ids(x, y, focused = FALSE)] = TRUE
+ vec[node_ids(x, focused = TRUE)]
+}
+
+evaluate_node_predicate = function(predicate, x, y, ...) {
+ N = pull_node_geom(x, focused = TRUE)
+ lengths(predicate(N, y, sparse = TRUE, ...)) > 0
}
diff --git a/R/paths.R b/R/paths.R
index d7b1db42..4d262a1e 100644
--- a/R/paths.R
+++ b/R/paths.R
@@ -1,116 +1,141 @@
-#' Paths between points in geographical space
-#'
-#' Combined wrapper around \code{\link[igraph]{shortest_paths}},
-#' \code{\link[igraph]{all_shortest_paths}} and
-#' \code{\link[igraph]{all_simple_paths}} from \code{\link[igraph]{igraph}},
-#' allowing to provide any geospatial point as \code{from} argument and any
-#' set of geospatial points as \code{to} argument. If such a geospatial point
-#' is not equal to a node in the network, it will be snapped to its nearest
-#' node before calculating the shortest or simple paths.
+#' Find shortest paths between nodes in a spatial network
#'
#' @param x An object of class \code{\link{sfnetwork}}.
#'
-#' @param from The geospatial point from which the paths will be
-#' calculated. Can be an object an object of class \code{\link[sf]{sf}} or
-#' \code{\link[sf]{sfc}}, containing a single feature. When multiple features
-#' are given, only the first one is used.
-#' Alternatively, it can be an integer, referring to the index of the
-#' node from which the paths will be calculated, or a character,
-#' referring to the name of the node from which the paths will be
-#' calculated.
-#'
-#' @param to The (set of) geospatial point(s) to which the paths will be
-#' calculated. Can be an object of class \code{\link[sf]{sf}} or
-#' \code{\link[sf]{sfc}}.
-#' Alternatively it can be a numeric vector containing the indices of the nodes
-#' to which the paths will be calculated, or a character vector
-#' containing the names of the nodes to which the paths will be
-#' calculated. By default, all nodes in the network are included.
+#' @param from The node where the paths should start. Evaluated by
+#' \code{\link{evaluate_node_query}}.
+#'
+#' @param to The nodes where the paths should end. Evaluated by
+#' \code{\link{evaluate_node_query}}. By default, all nodes in the network are
+#' included.
#'
#' @param weights The edge weights to be used in the shortest path calculation.
-#' Can be a numeric vector giving edge weights, or a column name referring to
-#' an attribute column in the edges table containing those weights. If set to
-#' \code{NULL}, the values of a column named \code{weight} in the edges table
-#' will be used automatically, as long as this column is present. If not, the
-#' geographic edge lengths will be calculated internally and used as weights.
-#' If set to \code{NA}, no weights are used, even if the edges have a
-#' \code{weight} column. Ignored when \code{type = 'all_simple'}.
-#'
-#' @param type Character defining which type of path calculation should be
-#' performed. If set to \code{'shortest'} paths are calculated using
-#' \code{\link[igraph]{shortest_paths}}, if set to
-#' \code{'all_shortest'} paths are calculated using
-#' \code{\link[igraph]{all_shortest_paths}}, if set to
-#' \code{'all_simple'} paths are calculated using
-#' \code{\link[igraph]{all_simple_paths}}. Defaults to \code{'shortest'}.
+#' Evaluated by \code{\link{evaluate_weight_spec}}. The default is
+#' \code{\link{edge_length}}, which computes the geographic lengths of the
+#' edges.
+#'
+#' @param all Should all shortest paths be returned for each pair of nodes? If
+#' set to \code{FALSE}, only one shortest path is returned for each pair of
+#' nodes, even if multiple shortest paths exist. Defaults to \code{FALSE}.
+#'
+#' @param k The number of paths to find. Setting this to any integer higher
+#' than 1 returns not only the shortest path, but also the next k - 1 loopless
+#' shortest paths, which may be longer than the shortest path. Currently, this
+#' is only supported for one-to-one routing, meaning that both the from and to
+#' argument should be of length 1. This argument is ignored if \code{all} is
+#' set to \code{TRUE}.
+#'
+#' @param direction The direction of travel. Defaults to \code{'out'}, meaning
+#' that the direction given by the network is followed and paths are found from
+#' the node given as argument \code{from}. May be set to \code{'in'}, meaning
+#' that the opposite direction is followed an paths are found towards the node
+#' given as argument \code{from}. May also be set to \code{'all'}, meaning that
+#' the network is considered to be undirected. This argument is ignored for
+#' undirected networks.
+#'
+#' @param router The routing backend to use for the shortest path computation.
+#' Currently supported options are \code{'igraph'} and \code{'dodgr'}. See
+#' Details.
#'
#' @param use_names If a column named \code{name} is present in the nodes
#' table, should these names be used to encode the nodes in a path, instead of
-#' the node indices? Defaults to \code{TRUE}. Ignored when the nodes table does
+#' the node indices? Defaults to \code{FALSE}. Ignored when the nodes table does
#' not have a column named \code{name}.
#'
-#' @param ... Arguments passed on to the corresponding
-#' \code{\link[igraph:shortest_paths]{igraph}} or
-#' \code{\link[igraph:all_simple_paths]{igraph}} function. Arguments
-#' \code{predecessors} and \code{inbound.edges} are ignored.
-#'
-#' @details Spatial features provided to the \code{from} and/or
-#' \code{to} argument don't necessarily have to be points. Internally, the
-#' nearest node to each feature is found by calling
-#' \code{\link[sf]{st_nearest_feature}}, so any feature with a geometry type
-#' that is accepted by that function can be provided as \code{from} and/or
-#' \code{to} argument.
-#'
-#' When directly providing integer node indices or character node names to the
-#' \code{from} and/or \code{to} argument, keep the following in mind. A node
-#' index should correspond to a row-number of the nodes table of the network.
-#' A node name should correspond to a value of a column in the nodes table
-#' named \code{name}. This column should contain character values without
-#' duplicates.
-#'
-#' For more details on the wrapped functions from \code{\link[igraph]{igraph}}
-#' see the \code{\link[igraph]{shortest_paths}} or
-#' \code{\link[igraph]{all_simple_paths}} documentation pages.
-#'
-#' @seealso \code{\link{st_network_cost}}
-#'
-#' @return An object of class \code{\link[tibble]{tbl_df}} with one row per
-#' returned path. Depending on the setting of the \code{type} argument,
-#' columns can be \code{node_paths} (a list column with for each path the
-#' ordered indices of nodes present in that path) and \code{edge_paths}
-#' (a list column with for each path the ordered indices of edges present in
-#' that path). \code{'all_shortest'} and \code{'all_simple'} return only
-#' \code{node_paths}, while \code{'shortest'} returns both.
+#' @param return_cost Should the total cost of each path be computed? Defaults
+#' to \code{TRUE}.
+#'
+#' @param return_geometry Should a linestring geometry be constructed for each
+#' path? Defaults to \code{TRUE}. The geometries are constructed by calling
+#' \code{\link[sf]{st_line_merge}} on the linestring geometries of the edges in
+#' the path. Ignored for networks with spatially implicit edges.
+#'
+#' @param ... Additional arguments passed on to the underlying function of the
+#' chosen routing backend. See Details.
+#'
+#' @details The sfnetworks package does not implement its own routing algorithms
+#' to find shortest paths. Instead, it relies on "routing backends", i.e. other
+#' R packages that have implemented such algorithms. Currently two different
+#' routing backends are supported.
+#'
+#' The default is \code{\link[igraph]{igraph}}. This package supports
+#' one-to-many shortest path calculation with the
+#' \code{\link[igraph]{shortest_paths}} function. Note that multiple from nodes
+#' are not supported. If multiple from nodes are given, only the first one is
+#' taken. The igraph router also supports the computation of all shortest path
+#' (see the \code{all} argument) through the
+#' \code{\link[igraph]{all_shortest_paths}} function and of k shortest paths
+#' (see the \code{k} argument) through the
+#' \code{\link[igraph]{k_shortest_paths}} function. In the latter case, only
+#' one-to-one routing is supported, meaning that also only one to node should
+#' be provided. The igraph router does not support dual-weighted routing.
+#'
+#' The second supported routing backend is \code{\link[dodgr]{dodgr}}. This
+#' package supports many-to-many shortest path calculation with the
+#' \code{\link[dodgr]{dodgr_paths}} function. It also supports dual-weighted
+#' routing. The computation of all shortest paths and k shortest paths is
+#' currently not supported by the dodgr router. The dodgr package is a
+#' conditional dependency of sfnetworks. Using the dodgr router requires the
+#' dodgr package to be installed.
+#'
+#' The default router can be changed by setting the \code{sfn_default_router}
+#' option.
+#'
+#' @seealso \code{\link{st_network_cost}}, \code{\link{st_network_travel}}
+#'
+#' @return An object of class \code{\link[sf]{sf}} with one row per requested
+#' path. If \code{return_geometry = FALSE} or edges are spatially implicit, a
+#' \code{\link[tibble]{tbl_df}} is returned instead. If a requested path could
+#' not be found, it is included in the output as an empty path.
+#'
+#' Depending on the argument settings, the output may include the following
+#' columns:
+#'
+#' \itemize{
+#' \item \code{from}: The index of the node at the start of the path.
+#' \item \code{to}: The index of the node at the end of the path.
+#' \item \code{node_path}: A vector containing the indices of all nodes on
+#' the path, in order of visit.
+#' \item \code{edge_path}: A vector containing the indices of all edges on
+#' the path, in order of visit.
+#' \item \code{path_found}: A boolean describing if the requested path exists.
+#' \item \code{cost}: The total cost of the path, obtained by summing the
+#' weights of all visited edges. Included if \code{return_cost = TRUE}.
+#' \item \code{geometry}: The geometry of the path, obtained by merging the
+#' geometries of all visited edges. Included if \code{return_geometry = TRUE}
+#' and the network has spatially explicit edges.
+#' }
#'
#' @examples
#' library(sf, quietly = TRUE)
#' library(tidygraph, quietly = TRUE)
#'
-#' # Create a network with edge lengths as weights.
-#' # These weights will be used automatically in shortest paths calculation.
-#' net = as_sfnetwork(roxel, directed = FALSE) %>%
-#' st_transform(3035) %>%
-#' activate("edges") %>%
-#' mutate(weight = edge_length())
+#' oldpar = par(no.readonly = TRUE)
+#' par(mar = c(1,1,1,1))
#'
-#' # Providing node indices.
+#' net = as_sfnetwork(roxel, directed = FALSE) |>
+#' st_transform(3035)
+#'
+#' # Compute the shortest path between two nodes.
+#' # Note that geographic edge length is used as edge weights by default.
#' paths = st_network_paths(net, from = 495, to = 121)
#' paths
#'
-#' node_path = paths %>%
-#' slice(1) %>%
-#' pull(node_paths) %>%
-#' unlist()
-#' node_path
+#' plot(net, col = "grey")
+#' plot(st_geometry(net)[paths$from], pch = 20, cex = 2, add = TRUE)
+#' plot(st_geometry(paths), col = "orange", lwd = 3, add = TRUE)
+#'
+#' # Compute the shortest paths from one to multiple nodes.
+#' # This will return a tibble with one row per path.
+#' paths = st_network_paths(net, from = 495, to = c(121, 131, 141))
+#' paths
#'
-#' oldpar = par(no.readonly = TRUE)
-#' par(mar = c(1,1,1,1))
#' plot(net, col = "grey")
-#' plot(slice(activate(net, "nodes"), node_path), col = "red", add = TRUE)
-#' par(oldpar)
+#' plot(st_geometry(net)[paths$from], pch = 20, cex = 2, add = TRUE)
+#' plot(st_geometry(paths), col = "orange", lwd = 3, add = TRUE)
#'
-#' # Providing nodes as spatial points.
-#' # Points that don't equal a node will be snapped to their nearest node.
+#' # Compute the shortest path between two spatial point features.
+#' # These are snapped to their nearest node before finding the path.
#' p1 = st_geometry(net, "nodes")[495] + st_sfc(st_point(c(50, -50)))
#' st_crs(p1) = st_crs(net)
#' p2 = st_geometry(net, "nodes")[121] + st_sfc(st_point(c(-10, 100)))
@@ -119,319 +144,312 @@
#' paths = st_network_paths(net, from = p1, to = p2)
#' paths
#'
-#' node_path = paths %>%
-#' slice(1) %>%
-#' pull(node_paths) %>%
-#' unlist()
-#' node_path
-#'
-#' oldpar = par(no.readonly = TRUE)
-#' par(mar = c(1,1,1,1))
#' plot(net, col = "grey")
-#' plot(c(p1, p2), col = "black", pch = 8, add = TRUE)
-#' plot(slice(activate(net, "nodes"), node_path), col = "red", add = TRUE)
+#' plot(c(p1, p2), pch = 20, cex = 2, add = TRUE)
+#' plot(st_geometry(net)[paths$from], pch = 4, cex = 2, add = TRUE)
+#' plot(st_geometry(paths), col = "orange", lwd = 3, add = TRUE)
+#'
+#' # Use a node type query function to specify destinations.
+#' st_network_paths(net, 1, node_is_adjacent(1))
+#'
+#' # Use a spatial edge measure to specify edge weights.
+#' # By default edge_length() is used.
+#' st_network_paths(net, p1, p2, weights = edge_displacement())
+#'
+#' # Use a column in the edges table to specify edge weights.
+#' # This uses tidy evaluation.
+#' net |>
+#' activate("edges") |>
+#' mutate(foo = runif(n(), min = 0, max = 1)) |>
+#' st_network_paths(p1, p2, weights = foo)
+#'
+#' # Compute the shortest paths without edge weights.
+#' # This is the path with the fewest number of edges, ignoring space.
+#' st_network_paths(net, p1, p2, weights = NA)
+#'
+#' # Use the dodgr router for many-to-many routing.
+#' paths = st_network_paths(net,
+#' from = c(1, 2),
+#' to = c(10, 11),
+#' router = "dodgr"
+#' )
+#'
+#' # Use the dodgr router for dual-weighted routing.
+#' paths = st_network_paths(net,
+#' from = c(1, 2),
+#' to = c(10, 11),
+#' weights = dual_weights(edge_segment_count(), edge_length()),
+#' router = "dodgr"
+#' )
+#'
#' par(oldpar)
#'
-#' # Using another column for weights.
-#' net %>%
-#' activate("edges") %>%
-#' mutate(foo = runif(n(), min = 0, max = 1)) %>%
-#' st_network_paths(p1, p2, weights = "foo")
-#'
-#' # Obtaining all simple paths between two nodes.
-#' # Beware, this function can take long when:
-#' # --> Providing a lot of 'to' nodes.
-#' # --> The network is large and dense.
-#' net = as_sfnetwork(roxel, directed = TRUE)
-#' st_network_paths(net, from = 1, to = 12, type = "all_simple")
-#'
-#' # Obtaining all shortest paths between two nodes.
-#' # Not using edge weights.
-#' # Hence, a shortest path is the paths with the least number of edges.
-#' st_network_paths(net, from = 5, to = 1, weights = NA, type = "all_shortest")
-#'
-#' @importFrom igraph V
#' @export
-st_network_paths = function(x, from, to = igraph::V(x), weights = NULL,
- type = "shortest", use_names = TRUE, ...) {
+st_network_paths = function(x, from, to = node_ids(x),
+ weights = edge_length(), all = FALSE, k = 1,
+ direction = "out",
+ router = getOption("sfn_default_router", "igraph"),
+ use_names = FALSE, return_cost = TRUE,
+ return_geometry = TRUE, ...) {
UseMethod("st_network_paths")
}
-#' @importFrom igraph V
-#' @importFrom sf st_geometry
+#' @importFrom methods hasArg
+#' @importFrom rlang enquo
#' @export
-st_network_paths.sfnetwork = function(x, from, to = igraph::V(x),
- weights = NULL, type = "shortest",
- use_names = TRUE, ...) {
- # If 'from' points are given as simple feature geometries:
- # --> Convert them to node indices.
- if (is.sf(from) | is.sfc(from)) from = get_nearest_node_index(x, from)
- # If 'to' points are given as simple feature geometries:
- # --> Convert them to node indices.
- if (is.sf(to) | is.sfc(to)) to = get_nearest_node_index(x, to)
- # Igraph does not support multiple 'from' nodes.
- if (length(from) > 1) raise_multiple_elements("from")
- # Igraph does not support NA values in 'from' and 'to' nodes.
- if (any(is.na(c(from, to)))) raise_na_values("from and/or to")
- # Call paths calculation function according to type argument.
- switch(
- type,
- shortest = get_shortest_paths(x, from, to, weights, use_names,...),
- all_shortest = get_all_shortest_paths(x, from, to, weights, use_names,...),
- all_simple = get_all_simple_paths(x, from, to, use_names,...),
- raise_unknown_input(type)
+st_network_paths.sfnetwork = function(x, from, to = node_ids(x),
+ weights = edge_length(),
+ all = FALSE, k = 1,
+ direction = "out",
+ router = getOption("sfn_default_router", "igraph"),
+ use_names = FALSE, return_cost = TRUE,
+ return_geometry = TRUE, ...) {
+ # Deprecate the type argument.
+ if (hasArg("type")) deprecate_type()
+ # Evaluate the given from node query.
+ from = evaluate_node_query(x, enquo(from))
+ if (any(is.na(from))) raise_na_values("from")
+ # Evaluate the given to node query.
+ to = evaluate_node_query(x, enquo(to))
+ if (any(is.na(to))) raise_na_values("to")
+ # Evaluate the given weights specification.
+ weights = evaluate_weight_spec(x, enquo(weights))
+ # Compute the shortest paths.
+ find_paths(
+ x, from, to, weights,
+ all = all,
+ k = k,
+ direction = direction,
+ router = router,
+ use_names = use_names,
+ return_cost = return_cost,
+ return_geometry = return_geometry,
+ ...
)
}
-#' @importFrom igraph shortest_paths vertex_attr_names
-#' @importFrom tibble as_tibble
-get_shortest_paths = function(x, from, to, weights, use_names = TRUE, ...) {
- # Set weights.
- weights = set_path_weights(x, weights)
- # Call igraph function.
- paths = shortest_paths(x, from, to, weights = weights, output = "both", ...)
- # Extract vector of node indices or names.
+#' @importFrom igraph vertex_attr vertex_attr_names
+#' @importFrom sf st_as_sf
+find_paths = function(x, from, to, weights, all = FALSE, k = 1,
+ direction = "out",
+ router = getOption("sfn_default_router", "igraph"),
+ use_names = FALSE, return_cost = TRUE,
+ return_geometry = TRUE, ...) {
+ # Find paths with the given router.
+ paths = switch(
+ router,
+ igraph = igraph_paths(x, from, to, weights, all, k, direction, ...),
+ dodgr = dodgr_paths(x, from, to, weights, all, k, direction, ...),
+ raise_unknown_input("router", router, c("igraph", "dodgr"))
+ )
+ # Convert node indices to node names if requested.
if (use_names && "name" %in% vertex_attr_names(x)) {
- npaths = lapply(paths[[1]], attr, "names")
- } else {
- npaths = lapply(paths[[1]], as.integer)
+ nnames = vertex_attr(x, "name")
+ paths$from = do.call("c", lapply(paths$from, \(x) nnames[x]))
+ paths$to = do.call("c", lapply(paths$to, \(x) nnames[x]))
+ paths$node_path = lapply(paths$node_path, \(x) nnames[x])
}
- # Extract vector of edge indices.
- epaths = lapply(paths[[2]], as.integer)
- # Return as columns in a tibble.
- as_tibble(do.call(cbind, list(node_paths = npaths, edge_paths = epaths)))
-}
-
-#' @importFrom igraph all_shortest_paths vertex_attr_names
-#' @importFrom tibble as_tibble
-get_all_shortest_paths = function(x, from, to, weights, use_names = TRUE,...) {
- # Set weights.
- weights = set_path_weights(x, weights)
- # Call igraph function.
- paths = all_shortest_paths(x, from, to, weights = weights, ...)
- # Extract vector of node indices or names.
- if (use_names && "name" %in% vertex_attr_names(x)) {
- npaths = lapply(paths[[1]], attr, "names")
- } else {
- npaths = lapply(paths[[1]], as.integer)
+ # Define if the path was found.
+ paths$path_found = lengths(paths$node_path) > 0
+ # Compute total cost of each path if requested.
+ if (return_cost) {
+ if (inherits(weights, "dual_weights")) weights = weights$reported
+ if (length(weights) == 1 && is.na(weights)) {
+ costs = do.call("c", lapply(paths$edge_path, length))
+ } else {
+ costs = do.call("c", lapply(paths$edge_path, \(x) sum(weights[x])))
+ }
+ costs[!paths$path_found] = Inf
+ paths$cost = costs
}
- # Return as column in a tibble.
- as_tibble(do.call(cbind, list(node_paths = npaths)))
+ # Construct path geometries of requested.
+ if (return_geometry && has_explicit_edges(x)) {
+ egeom = pull_edge_geom(x)
+ pgeom = do.call("c", lapply(paths$edge_path, \(x) merge_lines(egeom[x])))
+ paths$geometry = pgeom
+ paths = st_as_sf(paths)
+ }
+ paths
}
-#' @importFrom igraph all_simple_paths vertex_attr_names
-#' @importFrom tibble as_tibble
-get_all_simple_paths = function(x, from, to, use_names = TRUE, ...) {
- # Call igraph function.
- paths = all_simple_paths(x, from, to, ...)
- # Extract paths of node indices.
- if (use_names && "name" %in% vertex_attr_names(x)) {
- npaths = lapply(paths, attr, "names")
+#' @importFrom cli cli_abort cli_warn
+#' @importFrom igraph all_shortest_paths shortest_paths k_shortest_paths
+#' igraph_opt igraph_options
+#' @importFrom methods hasArg
+#' @importFrom tibble tibble
+#' @importFrom utils tail
+igraph_paths = function(x, from, to, weights, all = FALSE, k = 1,
+ direction = "out", ...) {
+ # Change default igraph options.
+ # This prevents igraph returns node or edge indices as formatted sequences.
+ # We only need the "raw" integer indices.
+ # Changing this option improves performance especially on large networks.
+ default_igraph_opt = igraph_opt("return.vs.es")
+ igraph_options(return.vs.es = FALSE)
+ on.exit(igraph_options(return.vs.es = default_igraph_opt))
+ # The direction argument is used instead of igraphs mode argument.
+ # This means the mode argument should not be set.
+ if (hasArg("mode")) raise_unsupported_arg("mode", replacement = "direction")
+ # Dual-weighted routing is not supported by igraph.
+ if (inherits(weights, "dual_weights")) {
+ cli_abort(c(
+ "Router {.pkg igraph} does not support dual-weighted routing.",
+ "i" = "Use the {.pkg dodgr} router for dual-weighted routing."
+ ))
+ }
+ # Any igraph paths function supports only a single from node.
+ # If multiple from nodes are given we take only the first one.
+ if (length(from) > 1) {
+ cli_warn(c(
+ "Router {.pkg igraph} does not support multiple {.arg from} nodes.",
+ "i" = "Only the first {.arg from} node is considered.",
+ "i" = "Use the {.pkg dodgr} router for many-to-many routing."
+ ))
+ from = from[1]
+ }
+ # Call igraph paths calculation function depending on the settings.
+ if (all) {
+ # Call igraph::all_shortest_paths to obtain the requested paths.
+ paths = all_shortest_paths(
+ x, from, to,
+ weights = weights,
+ mode = direction,
+ ...
+ )
+ # Extract the nodes and edges in each path.
+ npaths = paths$vpaths
+ epaths = paths$epaths
+ # Define for each path where it starts and ends.
+ starts = do.call("c", lapply(npaths, `[`, 1))
+ ends = do.call("c", lapply(npaths, tail, 1))
} else {
- npaths = lapply(paths, as.integer)
+ k = as.integer(k)
+ if (k == 1) {
+ # Call igraph::shortest_paths to obtain the requested paths.
+ paths = shortest_paths(
+ x, from, to,
+ weights = weights,
+ output = "both",
+ mode = direction,
+ ...
+ )
+ # Extract the nodes and edges in each path.
+ npaths = paths$vpath
+ epaths = paths$epath
+ # Define for each path where it starts and ends.
+ starts = rep(from, length(to))
+ ends = to
+ } else {
+ # For k shortest paths igraph only supports one-to-one routing.
+ # Hence only a single to node is supported.
+ # If multiple to nodes are given we take only the first one.
+ if (length(to) > 1) {
+ cli_warn(c(
+ paste(
+ "Router {.pkg igraph} does not support multiple {.arg to}",
+ "nodes for k shortest paths computation."
+ ),
+ "i" = "Only the first {.arg to} node is considered."
+ ))
+ to = to[1]
+ }
+ # Call igraph::k_shortest_paths to obtain the requested paths.
+ paths = k_shortest_paths(
+ x, from, to,
+ k = k,
+ weights = weights,
+ mode = direction,
+ ...
+ )
+ # Extract the nodes and edges in each path.
+ npaths = paths$vpaths
+ epaths = paths$epaths
+ # We will always return k paths.
+ # Even if that many paths do not exists.
+ # Hence if the returned number of paths is smaller than k:
+ # --> We add empty paths to the result.
+ n = length(npaths)
+ if (n < k) {
+ npaths = c(npaths, rep(list(numeric(0)), k - n))
+ epaths = c(epaths, rep(list(numeric(0)), k - n))
+ }
+ # Define for each path where it starts and ends.
+ # Since we do one-to-one routing these are always the same nodes.
+ starts = rep(from, k)
+ ends = rep(to, k)
+ }
}
- # Return as column in a tibble.
- as_tibble(do.call(cbind, list(node_paths = npaths)))
-}
-
-#' Compute a cost matrix of a spatial network
-#'
-#' Wrapper around \code{\link[igraph]{distances}} to calculate costs of
-#' pairwise shortest paths between points in a spatial network. It allows to
-#' provide any set of geospatial point as \code{from} and \code{to} arguments.
-#' If such a geospatial point is not equal to a node in the network, it will
-#' be snapped to its nearest node before calculating costs.
-#'
-#' @param x An object of class \code{\link{sfnetwork}}.
-#'
-#' @param from The (set of) geospatial point(s) from which the shortest paths
-#' will be calculated. Can be an object of class \code{\link[sf]{sf}} or
-#' \code{\link[sf]{sfc}}.
-#' Alternatively it can be a numeric vector containing the indices of the nodes
-#' from which the shortest paths will be calculated, or a character vector
-#' containing the names of the nodes from which the shortest paths will be
-#' calculated. By default, all nodes in the network are included.
-#'
-#' @param to The (set of) geospatial point(s) to which the shortest paths will
-#' be calculated. Can be an object of class \code{\link[sf]{sf}} or
-#' \code{\link[sf]{sfc}}.
-#' Alternatively it can be a numeric vector containing the indices of the nodes
-#' to which the shortest paths will be calculated, or a character vector
-#' containing the names of the nodes to which the shortest paths will be
-#' calculated. Duplicated values will be removed before calculating the cost
-#' matrix. By default, all nodes in the network are included.
-#'
-#' @param weights The edge weights to be used in the shortest path calculation.
-#' Can be a numeric vector giving edge weights, or a column name referring to
-#' an attribute column in the edges table containing those weights. If set to
-#' \code{NULL}, the values of a column named \code{weight} in the edges table
-#' will be used automatically, as long as this column is present. If not, the
-#' geographic edge lengths will be calculated internally and used as weights.
-#' If set to \code{NA}, no weights are used, even if the edges have a
-#' \code{weight} column.
-#'
-#' @param direction The direction of travel. Defaults to \code{'out'}, meaning
-#' that the direction given by the network is followed and costs are calculated
-#' from the points given as argument \code{from}. May be set to \code{'in'},
-#' meaning that the opposite direction is followed an costs are calculated
-#' towards the points given as argument \code{from}. May also be set to
-#' \code{'all'}, meaning that the network is considered to be undirected. This
-#' argument is ignored for undirected networks.
-#'
-#' @param Inf_as_NaN Should the cost values of unconnected nodes be stored as
-#' \code{NaN} instead of \code{Inf}? Defaults to \code{FALSE}.
-#'
-#' @param ... Arguments passed on to \code{\link[igraph]{distances}}. Argument
-#' \code{mode} is ignored. Use \code{direction} instead.
-#'
-#' @details Spatial features provided to the \code{from} and/or
-#' \code{to} argument don't necessarily have to be points. Internally, the
-#' nearest node to each feature is found by calling
-#' \code{\link[sf]{st_nearest_feature}}, so any feature with a geometry type
-#' that is accepted by that function can be provided as \code{from} and/or
-#' \code{to} argument.
-#'
-#' When directly providing integer node indices or character node names to the
-#' \code{from} and/or \code{to} argument, keep the following in mind. A node
-#' index should correspond to a row-number of the nodes table of the network.
-#' A node name should correspond to a value of a column in the nodes table
-#' named \code{name}. This column should contain character values without
-#' duplicates.
-#'
-#' For more details on the wrapped function from \code{\link[igraph]{igraph}}
-#' see the \code{\link[igraph]{distances}} documentation page.
-#'
-#' @seealso \code{\link{st_network_paths}}
-#'
-#' @return An n times m numeric matrix where n is the length of the \code{from}
-#' argument, and m is the length of the \code{to} argument.
-#'
-#' @examples
-#' library(sf, quietly = TRUE)
-#' library(tidygraph, quietly = TRUE)
-#'
-#' # Create a network with edge lengths as weights.
-#' # These weights will be used automatically in shortest paths calculation.
-#' net = as_sfnetwork(roxel, directed = FALSE) %>%
-#' st_transform(3035) %>%
-#' activate("edges") %>%
-#' mutate(weight = edge_length())
-#'
-#' # Providing node indices.
-#' st_network_cost(net, from = c(495, 121), to = c(495, 121))
-#'
-#' # Providing nodes as spatial points.
-#' # Points that don't equal a node will be snapped to their nearest node.
-#' p1 = st_geometry(net, "nodes")[495] + st_sfc(st_point(c(50, -50)))
-#' st_crs(p1) = st_crs(net)
-#' p2 = st_geometry(net, "nodes")[121] + st_sfc(st_point(c(-10, 100)))
-#' st_crs(p2) = st_crs(net)
-#'
-#' st_network_cost(net, from = c(p1, p2), to = c(p1, p2))
-#'
-#' # Using another column for weights.
-#' net %>%
-#' activate("edges") %>%
-#' mutate(foo = runif(n(), min = 0, max = 1)) %>%
-#' st_network_cost(c(p1, p2), c(p1, p2), weights = "foo")
-#'
-#' # Not providing any from or to points includes all nodes by default.
-#' with_graph(net, graph_order()) # Our network has 701 nodes.
-#' cost_matrix = st_network_cost(net)
-#' dim(cost_matrix)
-#'
-#' @importFrom igraph V
-#' @export
-st_network_cost = function(x, from = igraph::V(x), to = igraph::V(x),
- weights = NULL, direction = "out",
- Inf_as_NaN = FALSE, ...) {
- UseMethod("st_network_cost")
+ # Return in a tibble.
+ tibble(
+ from = starts, to = ends,
+ node_path = npaths, edge_path = epaths
+ )
}
-#' @importFrom igraph distances V
-#' @importFrom units deparse_unit as_units
-#' @export
-st_network_cost.sfnetwork = function(x, from = igraph::V(x), to = igraph::V(x),
- weights = NULL, direction = "out",
- Inf_as_NaN = FALSE, ...) {
- # If 'from' and/or 'to' points are given as simple feature geometries:
- # --> Convert them to node indices.
- if (is.sf(from) | is.sfc(from)) from = get_nearest_node_index(x, from)
- if (is.sf(to) | is.sfc(to)) to = get_nearest_node_index(x, to)
- # Igraph does not support NA values in 'from' and 'to' nodes.
- if (any(is.na(c(from, to)))) raise_na_values("from and/or to")
- # Set weights.
- weights = set_path_weights(x, weights)
- # Check for mode argument passed to ...
- dots = list(...)
- # If mode argument present, ignore it and return a warning.
- if (!is.null(dots$mode)) {
- dots$mode = NULL
- warning(
- "Argument 'mode' is ignored. Use 'direction' instead",
- call. = FALSE
+#' @importFrom cli cli_abort
+#' @importFrom igraph is_directed
+#' @importFrom rlang check_installed
+#' @importFrom tibble tibble
+#' @importFrom utils tail
+dodgr_paths = function(x, from, to, weights, all = FALSE, k = 1,
+ direction = "out", ...) {
+ check_installed("dodgr") # Package dodgr is required for this function.
+ # The dodgr router currently does not support:
+ # --> Computing all shortest paths or k shortest path.
+ if (all) {
+ cli_abort(
+ "Router {.pkg dodgr} does not support setting {.code all = TRUE}."
)
}
- # Igraph does not support duplicated 'to' nodes.
- if(any(duplicated(to))) {
- # --> Obtain unique 'to' nodes to pass to igraph.
- to_unique = unique(to)
- # --> Find which 'to' nodes are duplicated.
- match = match(to, to_unique)
- # Call igraph function.
- args = list(x, from, to_unique, weights = weights, mode = direction)
- matrix = do.call(igraph::distances, c(args, dots))
- # Return the matrix
- # --> With duplicated 'to' nodes included.
- matrix = matrix[, match, drop = FALSE]
- } else {
- # Call igraph function.
- args = list(x, from, to, weights = weights, mode = direction)
- matrix = do.call(igraph::distances, c(args, dots))
- }
- # Convert Inf to NaN if requested.
- if (Inf_as_NaN) matrix[is.infinite(matrix)] = NaN
- # Check if weights parameter inherits units.
- if (inherits(weights, "units")) {
- # Fetch weight units to pass onto distance matrix.
- weights_units = deparse_unit(weights)
- # Return matrix as units object
- as_units(matrix, weights_units)
- } else {
- # Return the matrix.
- matrix
+ if (k > 1) {
+ cli_abort(
+ "Router {.pkg dodgr} does not support setting {.code k > 1}."
+ )
}
-}
-#' @importFrom igraph edge_attr
-#' @importFrom tidygraph activate with_graph
-set_path_weights = function(x, weights) {
- if (is.character(weights) & length(weights) == 1) {
- # Case 1: Weights is a character pointing to a column in the edges table.
- # --> Use the values of that column as weight values (if it exists).
- values = edge_attr(x, weights)
- if (is.null(values)) {
- stop(
- "Edge attribute '", weights, "' not found",
- call. = FALSE
- )
- } else {
- values
- }
- } else if (is.null(weights)) {
- values = edge_attr(x, "weight")
- if (is.null(values)) {
- # Case 2: Weights is NULL and the edges don't have a weight attribute.
- # --> Use the length of the edge linestrings as weight values.
- with_graph(x, edge_length())
- } else {
- # Case 3: Weights is NULL and the edges have a weight attribute.
- # --> Use the values of the weight attribute as weight values
- values
+ # Convert the network to dodgr format.
+ x_dodgr = sfnetwork_to_minimal_dodgr(x, weights, direction)
+ # Call dodgr::dodgr_paths to compute the requested paths.
+ paths = dodgr::dodgr_paths(
+ x_dodgr,
+ from = as.character(from),
+ to = as.character(to),
+ vertices = FALSE,
+ ...
+ )
+ # Unnest the nested list of edge indices.
+ epaths = lapply(do.call(cbind, paths), \(x) x)
+ # Infer the node paths from the edge paths.
+ get_node_path = function(E) {
+ N = c(x_dodgr$from[E], x_dodgr$to[tail(E, 1)])
+ as.integer(N)
+ }
+ npaths = lapply(epaths, get_node_path)
+ # Update the edge paths:
+ # --> For undirected networks we duplicated and reversed all edges.
+ # --> Paths that were not found should have numeric(0) as value.
+ if (!is_directed(x) | direction == "all") {
+ n = nrow(x_dodgr) / 2
+ update_edge_path = function(E) {
+ if (is.null(E) || all(is.na(E))) return (integer(0))
+ is_added = E > n
+ E[is_added] = E[is_added] - n
+ as.integer(E)
}
+ epaths = lapply(epaths, update_edge_path)
} else {
- # All other cases: igraph will handle the given weights.
- # No need for pre-processing.
- weights
+ update_edge_path = function(E) {
+ if (is.null(E) || all(is.na(E))) return (integer(0))
+ E
+ }
+ epaths = lapply(epaths, update_edge_path)
}
-}
+ # Define for each path where it starts and ends.
+ starts = rep(from, rep(length(to), length(from)))
+ ends = rep(to, length(from))
+ # Return in a tibble.
+ tibble(
+ from = starts, to = ends,
+ node_path = npaths, edge_path = epaths
+ )
+}
\ No newline at end of file
diff --git a/R/plot.R b/R/plot.R
index 5f7e82d5..8dd1954a 100644
--- a/R/plot.R
+++ b/R/plot.R
@@ -1,4 +1,4 @@
-#' Plot sfnetwork geometries
+#' Plot the geometries of a sfnetwork
#'
#' Plot the geometries of an object of class \code{\link{sfnetwork}}.
#'
@@ -8,57 +8,82 @@
#' straight lines be drawn between connected nodes? Defaults to \code{TRUE}.
#' Ignored when the edges of the network are spatially explicit.
#'
-#' @param ... Arguments passed on to \code{\link[sf:plot]{plot.sf}}
+#' @param node_args A named list of arguments that will be passed on to
+#' \code{\link[sf:plot]{plot.sf}} only for plotting the nodes.
#'
-#' @details This is a basic plotting functionality. For more advanced plotting,
-#' it is recommended to extract the nodes and edges from the network, and plot
-#' them separately with one of the many available spatial plotting functions
-#' as can be found in \code{sf}, \code{tmap}, \code{ggplot2}, \code{ggspatial},
-#' and others.
+#' @param edge_args A named list of arguments that will be passed on to
+#' \code{\link[sf:plot]{plot.sf}} only for plotting the edges.
#'
-#' @return This is a plot method and therefore has no visible return value.
+#' @param ... Arguments passed on to \code{\link[sf:plot]{plot.sf}} that will
+#' apply to the plot as a whole.
+#'
+#' @details Arguments passed to \code{...} will be used both for plotting the
+#' nodes and for plotting the edges. Edges are always plotted first. Arguments
+#' specified in \code{node_args} and \code{edge_args} should not be specified
+#' in \code{...} as well, this will result in an error.
+#'
+#' @return Invisible.
#'
#' @examples
+#' library(sf, quietly = TRUE)
+#'
#' oldpar = par(no.readonly = TRUE)
#' par(mar = c(1,1,1,1), mfrow = c(1,1))
#' net = as_sfnetwork(roxel)
#' plot(net)
#'
-#' # When lines are spatially implicit.
+#' # When edges are spatially implicit.
+#' # By default straight lines will be drawn between connected nodes.
#' par(mar = c(1,1,1,1), mfrow = c(1,2))
-#' net = as_sfnetwork(roxel, edges_as_lines = FALSE)
-#' plot(net)
-#' plot(net, draw_lines = FALSE)
+#' inet = st_drop_geometry(activate(net, "edges"))
+#' plot(inet)
+#' plot(inet, draw_lines = FALSE)
#'
-#' # Changing default settings.
+#' # Changing plot settings.
#' par(mar = c(1,1,1,1), mfrow = c(1,1))
-#' plot(net, col = 'blue', pch = 18, lwd = 1, cex = 2)
+#' plot(net, main = "My network", col = "blue", pch = 18, lwd = 1, cex = 2)
+#'
+#' # Changing plot settings for nodes and edges separately.
+#' plot(net, node_args = list(col = "red"), edge_args = list(col = "blue"))
#'
#' # Add grid and axis
#' par(mar = c(2.5,2.5,1,1))
#' plot(net, graticule = TRUE, axes = TRUE)
#'
+#' # Plot two networks on top of each other.
+#' par(mar = c(1,1,1,1), mfrow = c(1,1))
+#' neta = as_sfnetwork(roxel[1:10, ])
+#' netb = as_sfnetwork(roxel[50:60, ])
+#' plot(neta)
+#' plot(netb, node_args = list(col = "orange"), add = TRUE)
+#'
#' par(oldpar)
#'
#' @importFrom graphics plot
-#' @importFrom sf st_geometry
#' @export
-plot.sfnetwork = function(x, draw_lines = TRUE, ...) {
- # Extract and setup extra args.
+plot.sfnetwork = function(x, draw_lines = TRUE,
+ node_args = list(), edge_args = list(), ...) {
+ # Extract geometries of nodes and edges.
+ node_geoms = pull_node_geom(x)
+ edge_geoms = if (has_explicit_edges(x)) pull_edge_geom(x) else NULL
+ # Extract additional plot arguments.
dots = list(...)
- pch_missing = is.null(dots$pch)
- dots$pch = if (pch_missing) 20 else dots$pch
- # Get geometries of nodes.
- nsf = pull_node_geom(x)
- # Plot the nodes.
- do.call(plot, c(list(nsf), dots))
- # If necessary, plot also the edges.
- if (draw_lines) {
- x = explicitize_edges(x)
- esf = pull_edge_geom(x)
- dots$add = TRUE
- do.call(plot, c(list(esf), dots))
+ # Plot the edges.
+ if (draw_lines && is.null(edge_geoms)) {
+ bids = edge_incident_ids(x, matrix = TRUE)
+ edge_geoms = draw_lines(node_geoms[bids[, 1]], node_geoms[bids[, 2]])
+ }
+ if (! is.null(edge_geoms)) {
+ edge_args = c(edge_args, dots)
+ if (is.null(edge_args$extent)) edge_args$extent = st_network_bbox(x)
+ do.call(plot, c(list(edge_geoms), edge_args))
}
+ # Plot the nodes.
+ node_args = c(node_args, dots)
+ if (is.null(node_args$pch)) node_args$pch = 20
+ if (! is.null(edge_geoms) && ! isTRUE(node_args$add)) node_args$add = TRUE
+ do.call(plot, c(list(node_geoms), node_args))
+ # Return invisibly.
invisible()
}
@@ -78,12 +103,8 @@ plot.sfnetwork = function(x, draw_lines = TRUE, ...) {
#'
#' @name autoplot
autoplot.sfnetwork = function(object, ...) {
- g = ggplot2::ggplot() + ggplot2::geom_sf(data = nodes_as_sf(object))
- if (has_explicit_edges(object)) {
- g + ggplot2::geom_sf(data = edges_as_sf(object))
- } else {
- message("Spatially implicit edges are drawn as lines", call. = FALSE)
- object = explicitize_edges(object)
- g + ggplot2::geom_sf(data = edges_as_sf(object))
- }
+ object = make_edges_explicit(object) # Make sure edges are explicit.
+ ggplot2::ggplot() +
+ ggplot2::geom_sf(data = nodes_as_sf(object)) +
+ ggplot2::geom_sf(data = edges_as_sf(object))
}
diff --git a/R/print.R b/R/print.R
new file mode 100644
index 00000000..ef0d18f9
--- /dev/null
+++ b/R/print.R
@@ -0,0 +1,257 @@
+#' @export
+print.sfnetwork = function(x, ...,
+ n = getOption("sfn_max_print_active", 6),
+ n_non_active = getOption("sfn_max_print_inactive", 3)) {
+ N = node_data(x, focused = FALSE)
+ E = edge_data(x, focused = FALSE)
+ is_explicit = is_sf(E)
+ nodes_are_active = attr(x, "active") == "nodes"
+ # Print header.
+ cat_subtle(c("# A sfnetwork:", nrow(N), "nodes and", nrow(E), "edges\n"))
+ cat_subtle("#\n")
+ cat_subtle(describe_graph(x, is_explicit), "\n")
+ cat_subtle("#\n")
+ cat_subtle(describe_space(x, is_explicit), "\n")
+ cat_subtle("#\n")
+ if (is_focused(x)) {
+ if (nodes_are_active) {
+ n_focus = length(node_ids(x, focused = TRUE))
+ cat_subtle("# Focused on ", n_focus, " nodes\n")
+ } else {
+ n_focus = length(edge_ids(x, focused = TRUE))
+ cat_subtle("# Focused on ", n_focus, " edges\n")
+ }
+ }
+ # Print tables.
+ if (nodes_are_active) {
+ active_data = N
+ active_name = "Node data"
+ inactive_data = E
+ inactive_name = "Edge data"
+ } else {
+ active_data = E
+ active_name = "Edge data"
+ inactive_data = N
+ inactive_name = "Node data"
+ }
+ print(as_named_tbl(active_data, active_name, " (active)"), n = n, ...)
+ cat_subtle('#\n')
+ print(as_named_tbl(inactive_data, inactive_name), n = n_non_active)
+ invisible(x)
+}
+
+#' @importFrom utils capture.output
+#' @export
+print.morphed_sfnetwork = function(x, ...) {
+ x_tbg = structure(x, class = setdiff(class(x), "morphed_sfnetwork"))
+ out = capture.output(print(x_tbg), ...)
+ cat(gsub("tbl_graph", "sfnetwork", out[[1]]), "\n")
+ cat(out[[2]], "\n")
+ cat(out[[3]], "\n")
+ cat(out[[4]], "\n")
+ invisible(x)
+}
+
+# nocov start
+
+#' @importFrom pillar style_subtle
+cat_subtle = function(...) {
+ cat(style_subtle(paste0(...)))
+}
+
+#' @importFrom tibble as_tibble
+as_named_tbl = function(x, name = "A tibble", suffix = "") {
+ x = as_tibble(x)
+ attr(x, "name") = name
+ attr(x, "suffix") = suffix
+ class(x) = c("named_tbl", class(x))
+ x
+}
+
+#' Describe the graph structure of a sfnetwork
+#'
+#' @param x An object of class \code{\link{sfnetwork}}.
+#'
+#' @param is_explicit Is the network spatially explicit? If \code{NULL}, this
+#' will be automatically inferred from the provided network.
+#'
+#' @details This function is used by the print method for sfnetwork objects.
+#' It is adapted from the interal describe_graph function of tidygraph.
+#' See: https://github.com/thomasp85/tidygraph/blob/main/R/tbl_graph.R
+#'
+#' @return The description of the graph structure as a pasted character.
+#'
+#' @importFrom igraph is_simple is_directed is_bipartite is_connected is_dag
+#' gorder count_components
+#' @noRd
+describe_graph = function(x, is_explicit = NULL) {
+ if (gorder(x) == 0) return("# An empty graph")
+ prop = list(
+ simple = is_simple(x),
+ directed = is_directed(x),
+ bipartite = is_bipartite(x),
+ connected = is_connected(x),
+ tree = is_tree(x),
+ forest = is_forest(x),
+ DAG = is_dag(x),
+ explicit = if (is.null(is_explicit)) has_explicit_edges(x) else is_explicit
+ )
+ n_comp = count_components(x)
+ desc = c()
+ if (prop$tree || prop$forest) {
+ if (prop$directed) {
+ desc[1] = "A rooted"
+ } else {
+ desc[1] = "An unrooted"
+ }
+ if (prop$tree) {
+ desc[2] = "tree"
+ if (prop$explicit) {
+ desc[3] = "with spatially explicit edges"
+ } else {
+ desc[3] = "with spatially implicit edges"
+ }
+ } else {
+ desc[2] = paste0("forest with ", n_comp, " trees")
+ if (prop$explicit) {
+ desc[3] = "and spatially explicit edges"
+ } else {
+ desc[3] = "and spatially implicit edges"
+ }
+ }
+ } else {
+ if (prop$DAG) {
+ desc[1] = "A directed acyclic"
+ } else if (prop$bipartite) {
+ desc[1] = "A bipartite"
+ } else if (prop$directed) {
+ desc[1] = "A directed"
+ } else {
+ desc[1] = "An undirected"
+ }
+ if (prop$simple) {
+ desc[2] = "simple graph"
+ } else {
+ desc[2] = "multigraph"
+ }
+ if (n_comp > 1) {
+ desc[3] = paste0("with ", n_comp, " components")
+ } else {
+ desc[3] = paste0("with ", n_comp, " component")
+ }
+ if (prop$explicit) {
+ desc[4] = "and spatially explicit edges"
+ } else {
+ desc[4] = "and spatially implicit edges"
+ }
+ }
+ paste(c("#", desc), collapse = " ")
+}
+
+#' @importFrom igraph is_connected is_simple gorder gsize
+is_tree = function(x) {
+ is_connected(x) &&
+ is_simple(x) &&
+ (gorder(x) - gsize(x) == 1)
+}
+
+#' @importFrom igraph is_connected is_simple gorder gsize count_components
+is_forest = function(x) {
+ !is_connected(x) &&
+ is_simple(x) &&
+ (gorder(x) - gsize(x) - count_components(x) == 0)
+}
+
+#' Describe the spatial structure of a sfnetwork
+#'
+#' @param x An object of class \code{\link{sfnetwork}}.
+#'
+#' @param is_explicit Is the network spatially explicit? If \code{NULL}, this
+#' will be automatically inferred from the provided network.
+#'
+#' @details This function is used by the print method for sfnetwork objects.
+#' It is adapted from the print method for sfc objects in sf.
+#' See: https://github.com/r-spatial/sf/blob/main/R/sfc.R
+#'
+#' @return The description of the spatial structure as a pasted character.
+#'
+#' @importFrom sf st_crs
+#' @noRd
+describe_space = function(x, is_explicit = NULL) {
+ explicit = if (is.null(is_explicit)) has_explicit_edges(x) else is_explicit
+ node_geom = pull_node_geom(x)
+ edge_geom = if (explicit) pull_edge_geom(x) else NULL
+ desc = c()
+ # Dimensions.
+ if (length(node_geom)) {
+ desc = append(desc, paste("# Dimension:", class(node_geom[[1]])[1]))
+ }
+ # Bounding box.
+ if (explicit) {
+ box = merge_bboxes(attr(node_geom, "bbox"), attr(edge_geom, "bbox"))
+ } else {
+ box = attr(node_geom, "bbox")
+ }
+ box = signif(box, options("digits")$digits) # Round values.
+ box = paste(paste(names(box), box[], sep = ": "), collapse = " ") # Unpack.
+ desc = append(desc, paste("# Bounding box:", box))
+ # Z range.
+ if(! is.null(attr(node_geom, "z_range"))) {
+ if (explicit) {
+ zr = merge_zranges(attr(node_geom, "z_range"), attr(edge_geom, "z_range"))
+ } else {
+ zr = attr(node_geom, "z_range")
+ }
+ zr = signif(zr, options("digits")$digits) # Round values.
+ zr = paste(paste(names(zr), zr[], sep = ": "), collapse = " ")
+ desc = append(desc, paste("# Z range:", zr))
+ }
+ # M range.
+ if(! is.null(attr(node_geom, "m_range"))) {
+ if (explicit) {
+ mr = merge_mranges(attr(node_geom, "m_range"), attr(edge_geom, "m_range"))
+ } else {
+ mr = attr(node_geom, "m_range")
+ }
+ mr = signif(mr, options("digits")$digits) # Round values.
+ mr = paste(paste(names(mr), mr[], sep = ": "), collapse = " ")
+ desc = append(desc, paste("# M range:", mr))
+ }
+ # CRS.
+ crs = st_crs(node_geom)
+ if (is.na(crs)) {
+ desc = append(desc, paste("# CRS: NA"))
+ } else {
+ name = get_crs_name(crs)
+ if (crs$IsGeographic) {
+ desc = append(desc, paste("# Geodetic CRS:", name))
+ }
+ else {
+ desc = append(desc, paste("# Projected CRS:", name))
+ }
+ }
+ # Precision.
+ prc = network_precision(x)
+ if (prc < 0.0) {
+ desc = append(desc, paste("# Precision: float (single precision)"))
+ } else if (prc > 0.0) {
+ desc = append(desc, paste("# Precision:", prc))
+ }
+ paste(desc, collapse = "\n")
+}
+
+get_crs_name = function(crs) {
+ if (is.na(crs)) return(NA)
+ name = crs$Name
+ if (name == "unknown") {
+ input = crs$input
+ if (is.character(input) && !is.na(input) && input != "unknown") {
+ name = input
+ } else {
+ name = crs$proj4string
+ }
+ }
+ name
+}
+
+# nocov end
\ No newline at end of file
diff --git a/R/project.R b/R/project.R
new file mode 100644
index 00000000..07297d6c
--- /dev/null
+++ b/R/project.R
@@ -0,0 +1,110 @@
+#' Project spatial points on a spatial network
+#'
+#' @param x The spatial features to be projected, either as object of class
+#' \code{\link[sf]{sf}} or \code{\link[sf]{sfc}}, with \code{POINT} geometries.
+#'
+#' @param network An object of class \code{\link{sfnetwork}}.
+#'
+#' @param on On what component of the network should the points be projected?
+#' Setting it to \code{'edges'} (the default) will find the nearest point on
+#' the nearest edge to each point in \code{x}. Setting it to \code{'nodes'}
+#' will find the nearest node to each point in \code{x}.
+#'
+#' @details This function uses \code{\link[sf]{st_nearest_feature}} to find
+#' the nearest edge or node to each feature in \code{x}. When projecting on
+#' edges, it then finds the nearest point on the nearest edge by calling
+#' \code{\link[sf]{st_nearest_points}} in a pairwise manner.
+#'
+#' @note Due to internal rounding of rational numbers, even a point projected
+#' on an edge may not be evaluated as actually intersecting that edge when
+#' calling \code{\link[sf]{st_intersects}}.
+#'
+#' @returns The same object as \code{x} but with its geometries replaced by the
+#' projections.
+#'
+#' @examples
+#' library(sf, quietly = TRUE)
+#'
+#' oldpar = par(no.readonly = TRUE)
+#' par(mar = c(1,1,1,1))
+#'
+#' # Create a spatial network.
+#' n1 = st_point(c(0, 0))
+#' n2 = st_point(c(1, 0))
+#' n3 = st_point(c(2, 0))
+#'
+#' e1 = st_sfc(st_linestring(c(n1, n2)), crs = 3857)
+#' e2 = st_sfc(st_linestring(c(n2, n3)), crs = 3857)
+#'
+#' net = as_sfnetwork(c(e1, e2))
+#'
+#' # Create spatial points to project in.
+#' p1 = st_sfc(st_point(c(0.25, 0.1)))
+#' p2 = st_sfc(st_point(c(1, 0.2)))
+#' p3 = st_sfc(st_point(c(1.75, 0.15)))
+#'
+#' pts = st_sf(foo = letters[1:3], geometry = c(p1, p2, p3), crs = 3857)
+#'
+#' # Project points to the edges of the network.
+#' p1 = st_project_on_network(pts, net)
+#'
+#' plot(net)
+#' plot(st_geometry(pts), pch = 20, col = "orange", add = TRUE)
+#' plot(st_geometry(p1), pch = 4, col = "orange", add = TRUE)
+#'
+#' # Project points to the nodes of the network.
+#' p2 = st_project_on_network(pts, net, on = "nodes")
+#'
+#' plot(net)
+#' plot(st_geometry(pts), pch = 20, col = "orange", add = TRUE)
+#' plot(st_geometry(p2), pch = 4, col = "orange", add = TRUE)
+#'
+#' par(oldpar)
+#'
+#' @export
+st_project_on_network = function(x, network, on = "edges") {
+ UseMethod("st_project_on_network")
+}
+
+#' @export
+st_project_on_network.sfc = function(x, network, on = "edges") {
+ switch(
+ on,
+ edges = project_on_edges(x, network),
+ nodes = project_on_nodes(x, network),
+ raise_unknown_input("on", on, c("edges", "nodes"))
+ )
+}
+
+#' @importFrom sf st_set_geometry
+#' @export
+st_project_on_network.sf = function(x, network, on = "edges") {
+ P = st_project_on_network(st_geometry(x), network, on)
+ st_set_geometry(x, P)
+}
+
+#' @importFrom sf st_nearest_feature st_nearest_points
+#' @importFrom sfheaders sfc_cast
+project_on_edges = function(x, y) {
+ E = pull_edge_geom(y)
+ # Find the nearest edge to each feature.
+ nearest = st_nearest_feature(x, E)
+ # Find the nearest point on the nearest edge to each close feature.
+ # For this we can use sf::sf_nearest_points, which returns:
+ # --> A straight line between feature and point if they are different.
+ # --> A multipoint of feature and point if they are equal.
+ # To make it easier for ourselves we cast all outputs to lines.
+ L = st_nearest_points(x, E[nearest], pairwise = TRUE)
+ L = sfc_cast(L, "LINESTRING")
+ # Then, the endpoint of that line is the location we are looking for.
+ linestring_end_points(L)
+}
+
+#' @importFrom sf st_nearest_feature
+project_on_nodes = function(x, y) {
+ N = pull_node_geom(y)
+ # Find the nearest node to each feature.
+ nearest = st_nearest_feature(x, N)
+ # Return the nearest node geometries.
+ N[nearest]
+}
\ No newline at end of file
diff --git a/R/require.R b/R/require.R
index d5a0fdd4..14805080 100644
--- a/R/require.R
+++ b/R/require.R
@@ -8,26 +8,22 @@
#' message otherwise.
#'
#' @name require_active
+#' @importFrom cli cli_abort
#' @importFrom tidygraph .graph_context
#' @noRd
require_active_nodes <- function() {
if (!.graph_context$free() && .graph_context$active() != "nodes") {
- stop(
- "This call requires nodes to be active",
- call. = FALSE
- )
+ cli_abort("This call requires nodes to be active.")
}
}
#' @name require_active
+#' @importFrom cli cli_abort
#' @importFrom tidygraph .graph_context
#' @noRd
require_active_edges <- function() {
if (!.graph_context$free() && .graph_context$active() != "edges") {
- stop(
- "This call requires edges to be active",
- call. = FALSE
- )
+ cli_abort("This call requires edges to be active.")
}
}
@@ -35,114 +31,11 @@ require_active_edges <- function() {
#'
#' @param x An object of class \code{\link{sfnetwork}}.
#'
-#' @param hard Is it a hard requirement, meaning that edges need to be
-#' spatially explicit no matter which network element is active? Defaults to
-#' \code{FALSE}, meaning that the error message will suggest to activate nodes
-#' instead.
-#'
#' @return Nothing when the edges of x are spatially explicit, an error message
#' otherwise.
#'
+#' @importFrom cli cli_abort
#' @noRd
-require_explicit_edges = function(x, hard = FALSE) {
- if (! has_explicit_edges(x)) {
- if (hard) {
- stop(
- "This call requires spatially explicit edges",
- call. = FALSE
- )
- } else{
- stop(
- "This call requires spatially explicit edges when applied to the ",
- "edges table. Activate nodes first?",
- call. = FALSE
- )
- }
- }
-}
-
-#' Proceed only when the network has a valid sfnetwork structure
-#'
-#' @param x An object of class \code{\link{sfnetwork}}.
-#'
-#' @param message Should a message be printed before and after the validation?
-#' Default to \code{FALSE}.
-#'
-#' @return Nothing when the network has a valid sfnetwork structure, an error
-#' message otherwise.
-#'
-#' @details A valid sfnetwork structure means that all nodes have \code{POINT}
-#' geometries, and - when edges are spatially explicit - all edges have
-#' \code{LINESTRING} geometries, nodes and edges have the same CRS and
-#' coordinates of edge boundaries match coordinates of their corresponding
-#' nodes.
-#'
-#' @noRd
-require_valid_network_structure = function(x, message = FALSE) {
- if (message) message("Checking if spatial network structure is valid...")
- validate_nodes(x)
- if (has_explicit_edges(x)) {
- validate_edges(x)
- }
- if (message) message("Spatial network structure is valid")
-}
-
-#' @importFrom sf st_as_sf
-validate_nodes = function(x) {
- nodes = nodes_as_sf(x)
- # --> Are all node geometries points?
- if (! has_single_geom_type(nodes, "POINT")) {
- stop(
- "Not all nodes have geometry type POINT",
- call. = FALSE
- )
- }
-}
-
-#' @importFrom igraph is_directed
-#' @importFrom sf st_as_sf
-validate_edges = function(x) {
- nodes = nodes_as_sf(x)
- edges = edges_as_sf(x)
- # --> Are all edge geometries linestrings?
- if (! has_single_geom_type(edges, "LINESTRING")) {
- stop(
- "Not all edges have geometry type LINESTRING",
- call. = FALSE
- )
- }
- # --> Is the CRS of the edges the same as of the nodes?
- if (! have_equal_crs(nodes, edges)) {
- stop(
- "Nodes and edges do not have the same CRS",
- call. = FALSE
- )
- }
- # --> Is the precision of the edges the same as of the nodes?
- if (! have_equal_precision(nodes, edges)) {
- stop(
- "Nodes and edges do not have the same precision",
- call. = FALSE
- )
- }
- # --> Do the edge boundary points match their corresponding nodes?
- if (is_directed(x)) {
- # Start point should match start node.
- # End point should match end node.
- if (! all(nodes_match_edge_boundaries(x))) {
- stop(
- "Edge boundaries do not match their corresponding nodes",
- call. = FALSE
- )
- }
- } else {
- # Start point should match either start or end node.
- # End point should match either start or end node.
- if (! all(nodes_in_edge_boundaries(x))) {
- stop(
- "Edge boundaries do not match their corresponding nodes",
- call. = FALSE
- )
- }
- }
+require_explicit_edges = function(x) {
+ if (! has_explicit_edges(x)) raise_require_explicit()
}
diff --git a/R/roxel.R b/R/roxel.R
index 12d5b535..98b8b3d0 100644
--- a/R/roxel.R
+++ b/R/roxel.R
@@ -2,11 +2,11 @@
#'
#' A dataset containing the road network (roads, bikelanes, footpaths, etc.) of
#' Roxel, a neighborhood in the city of Münster, Germany. The data are taken
-#' from OpenStreetMap, querying by key = 'highway'. The topology is cleaned with
-#' the v.clean tool in GRASS GIS.
+#' from OpenStreetMap, querying by key = 'highway'.
+#' See `data-raw/roxel.R` for code on its creation.
#'
#' @format An object of class \code{\link[sf]{sf}} with \code{LINESTRING}
-#' geometries, containing 851 features and three columns:
+#' geometries, containing 1215 features and three columns:
#' \describe{
#' \item{name}{the name of the road, if it exists}
#' \item{type}{the type of the road, e.g. cycleway}
diff --git a/R/s2.R b/R/s2.R
deleted file mode 100644
index bb440e73..00000000
--- a/R/s2.R
+++ /dev/null
@@ -1,9 +0,0 @@
-#' s2 methods for sfnetworks
-#'
-#' @param x An object of class \code{\link{sfnetwork}}.
-#' @param ... Arguments passed on the corresponding \code{s2} function.
-#'
-#' @name s2
-as_s2_geography.sfnetwork = function(x, ...) {
- s2::as_s2_geography(st_geometry(x))
-}
diff --git a/R/sf.R b/R/sf.R
index a83b7192..eb863747 100644
--- a/R/sf.R
+++ b/R/sf.R
@@ -1,15 +1,3 @@
-is.sf = function(x) {
- inherits(x, "sf")
-}
-
-is.sfc = function(x) {
- inherits(x, "sfc")
-}
-
-is.sfg = function(x) {
- inherits(x, "sfg")
-}
-
#' sf methods for sfnetworks
#'
#' \code{\link[sf]{sf}} methods for \code{\link{sfnetwork}} objects.
@@ -29,155 +17,314 @@ is.sfg = function(x) {
#' extracting. If \code{NULL}, it will be set to the current active element of
#' the given network. Defaults to \code{NULL}.
#'
+#' @param focused Should only features that are in focus be extracted? Defaults
+#' to \code{TRUE}. See \code{\link[tidygraph]{focus}} for more information on
+#' focused networks.
+#'
#' @param value The value to be assigned. See the documentation of the
#' corresponding sf function for details.
#'
#' @param precision The precision to be assigned. See
#' \code{\link[sf]{st_precision}} for details.
#'
-#' @return The \code{sfnetwork} method for \code{\link[sf]{st_as_sf}} returns
-#' the active element of the network as object of class \code{\link[sf]{sf}}.
-#' The \code{sfnetwork} and \code{morphed_sfnetwork} methods for
-#' \code{\link[sf]{st_join}}, \code{\link[sf]{st_filter}},
-#' \code{\link[sf]{st_intersection}}, \code{\link[sf]{st_difference}},
-#' \code{\link[sf]{st_crop}} and the setter functions
-#' return an object of class \code{\link{sfnetwork}}
-#' and \code{morphed_sfnetwork} respectively. All other
-#' methods return the same type of objects as their corresponding sf function.
-#' See the \code{\link[sf]{sf}} documentation for details.
+#' @param ignore_multiple When performing a spatial join with the nodes
+#' table, and there are multiple matches for a single node, only the first one
+#' of them is joined into the network. But what should happen with the others?
+#' If this argument is set to \code{TRUE}, they will be ignored. If this
+#' argument is set to \code{FALSE}, they will be added as isolated nodes to the
+#' returned network. Nodes at equal locations can then be merged using the
+#' spatial morpher \code{\link{to_spatial_unique}}. Defaults to \code{TRUE}.
+#'
+#' @return The methods for \code{\link[sf]{st_join}},
+#' \code{\link[sf]{st_filter}}, \code{\link[sf]{st_intersection}},
+#' \code{\link[sf]{st_difference}} and \code{\link[sf]{st_crop}}, as well as
+#' the methods for all setter functions and the geometric unary operations
+#' preserve the class of the object it is applied to, i.e. either a
+#' \code{\link{sfnetwork}} object or its morphed equivalent. When dropping node
+#' geometries, an object of class \code{\link[tidygraph]{tbl_graph}} is
+#' returned. All other methods return the same type of objects as their
+#' corresponding sf function. See the \code{\link[sf]{sf}} documentation for
+#' details.
+#'
+#' @details See the \code{\link[sf]{sf}} documentation. The following methods
+#' have a special behavior:
#'
-#' @details See the \code{\link[sf]{sf}} documentation.
+#' \itemize{
+#' \item \code{st_geometry<-}: The geometry setter requires the replacement
+#' geometries to have the same CRS as the network. Node replacements should
+#' all be points, while edge replacements should all be linestrings. When
+#' replacing node geometries, the boundaries of the edge geometries are
+#' replaced as well to preserve the valid spatial network structure. When
+#' replacing edge geometries, new edge boundaries that do not match the
+#' location of their specified incident node are added as new nodes to the
+#' network.
+#' \item \code{st_transform}: No matter if applied to the nodes or edge
+#' table, this method will update the coordinates of both tables. The same
+#' holds for all other methods that update the way in which the coordinates
+#' are encoded without changing their actual location, such as
+#' \code{st_precision}, \code{st_normalize}, \code{st_zm}, and others.
+#' \item \code{st_join}: When applied to the nodes table and multiple matches
+#' exist for the same node, only the first match is joined. A warning will be
+#' given in this case. If \code{ignore_multiple = FALSE}, multiple mathces
+#' are instead added as isolated nodes to the returned network.
+#' \item \code{st_intersection}, \code{st_difference} and \code{st_crop}:
+#' These methods clip edge geometries when applied to the edges table. To
+#' preserve a valid spatial network structure, clipped edge boundaries are
+#' added as new nodes to the network.
+#' \item \code{st_reverse}: When reversing edge geometries in a directed
+#' network, the indices in the from and to columns will be swapped as well.
+#' \item \code{st_segmentize}: When segmentizing edge geometries, the edge
+#' boundaries are forced to remain the same such that the valid spatial
+#' network structure is preserved. This may lead to slightly inaccurate
+#' results.
+#' }
#'
-#' @name sf
+#' Geometric unary operations are only supported on \code{\link{sfnetwork}}
+#' objects if they do not change the geometry type nor the spatial location
+#' of the original features, since that would break the valid spatial network
+#' structure. When applying the unsupported operations, first extract the
+#' element of interest (nodes or edges) using \code{\link[sf]{st_as_sf}}.
+#'
+#' @name sf_methods
+NULL
+
+#' @name sf_methods
#'
#' @examples
#' library(sf, quietly = TRUE)
#'
+#' oldpar = par(no.readonly = TRUE)
+#' par(mar = c(1,1,1,1), mfrow = c(1,2))
+#'
#' net = as_sfnetwork(roxel)
#'
-#' # Extract the active network element.
+#' # Extract the active network element as sf object.
#' st_as_sf(net)
#'
-#' # Extract any network element.
+#' # Extract any network element as sf object.
#' st_as_sf(net, "edges")
#'
#' @importFrom sf st_as_sf
#' @export
-st_as_sf.sfnetwork = function(x, active = NULL, ...) {
+st_as_sf.sfnetwork = function(x, active = NULL, focused = TRUE, ...) {
if (is.null(active)) active = attr(x, "active")
switch(
active,
- nodes = nodes_as_sf(x, ...),
- edges = edges_as_sf(x, ...),
- raise_unknown_input(active)
+ nodes = nodes_as_sf(x, focused = focused, ...),
+ edges = edges_as_sf(x, focused = focused, ...),
+ raise_invalid_active(active)
)
}
#' @importFrom sf st_as_sf
-#' @importFrom tibble as_tibble
-#' @importFrom tidygraph as_tbl_graph
-nodes_as_sf = function(x, ...) {
- st_as_sf(
- as_tibble(as_tbl_graph(x), "nodes"),
+nodes_as_sf = function(x, focused = FALSE, ...) {
+ out = st_as_sf(
+ nodes_as_regular_tibble(x, focused = focused),
agr = node_agr(x),
- sf_column_name = node_geom_colname(x)
+ sf_column_name = node_geom_colname(x),
+ ...
)
+ p = network_precision(x)
+ if (! is.null(p)) st_precision(out) = p
+ out
}
#' @importFrom sf st_as_sf
-#' @importFrom tibble as_tibble
-#' @importFrom tidygraph as_tbl_graph
-edges_as_sf = function(x, ...) {
- require_explicit_edges(x)
- st_as_sf(
- as_tibble(as_tbl_graph(x), "edges"),
+edges_as_sf = function(x, focused = FALSE, ...) {
+ geom_colname = edge_geom_colname(x)
+ if (is.null(geom_colname)) raise_require_explicit()
+ out = st_as_sf(
+ edges_as_regular_tibble(x, focused = focused),
agr = edge_agr(x),
- sf_column_name = edge_geom_colname(x)
+ sf_column_name = geom_colname,
+ ...
)
-}
-
-#' @name sf
-#' @importFrom sf st_as_s2
-#' @export
-st_as_s2.sfnetwork = function(x, active = NULL, ...) {
- st_as_s2(pull_geom(x, active), ...)
+ p = network_precision(x)
+ if (! is.null(p)) st_precision(out) = p
+ out
}
# =============================================================================
# Geometries
# =============================================================================
-#' @name sf
+#' @name sf_methods
#' @examples
-#' # Get geometry of the active network element.
+#' # Get the geometry of the active network element.
#' st_geometry(net)
#'
-#' # Get geometry of any network element.
+#' # Get the geometry of any network element.
#' st_geometry(net, "edges")
#'
#' @importFrom sf st_geometry
#' @export
-st_geometry.sfnetwork = function(obj, active = NULL, ...) {
- pull_geom(obj, active)
+st_geometry.sfnetwork = function(obj, active = NULL, focused = TRUE, ...) {
+ pull_geom(obj, active, focused = focused)
}
-#' @name sf
+#' @name sf_methods
+#' @examples
+#' # Replace the geometry of the nodes.
+#' # This will automatically update edge geometries to match the new nodes.
+#' newnet = net
+#' newnds = rep(st_centroid(st_combine(st_geometry(net))), n_nodes(net))
+#' st_geometry(newnet) = newnds
+#'
+#' plot(net)
+#' plot(newnet)
+#'
+#' @importFrom cli cli_abort
#' @importFrom sf st_geometry<-
#' @export
`st_geometry<-.sfnetwork` = function(x, value) {
- if (is.null(value)) {
- x_new = drop_geom(x)
- } else {
- x_new = mutate_geom(x, value)
- require_valid_network_structure(x_new)
+ if (is.null(value)) return (drop_geom(x))
+ if (! have_equal_crs(x, value)) {
+ cli_abort(c(
+ "Replacement has a different CRS.",
+ "i" = "The CRS of the replacement should equal the original CRS.",
+ "i" = "You can transform to another CRS using {.fn sf::st_transform}."
+ ))
+ }
+ if (attr(x, "active") == "nodes") {
+ if (length(value) != n_nodes(x)) {
+ cli_abort(c(
+ "Replacement has a different number of features.",
+ "i" = "The network has {n_nodes(x)} nodes, not {length(value)}."
+ ))
+ }
+ if (! are_points(value)) {
+ cli_abort(c(
+ "Unsupported geometry types.",
+ "i" = "Node geometries should all be {.cls POINT}."
+ ))
+ }
+ x_new = mutate_node_geom(x, value, focused = TRUE)
+ make_edges_valid(x_new)
+ } else {
+ if (length(value) != n_edges(x)) {
+ cli_abort(c(
+ "Replacement has a different number of features.",
+ "i" = "The network has {n_edges(x)} edges, not {length(value)}."
+ ))
+ }
+ if (! are_linestrings(value)) {
+ cli_abort(c(
+ "Unsupported geometry types.",
+ "i" = "Edge geometries should all be {.cls LINESTRING}."
+ ))
+ }
+ x_new = mutate_edge_geom(x, value, focused = TRUE)
+ make_edges_valid(x_new, preserve_geometries = TRUE)
}
+}
+
+#' @importFrom cli cli_abort
+#' @importFrom igraph is_directed
+#' @importFrom sf st_geometry<-
+#' @importFrom tibble as_tibble
+#' @export
+`st_geometry<-.tbl_graph` = function(x, value) {
+ if (attr(x, "active") == "edges") {
+ cli_abort(c(
+ "Edge geometries can not be set on {.cls tbl_graph} objects.",
+ "i" = "Call {.fn tidygraph::activate} to activate nodes instead."
+ ))
+ }
+ N = as_tibble(x, "nodes")
+ st_geometry(N) = value
+ x_new = tbg_to_sfn(x)
+ node_data(x_new) = N
x_new
}
-#' @name sf
+#' @importFrom sf st_geometry<-
+#' @importFrom tidygraph as_tbl_graph
+#' @export
+`st_geometry<-.igraph` = function(x, value) {
+ `st_geometry<-`(as_tbl_graph(x), value)
+}
+
+#' @name sf_methods
+#' @examples
+#' # Drop the geometries of the edges.
+#' # This returns an sfnetwork with spatially implicit edges.
+#' st_drop_geometry(activate(net, "edges"))
+#'
+#' # Drop the geometries of the nodes.
+#' # This returns a tbl_graph.
+#' st_drop_geometry(net)
+#'
#' @importFrom sf st_drop_geometry
#' @export
st_drop_geometry.sfnetwork = function(x, ...) {
drop_geom(x)
}
-#' @name sf
+#' @name sf_methods
#' @examples
-#' # Get bbox of the active network element.
+#' # Get the bounding box of the active network element.
#' st_bbox(net)
#'
#' @importFrom sf st_bbox
#' @export
st_bbox.sfnetwork = function(obj, active = NULL, ...) {
- st_bbox(pull_geom(obj, active), ...)
+ st_bbox(pull_geom(obj, active, focused = TRUE), ...)
}
-#' @name sf
+#' @name sf_methods
#' @importFrom sf st_coordinates
#' @export
st_coordinates.sfnetwork = function(x, active = NULL, ...) {
- st_coordinates(pull_geom(x, active), ...)
+ st_coordinates(pull_geom(x, active, focused = TRUE), ...)
}
-#' @name sf
+#' @name sf_methods
#' @importFrom sf st_is
#' @export
st_is.sfnetwork = function(x, ...) {
- st_is(pull_geom(x), ...)
+ st_is(pull_geom(x, focused = TRUE), ...)
}
-#' @name sf
+#' @name sf_methods
#' @importFrom sf st_is_valid
#' @export
st_is_valid.sfnetwork = function(x, ...) {
- st_is_valid(pull_geom(x), ...)
+ st_is_valid(pull_geom(x, focused = TRUE), ...)
+}
+
+#' Extract the geometries of a sfnetwork as a S2 geography vector
+#'
+#' A method to convert an object of class \code{\link{sfnetwork}} into
+#' \code{\link[s2]{s2_geography}} format. Use this method without the
+#' .sfnetwork suffix and after loading the \pkg{s2} package.
+#'
+#' @param x An object of class \code{\link{sfnetwork}}.
+#'
+#' @param focused Should only features that are in focus be extracted? Defaults
+#' to \code{TRUE}. See \code{\link[tidygraph]{focus}} for more information on
+#' focused networks.
+#'
+#' @param ... Arguments passed on the corresponding \code{s2} function.
+#'
+#' @return An object of class \code{\link[s2]{s2_geography}}.
+#'
+#' @name as_s2_geography
+as_s2_geography.sfnetwork = function(x, focused = TRUE, ...) {
+ s2::as_s2_geography(pull_geom(x, focused = focused), ...)
+}
+
+#' @name sf_methods
+#' @importFrom sf st_as_s2
+#' @export
+st_as_s2.sfnetwork = function(x, active = NULL, focused = TRUE, ...) {
+ st_as_s2(pull_geom(x, active, focused = focused), ...)
}
# =============================================================================
# Coordinates
# =============================================================================
-#' @name sf
+#' @name sf_methods
#' @examples
#' # Get CRS of the network.
#' st_crs(net)
@@ -188,7 +335,7 @@ st_crs.sfnetwork = function(x, ...) {
st_crs(pull_geom(x), ...)
}
-#' @name sf
+#' @name sf_methods
#' @importFrom sf st_crs<- st_crs
#' @export
`st_crs<-.sfnetwork` = function(x, value) {
@@ -202,14 +349,24 @@ st_crs.sfnetwork = function(x, ...) {
mutate_node_geom(x, geom)
}
-#' @name sf
+#' @name sf_methods
#' @importFrom sf st_precision
#' @export
st_precision.sfnetwork = function(x) {
- st_precision(pull_geom(x))
+ network_precision(x)
}
-#' @name sf
+#' @importFrom igraph edge_attr vertex_attr
+network_precision = function(x) {
+ nc = node_geom_colname(x)
+ np = attr(vertex_attr(x, nc), "precision")
+ if (! is.null(np)) return (np)
+ ec = edge_geom_colname(x)
+ if (is.null(ec)) return (NULL)
+ attr(edge_attr(x, ec), "precision")
+}
+
+#' @name sf_methods
#' @importFrom sf st_set_precision st_precision<-
#' @export
st_set_precision.sfnetwork = function(x, precision) {
@@ -223,63 +380,63 @@ st_set_precision.sfnetwork = function(x, precision) {
mutate_node_geom(x, geom)
}
-#' @name sf
+#' @name sf_methods
#' @importFrom sf st_shift_longitude
#' @export
st_shift_longitude.sfnetwork = function(x, ...) {
change_coords(x, op = st_shift_longitude, ...)
}
-#' @name sf
+#' @name sf_methods
#' @importFrom sf st_transform
#' @export
st_transform.sfnetwork = function(x, ...) {
change_coords(x, op = st_transform, ...)
}
-#' @name sf
+#' @name sf_methods
#' @importFrom sf st_wrap_dateline
#' @export
st_wrap_dateline.sfnetwork = function(x, ...) {
change_coords(x, op = st_wrap_dateline, ...)
}
-#' @name sf
+#' @name sf_methods
#' @importFrom sf st_normalize
#' @export
st_normalize.sfnetwork = function(x, ...) {
change_coords(x, op = st_normalize, ...)
}
-#' @name sf
+#' @name sf_methods
#' @importFrom sf st_zm
#' @export
st_zm.sfnetwork = function(x, ...) {
change_coords(x, op = st_zm, ...)
}
-#' @name sf
+#' @name sf_methods
#' @importFrom sf st_m_range
#' @export
st_m_range.sfnetwork = function(obj, active = NULL, ...) {
- st_m_range(pull_geom(obj, active), ...)
+ st_m_range(pull_geom(obj, active, focused = TRUE), ...)
}
-#' @name sf
+#' @name sf_methods
#' @importFrom sf st_z_range
#' @export
st_z_range.sfnetwork = function(obj, active = NULL, ...) {
- st_z_range(pull_geom(obj, active), ...)
+ st_z_range(pull_geom(obj, active, focused = TRUE), ...)
}
change_coords = function(x, op, ...) {
if (attr(x, "active") == "edges" || has_explicit_edges(x)) {
geom = pull_edge_geom(x)
- new_geom = do.call(match.fun(op), list(geom, ...))
+ new_geom = op(geom, ...)
x = mutate_edge_geom(x, new_geom)
}
geom = pull_node_geom(x)
- new_geom = do.call(match.fun(op), list(geom, ...))
+ new_geom = op(geom, ...)
mutate_node_geom(x, new_geom)
}
@@ -287,7 +444,7 @@ change_coords = function(x, op, ...) {
# Attribute Geometry Relationships
# =============================================================================
-#' @name sf
+#' @name sf_methods
#' @examples
#' # Get agr factor of the active network element.
#' st_agr(net)
@@ -301,12 +458,12 @@ st_agr.sfnetwork = function(x, active = NULL, ...) {
agr(x, active)
}
-#' @name sf
+#' @name sf_methods
#' @importFrom sf st_agr<- st_agr st_as_sf
#' @export
`st_agr<-.sfnetwork` = function(x, value) {
active = attr(x, "active")
- x_sf = st_as_sf(x, active)
+ x_sf = st_as_sf(x, active, focused = FALSE)
st_agr(x_sf) = value
agr(x, active) = st_agr(x_sf)
x
@@ -326,36 +483,52 @@ st_agr.sfnetwork = function(x, active = NULL, ...) {
# as their corresponding LINESTRING geometries in x (source and target may be
# switched).
-#' @name sf
-#' @importFrom igraph is_directed
+#' @name sf_methods
+#' @importFrom cli cli_warn
+#' @importFrom igraph is_directed reverse_edges
#' @importFrom sf st_reverse
-#' @importFrom tidygraph as_tbl_graph reroute
#' @export
st_reverse.sfnetwork = function(x, ...) {
active = attr(x, "active")
if (active == "edges") {
- require_explicit_edges(x)
if (is_directed(x)) {
- warning(
- "In directed networks st_reverse swaps columns 'to' and 'from'",
- call. = FALSE
- )
- node_ids = edge_boundary_node_indices(x, matrix = TRUE)
- from_ids = node_ids[, 1]
- to_ids = node_ids[, 2]
- x_tbg = reroute(as_tbl_graph(x), from = to_ids, to = from_ids)
- x = tbg_to_sfn(x_tbg)
+ x = reverse_edges(x, eids = edge_ids(x)) %preserve_all_attrs% x
}
} else {
- warning(
- "st_reverse has no effect on nodes. Activate edges first?",
- call. = FALSE
- )
+ cli_warn(c(
+ "{.fn st_reverse} has no effect on nodes.",
+ "i" = "Call {.fn tidygraph::activate} to activate edges instead."
+ ))
}
geom_unary_ops(st_reverse, x, active,...)
}
-#' @name sf
+#' @name sf_methods
+#' @importFrom cli cli_warn
+#' @importFrom igraph is_directed
+#' @importFrom sf st_segmentize
+#' @export
+st_segmentize.sfnetwork = function(x, ...) {
+ active = attr(x, "active")
+ if (active == "edges") {
+ x_new = geom_unary_ops(st_segmentize, x, active,...)
+ # st_segmentize can sometimes slightly move linestring boundaries.
+ # We need them to remain constant to preserve the valid network structure.
+ # Therefore we have to update edge boundaries after calling st_segmentize.
+ # Note that this may mean results are slightly inaccurate.
+ # TODO: Do we need to warn users for this?
+ if (is_directed(x)) x_new = make_edges_follow_indices(x_new)
+ make_edges_valid(x_new)
+ } else {
+ cli_warn(c(
+ "{.fn st_segmentize} has no effect on nodes.",
+ "i" = "Call {.fn tidygraph::activate} to activate edges instead."
+ ))
+ geom_unary_ops(st_segmentize, x, active,...)
+ }
+}
+
+#' @name sf_methods
#' @importFrom sf st_simplify
#' @export
st_simplify.sfnetwork = function(x, ...) {
@@ -366,15 +539,15 @@ st_simplify.sfnetwork = function(x, ...) {
#' @importFrom sf st_as_sf st_geometry
geom_unary_ops = function(op, x, active, ...) {
x_sf = st_as_sf(x, active = active)
- d_tmp = do.call(match.fun(op), list(x_sf, ...))
- mutate_geom(x, st_geometry(d_tmp), active = active)
+ d_tmp = op(x_sf, ...)
+ mutate_geom(x, st_geometry(d_tmp), active = active, focused = TRUE)
}
# =============================================================================
# Join and filter
# =============================================================================
-#' @name sf
+#' @name sf_methods
#' @examples
#' # Spatial join applied to the active network element.
#' net = st_transform(net, 3035)
@@ -384,27 +557,29 @@ geom_unary_ops = function(op, x, active, ...) {
#' joined = st_join(net, codes, join = st_intersects)
#' joined
#'
-#' oldpar = par(no.readonly = TRUE)
-#' par(mar = c(1,1,1,1), mfrow = c(1,2))
#' plot(net, col = "grey")
#' plot(codes, col = NA, border = "red", lty = 4, lwd = 4, add = TRUE)
#' text(st_coordinates(st_centroid(st_geometry(codes))), codes$post_code)
+#'
#' plot(st_geometry(joined, "edges"))
#' plot(st_as_sf(joined, "nodes"), pch = 20, add = TRUE)
#' par(oldpar)
+#'
#' @importFrom sf st_join
+#' @importFrom tidygraph unfocus
#' @export
-st_join.sfnetwork = function(x, y, ...) {
+st_join.sfnetwork = function(x, y, ..., ignore_multiple = TRUE) {
+ x = unfocus(x)
active = attr(x, "active")
switch(
active,
- nodes = spatial_join_nodes(x, y, ...),
+ nodes = spatial_join_nodes(x, y, ..., ignore_multiple = ignore_multiple),
edges = spatial_join_edges(x, y, ...),
- raise_unknown_input(active)
+ raise_invalid_active(active)
)
}
-#' @name sf
+#' @name sf_methods
#' @importFrom sf st_join
#' @export
st_join.morphed_sfnetwork = function(x, y, ...) {
@@ -412,9 +587,10 @@ st_join.morphed_sfnetwork = function(x, y, ...) {
x
}
+#' @importFrom cli cli_warn
#' @importFrom igraph delete_vertices vertex_attr<-
#' @importFrom sf st_as_sf st_join
-spatial_join_nodes = function(x, y, ...) {
+spatial_join_nodes = function(x, y, ..., ignore_multiple = TRUE) {
# Convert x and y to sf.
x_sf = nodes_as_sf(x)
y_sf = st_as_sf(y)
@@ -432,32 +608,57 @@ spatial_join_nodes = function(x, y, ...) {
# --> See the package vignettes for more info.
duplicated_match = duplicated(n_new$.sfnetwork_index)
if (any(duplicated_match)) {
- n_new = n_new[!duplicated_match, ]
- warning(
- "Multiple matches were detected from some nodes. ",
- "Only the first match is considered",
- call. = FALSE
- )
+ if (ignore_multiple) {
+ cli_warn(c(
+ "{.fn st_join} did not join all features.",
+ "!" = paste(
+ "Multiple matches were detected for some nodes,",
+ "of which all but the first one are ignored."
+ ),
+ "i" = paste(
+ "If you want to add multiple matches as isolated nodes instead,",
+ "set {.arg ignore_multiple} to {.code FALSE}."
+ )
+ ))
+ n_new = n_new[!duplicated_match, ]
+ } else {
+ cli_warn(c(
+ "{.fn st_join} created isolated nodes.",
+ "!" = paste(
+ "Multiple matches were detected for some nodes, of which all but",
+ "the first one are added as isolated nodes to the network."
+ ),
+ "i" = paste(
+ "If you want to ignore multiple matches instead,",
+ "set {.arg ignore_multiple} to {.code TRUE}."
+ )
+ ))
+ n_dups = n_new[duplicated_match, ]
+ n_new = n_new[!duplicated_match, ]
+ }
}
# If an inner join was requested instead of a left join:
# --> This means only nodes in x that had a match in y are preserved.
# --> The other nodes need to be removed.
- args = list(...)
- if (!is.null(args$left) && !args$left) {
+ if (isTRUE(list(...)$left)) {
keep = n_new$.sfnetwork_index
drop = if (length(keep) == 0) orig_idxs else orig_idxs[-keep]
x = delete_vertices(x, drop) %preserve_all_attrs% x
}
# Update node attributes of the original network.
n_new$.sfnetwork_index = NULL
- node_attribute_values(x) = n_new
+ node_data(x) = n_new
+ # Add duplicated matches as isolated nodes.
+ if (any(duplicated_match) & !ignore_multiple) {
+ n_dups$.sfnetwork_index = NULL
+ x = bind_spatial_nodes(x, n_dups)
+ }
x
}
#' @importFrom igraph is_directed
#' @importFrom sf st_as_sf st_join
spatial_join_edges = function(x, y, ...) {
- require_explicit_edges(x)
# Convert x and y to sf.
x_sf = edges_as_sf(x)
y_sf = st_as_sf(y)
@@ -468,7 +669,7 @@ spatial_join_edges = function(x, y, ...) {
x_new %preserve_network_attrs% x
}
-#' @name sf
+#' @name sf_methods
#' @examples
#' # Spatial filter applied to the active network element.
#' p1 = st_point(c(4151358, 3208045))
@@ -476,32 +677,34 @@ spatial_join_edges = function(x, y, ...) {
#' p3 = st_point(c(4151756, 3207506))
#' p4 = st_point(c(4151774, 3208031))
#'
-#' poly = st_multipoint(c(p1, p2, p3, p4)) %>%
-#' st_cast('POLYGON') %>%
-#' st_sfc(crs = 3035) %>%
+#' poly = st_multipoint(c(p1, p2, p3, p4)) |>
+#' st_cast('POLYGON') |>
+#' st_sfc(crs = 3035) |>
#' st_as_sf()
#'
#' filtered = st_filter(net, poly, .pred = st_intersects)
#'
-#' oldpar = par(no.readonly = TRUE)
-#' par(mar = c(1,1,1,1), mfrow = c(1,2))
#' plot(net, col = "grey")
#' plot(poly, border = "red", lty = 4, lwd = 4, add = TRUE)
#' plot(filtered)
+#'
#' par(oldpar)
+#'
#' @importFrom sf st_filter
+#' @importFrom tidygraph unfocus
#' @export
st_filter.sfnetwork = function(x, y, ...) {
+ x = unfocus(x)
active = attr(x, "active")
switch(
active,
nodes = spatial_filter_nodes(x, y, ...),
edges = spatial_filter_edges(x, y, ...),
- raise_unknown_input(active)
+ raise_invalid_active(active)
)
}
-#' @name sf
+#' @name sf_methods
#' @importFrom sf st_filter
#' @export
st_filter.morphed_sfnetwork = function(x, y, ...) {
@@ -521,28 +724,29 @@ spatial_filter_nodes = function(x, y, ...) {
#' @importFrom igraph delete_edges
#' @importFrom sf st_geometry st_filter
spatial_filter_edges = function(x, y, ...) {
- require_explicit_edges(x)
x_sf = edges_as_sf(x)
y_sf = st_geometry(y)
drop = find_indices_to_drop(x_sf, y_sf, ..., .operator = st_filter)
delete_edges(x, drop) %preserve_all_attrs% x
}
-#' @name sf
+#' @name sf_methods
#' @importFrom sf st_crop st_as_sfc
+#' @importFrom tidygraph unfocus
#' @export
st_crop.sfnetwork = function(x, y, ...) {
+ x = unfocus(x)
if (inherits(y, "bbox")) y = st_as_sfc(y)
active = attr(x, "active")
switch(
active,
nodes = spatial_clip_nodes(x, y, ..., .operator = st_crop),
edges = spatial_clip_edges(x, y, ..., .operator = st_crop),
- raise_unknown_input(active)
+ raise_invalid_active(active)
)
}
-#' @name sf
+#' @name sf_methods
#' @importFrom sf st_crop
#' @export
st_crop.morphed_sfnetwork = function(x, y, ...) {
@@ -550,20 +754,22 @@ st_crop.morphed_sfnetwork = function(x, y, ...) {
x
}
-#' @name sf
+#' @name sf_methods
#' @importFrom sf st_difference st_as_sfc
+#' @importFrom tidygraph unfocus
#' @export
st_difference.sfnetwork = function(x, y, ...) {
+ x = unfocus(x)
active = attr(x, "active")
switch(
active,
nodes = spatial_clip_nodes(x, y, ..., .operator = st_difference),
edges = spatial_clip_edges(x, y, ..., .operator = st_difference),
- raise_unknown_input(active)
+ raise_invalid_active(active)
)
}
-#' @name sf
+#' @name sf_methods
#' @importFrom sf st_difference
#' @export
st_difference.morphed_sfnetwork = function(x, y, ...) {
@@ -571,20 +777,22 @@ st_difference.morphed_sfnetwork = function(x, y, ...) {
x
}
-#' @name sf
+#' @name sf_methods
#' @importFrom sf st_intersection st_as_sfc
+#' @importFrom tidygraph unfocus
#' @export
st_intersection.sfnetwork = function(x, y, ...) {
+ x = unfocus(x)
active = attr(x, "active")
switch(
active,
nodes = spatial_clip_nodes(x, y, ..., .operator = st_intersection),
edges = spatial_clip_edges(x, y, ..., .operator = st_intersection),
- raise_unknown_input(active)
+ raise_invalid_active(active)
)
}
-#' @name sf
+#' @name sf_methods
#' @importFrom sf st_intersection
#' @export
st_intersection.morphed_sfnetwork = function(x, y, ...) {
@@ -601,27 +809,16 @@ spatial_clip_nodes = function(x, y, ..., .operator = sf::st_intersection) {
delete_vertices(x, drop) %preserve_all_attrs% x
}
-#' @importFrom dplyr bind_rows
#' @importFrom igraph is_directed
-#' @importFrom sf st_cast st_equals st_geometry st_is st_line_merge st_sf
+#' @importFrom sf st_cast st_geometry st_is st_line_merge
spatial_clip_edges = function(x, y, ..., .operator = sf::st_intersection) {
- require_explicit_edges(x)
+ # For this function edge geometries should follow the from/to column indices.
+ # This is not by default the case in undirected networks.
directed = is_directed(x)
- # Clipping does not work good yet for undirected networks.
- if (!directed) {
- warning(
- "Clipping does not give correct results for undirected networks ",
- "when applied to the edges",
- call. = FALSE
- )
- }
- ## ===========================
- # STEP I: CLIP THE EDGES
- ## ===========================
+ if (! directed) x = make_edges_follow_indices(x)
# Clip the edges using the given operator.
# Possible operators are st_intersection, st_difference and st_crop.
- args = list(edges_as_sf(x), st_geometry(y), ...)
- e_new = do.call(match.fun(.operator), args)
+ e_new = .operator(edges_as_sf(x), st_geometry(y), ...)
# A few issues need to be resolved before moving on.
# 1) An edge shares a single point with the clipper:
# --> The operator includes it as a point in the output.
@@ -658,40 +855,12 @@ spatial_clip_edges = function(x, y, ..., .operator = sf::st_intersection) {
# We bind together all retrieved linestrings.
# This automatically exludes the point objects.
e_new = rbind(e_new_l, e_new_ml)
- ## ===========================
- # STEP I: UPDATE THE NODES
- ## ===========================
- # Just as with any filtering operation on the edges:
- # --> All nodes of the original network will remain in the new network.
- n_orig = nodes_as_sf(x)
# Create a new network with the original nodes and the clipped edges.
- x_tmp = sfnetwork_(n_orig, e_new, directed = directed)
- # Additional processing is required because of the following:
- # --> Edge geometries that cross the border of the clipper are cut.
- # --> Boundaries don't match their corresponding nodes anymore.
- # --> We need to add new nodes at the affected boundaries.
- # --> Otherwise the valid spatial network structure is broken.
- # We proceed as follows:
- # Retrieve the boundaries of the clipped edge geometries.
- bound_pts = edge_boundary_points(x_tmp)
- # Retrieve the nodes at the ends of each edge.
- # According to the from and to indices.
- bound_nds = edge_boundary_nodes(x_tmp)
- # Check if linestring boundaries match their corresponding nodes.
- matches = diag(st_equals(bound_pts, bound_nds, sparse = FALSE))
- # For boundary points that do not match their corresponding node:
- # --> These points will be added as new nodes to the network.
- n_add = list()
- n_add[attr(n_orig, "sf_column")] = list(bound_pts[which(!matches)])
- n_add = st_sf(n_add)
- n_new = bind_rows(n_orig, n_add)
- # Update the node indices of the from and two columns accordingly.
- idxs = edge_boundary_node_indices(x_tmp)
- idxs[!matches] = c((nrow(n_orig) + 1):(nrow(n_orig) + nrow(n_add)))
- e_new$from = idxs[seq(1, length(idxs) - 1, 2)]
- e_new$to = idxs[seq(2, length(idxs), 2)]
- # Create a new network with the updated nodes and edges.
- sfnetwork_(n_new, e_new) %preserve_network_attrs% x
+ x_new = sfnetwork_(nodes_as_sf(x), e_new, directed = directed)
+ # Boundaries of clipped edges may not match their original incident node.
+ # In these cases we will add the affected edge boundary as a new node.
+ # This makes sure the new network has a valid spatial network structure.
+ make_edges_valid(x_new, preserve_geometries = TRUE)
}
find_indices_to_drop = function(x, y, ..., .operator = sf::st_filter) {
@@ -702,7 +871,7 @@ find_indices_to_drop = function(x, y, ..., .operator = sf::st_filter) {
orig_idxs = seq_len(nrow(x))
x$.sfnetwork_index = orig_idxs
# Filter with the given operator.
- filtered = do.call(match.fun(.operator), list(x, y, ...))
+ filtered = .operator(x, y, ...)
# Subset the original network based on the result of the filter operation.
keep = filtered$.sfnetwork_index
drop = if (length(keep) == 0) orig_idxs else orig_idxs[-keep]
@@ -730,7 +899,7 @@ find_indices_to_drop = function(x, y, ..., .operator = sf::st_filter) {
# create specific sfnetwork methods for these functions in order to make them
# work as expected.
-#' @name sf
+#' @name sf_methods
#' @importFrom sf st_geometry st_intersects
#' @export
st_intersects.sfnetwork = function(x, y, ...) {
@@ -741,21 +910,21 @@ st_intersects.sfnetwork = function(x, y, ...) {
}
}
-#' @name sf
+#' @name sf_methods
#' @importFrom sf st_as_sf st_sample
#' @export
st_sample.sfnetwork = function(x, ...) {
st_sample(st_as_sf(x), ...)
}
-#' @name sf
+#' @name sf_methods
#' @importFrom sf st_geometry st_nearest_points
#' @export
st_nearest_points.sfnetwork = function(x, y, ...) {
st_nearest_points(pull_geom(x), st_geometry(y), ...)
}
-#' @name sf
+#' @name sf_methods
#' @importFrom sf st_area
#' @export
st_area.sfnetwork = function(x, ...) {
diff --git a/R/sfnetwork.R b/R/sfnetwork.R
deleted file mode 100644
index 889797de..00000000
--- a/R/sfnetwork.R
+++ /dev/null
@@ -1,487 +0,0 @@
-#' Create a sfnetwork
-#'
-#' \code{sfnetwork} is a tidy data structure for geospatial networks. It
-#' extends the \code{\link[tidygraph]{tbl_graph}} data structure for
-#' relational data into the domain of geospatial networks, with nodes and
-#' edges embedded in geographical space, and offers smooth integration with
-#' \code{\link[sf]{sf}} for spatial data analysis.
-#'
-#' @param nodes The nodes of the network. Should be an object of class
-#' \code{\link[sf]{sf}}, or directly convertible to it using
-#' \code{\link[sf]{st_as_sf}}. All features should have an associated geometry
-#' of type \code{POINT}.
-#'
-#' @param edges The edges of the network. May be an object of class
-#' \code{\link[sf]{sf}}, with all features having an associated geometry of
-#' type \code{LINESTRING}. It may also be a regular \code{\link{data.frame}} or
-#' \code{\link[tibble]{tbl_df}} object. In any case, the nodes at the ends of
-#' each edge must either be encoded in a \code{to} and \code{from} column, as
-#' integers or characters. Integers should refer to the position of a node in
-#' the nodes table, while characters should refer to the name of a node encoded
-#' in the column referred to in the \code{node_key} argument. Setting edges to
-#' \code{NULL} will create a network without edges.
-#'
-#' @param directed Should the constructed network be directed? Defaults to
-#' \code{TRUE}.
-#'
-#' @param node_key The name of the column in the nodes table that character
-#' represented \code{to} and \code{from} columns should be matched against. If
-#' \code{NA}, the first column is always chosen. This setting has no effect if
-#' \code{to} and \code{from} are given as integers. Defaults to \code{'name'}.
-#'
-#' @param edges_as_lines Should the edges be spatially explicit, i.e. have
-#' \code{LINESTRING} geometries stored in a geometry list column? If
-#' \code{NULL}, this will be automatically defined, by setting the argument to
-#' \code{TRUE} when the edges are given as an object of class
-#' \code{\link[sf]{sf}}, and \code{FALSE} otherwise. Defaults to \code{NULL}.
-#'
-#' @param length_as_weight Should the length of the edges be stored in a column
-#' named \code{weight}? If set to \code{TRUE}, this will calculate the length
-#' of the linestring geometry of the edge in the case of spatially explicit
-#' edges, and the straight-line distance between the source and target node in
-#' the case of spatially implicit edges. If there is already a column named
-#' \code{weight}, it will be overwritten. Defaults to \code{FALSE}.
-#'
-#' @param force Should network validity checks be skipped? Defaults to
-#' \code{FALSE}, meaning that network validity checks are executed when
-#' constructing the network. These checks guarantee a valid spatial network
-#' structure. For the nodes, this means that they all should have \code{POINT}
-#' geometries. In the case of spatially explicit edges, it is also checked that
-#' all edges have \code{LINESTRING} geometries, nodes and edges have the same
-#' CRS and boundary points of edges match their corresponding node coordinates.
-#' These checks are important, but also time consuming. If you are already sure
-#' your input data meet the requirements, the checks are unnecessary and can be
-#' turned off to improve performance.
-#'
-#' @param message Should informational messages (those messages that are
-#' neither warnings nor errors) be printed when constructing the network?
-#' Defaults to \code{TRUE}.
-#'
-#' @param ... Arguments passed on to \code{\link[sf]{st_as_sf}}, if nodes need
-#' to be converted into an \code{\link[sf]{sf}} object during construction.
-#'
-#' @return An object of class \code{sfnetwork}.
-#'
-#' @examples
-#' library(sf, quietly = TRUE)
-#'
-#' ## Create sfnetwork from sf objects
-#' p1 = st_point(c(7, 51))
-#' p2 = st_point(c(7, 52))
-#' p3 = st_point(c(8, 52))
-#' nodes = st_as_sf(st_sfc(p1, p2, p3, crs = 4326))
-#'
-#' e1 = st_cast(st_union(p1, p2), "LINESTRING")
-#' e2 = st_cast(st_union(p1, p3), "LINESTRING")
-#' e3 = st_cast(st_union(p3, p2), "LINESTRING")
-#' edges = st_as_sf(st_sfc(e1, e2, e3, crs = 4326))
-#' edges$from = c(1, 1, 3)
-#' edges$to = c(2, 3, 2)
-#'
-#' # Default.
-#' sfnetwork(nodes, edges)
-#'
-#' # Undirected network.
-#' sfnetwork(nodes, edges, directed = FALSE)
-#'
-#' # Using character encoded from and to columns.
-#' nodes$name = c("city", "village", "farm")
-#' edges$from = c("city", "city", "farm")
-#' edges$to = c("village", "farm", "village")
-#' sfnetwork(nodes, edges, node_key = "name")
-#'
-#' # Spatially implicit edges.
-#' sfnetwork(nodes, edges, edges_as_lines = FALSE)
-#'
-#' # Store edge lenghts in a weight column.
-#' sfnetwork(nodes, edges, length_as_weight = TRUE)
-#'
-#' # Adjust the number of features printed by active and inactive components
-#' oldoptions = options(sfn_max_print_active = 1, sfn_max_print_inactive = 2)
-#' sfnetwork(nodes, edges)
-#' options(oldoptions)
-#'
-#' @importFrom sf st_as_sf st_length
-#' @importFrom tidygraph tbl_graph
-#' @export
-sfnetwork = function(nodes, edges = NULL, directed = TRUE, node_key = "name",
- edges_as_lines = NULL, length_as_weight = FALSE,
- force = FALSE, message = TRUE, ...) {
- # Prepare nodes.
- # If nodes is not an sf object:
- # --> Try to convert it to an sf object.
- # --> Arguments passed in ... will be passed on to st_as_sf.
- if (! is.sf(nodes)) {
- nodes = tryCatch(
- st_as_sf(nodes, ...),
- error = function(e) {
- stop(
- "Failed to convert nodes to sf object because: ",
- e,
- call. = FALSE
- )
- }
- )
- }
- # Prepare edges.
- # If edges is an sf object (i.e. edges are spatially explicit):
- # --> Tidygraph cannot handle it due to sticky geometry.
- # --> Therefore it has to be converted into a regular data frame (or tibble).
- edges_are_explicit = is.sf(edges)
- if (edges_are_explicit) {
- edges_df = structure(edges, class = setdiff(class(edges), "sf"))
- if (is.null(edges_as_lines)) edges_as_lines = TRUE
- } else {
- edges_df = edges
- if (is.null(edges_as_lines)) edges_as_lines = FALSE
- }
- # Create network.
- x_tbg = tbl_graph(nodes, edges_df, directed, node_key)
- x_sfn = structure(x_tbg, class = c("sfnetwork", class(x_tbg)))
- # Post-process network. This includes:
- # --> Checking if the network has a valid spatial network structure.
- # --> Making edges spatially explicit or implicit if requested.
- # --> Adding additional attributes if requested.
- if (is.null(edges)) {
- # Run validity check for nodes only and return the network.
- if (! force) require_valid_network_structure(x_sfn, message = message)
- return (x_sfn)
- }
- if (edges_as_lines) {
- # Run validity check before explicitizing edges.
- if (! force) require_valid_network_structure(x_sfn, message = message)
- # Add edge geometries if needed.
- if (edges_are_explicit) {
- # Edges already have geometries, we don't need to add them.
- # We do need to add sf specific attributes to the edges table.
- # These got lost when converting edges to regular data frame.
- edge_geom_colname(x_sfn) = attr(edges, "sf_column")
- edge_agr(x_sfn) = attr(edges, "agr")
- } else {
- # Add linestring geometries between nodes.
- x_sfn = explicitize_edges(x_sfn)
- }
- } else {
- # Remove edge geometries if needed.
- if (edges_are_explicit) {
- x_sfn = implicitize_edges(x_sfn)
- }
- # Run validity check after implicitizing edges.
- if (! force) require_valid_network_structure(x_sfn, message = message)
- }
- if (length_as_weight) {
- edges = edges_as_sf(x_sfn)
- if ("weight" %in% names(edges)) {
- raise_overwrite("weight")
- }
- edges$weight = st_length(edges)
- edge_attribute_values(x_sfn) = edges
- }
- x_sfn
-}
-
-# Simplified construction function.
-# Must be sure that nodes and edges together form a valid sfnetwork.
-# ONLY FOR INTERNAL USE!
-
-#' @importFrom tidygraph tbl_graph
-sfnetwork_ = function(nodes, edges = NULL, directed = TRUE) {
- if (is.sf(edges)) {
- edges_df = structure(edges, class = setdiff(class(edges), "sf"))
- } else {
- edges_df = edges
- }
- x_tbg = tbl_graph(nodes, edges_df, directed)
- if (! is.null(edges)) {
- edge_geom_colname = attr(edges, "sf_column")
- edge_agr = attr(edges, "agr")
- }
- structure(x_tbg, class = c("sfnetwork", class(x_tbg)))
-}
-
-# Fast function to convert from tbl_graph to sfnetwork.
-# Must be sure that tbl_graph has already a valid sfnetwork structure.
-# ONLY FOR INTERNAL USE!
-
-tbg_to_sfn = function(x) {
- class(x) = c("sfnetwork", class(x))
- x
-}
-
-#' Convert a foreign object to a sfnetwork
-#'
-#' Convert a given object into an object of class \code{\link{sfnetwork}}.
-#' If an object can be read by \code{\link[tidygraph]{as_tbl_graph}} and the
-#' nodes can be read by \code{\link[sf]{st_as_sf}}, it is automatically
-#' supported.
-#'
-#' @param x Object to be converted into an \code{\link{sfnetwork}}.
-#'
-#' @param ... Arguments passed on to the \code{\link{sfnetwork}} construction
-#' function.
-#'
-#' @return An object of class \code{\link{sfnetwork}}.
-#'
-#' @export
-as_sfnetwork = function(x, ...) {
- UseMethod("as_sfnetwork")
-}
-
-#' @name as_sfnetwork
-#' @importFrom tidygraph as_tbl_graph
-#' @export
-as_sfnetwork.default = function(x, ...) {
- as_sfnetwork(as_tbl_graph(x), ...)
-}
-
-#' @describeIn as_sfnetwork Only sf objects with either exclusively geometries
-#' of type \code{LINESTRING} or exclusively geometries of type \code{POINT} are
-#' supported. For lines, is assumed that the given features form the edges.
-#' Nodes are created at the endpoints of the lines. Endpoints which are shared
-#' between multiple edges become a single node. For points, it is assumed that
-#' the given features geometries form the nodes. They will be connected by
-#' edges sequentially. Hence, point 1 to point 2, point 2 to point 3, etc.
-#' @examples
-#' # From an sf object.
-#' library(sf, quietly = TRUE)
-#'
-#' # With LINESTRING geometries.
-#' as_sfnetwork(roxel)
-#'
-#' oldpar = par(no.readonly = TRUE)
-#' par(mar = c(1,1,1,1), mfrow = c(1,2))
-#' plot(st_geometry(roxel))
-#' plot(as_sfnetwork(roxel))
-#' par(oldpar)
-#'
-#' # With POINT geometries.
-#' p1 = st_point(c(7, 51))
-#' p2 = st_point(c(7, 52))
-#' p3 = st_point(c(8, 52))
-#' points = st_as_sf(st_sfc(p1, p2, p3))
-#' as_sfnetwork(points)
-#'
-#' oldpar = par(no.readonly = TRUE)
-#' par(mar = c(1,1,1,1), mfrow = c(1,2))
-#' plot(st_geometry(points))
-#' plot(as_sfnetwork(points))
-#' par(oldpar)
-#'
-#' @export
-as_sfnetwork.sf = function(x, ...) {
- if (has_single_geom_type(x, "LINESTRING")) {
- # Workflow:
- # It is assumed that the given LINESTRING geometries form the edges.
- # Nodes need to be created at the boundary points of the edges.
- # Identical boundary points should become the same node.
- n_lst = create_nodes_from_edges(x)
- } else if (has_single_geom_type(x, "POINT")) {
- # Workflow:
- # It is assumed that the given POINT geometries form the nodes.
- # Edges need to be created as linestrings between those nodes.
- # It is assumed that the given nodes are connected sequentially.
- n_lst = create_edges_from_nodes(x)
- } else {
- stop(
- "Geometries are not all of type LINESTRING, or all of type POINT",
- call. = FALSE
- )
- }
- sfnetwork(n_lst$nodes, n_lst$edges, force = TRUE, ...)
-}
-
-#' @name as_sfnetwork
-#' @examples
-#' # From a linnet object.
-#' if (require(spatstat.geom, quietly = TRUE)) {
-#' as_sfnetwork(simplenet)
-#' }
-#'
-#' @export
-as_sfnetwork.linnet = function(x, ...) {
- check_spatstat("spatstat.geom")
- # The easiest approach is the same as for psp objects, i.e. converting the
- # linnet object into a psp format and then applying the corresponding method.
- x_psp = spatstat.geom::as.psp(x)
- as_sfnetwork(x_psp, ...)
-}
-
-#' @name as_sfnetwork
-#' @examples
-#' # From a psp object.
-#' if (require(spatstat.geom, quietly = TRUE)) {
-#' set.seed(42)
-#' test_psp = psp(runif(10), runif(10), runif(10), runif(10), window=owin())
-#' as_sfnetwork(test_psp)
-#' }
-#'
-#' @importFrom sf st_as_sf st_collection_extract
-#' @export
-as_sfnetwork.psp = function(x, ...) {
- check_spatstat_sf()
- # The easiest method for transforming a Line Segment Pattern (psp) object
- # into sfnetwork format is to transform it into sf format and then apply
- # the usual methods.
- x_sf = st_as_sf(x)
- # x_sf is an sf object composed by 1 POLYGON (the window of the psp object)
- # and several LINESTRINGs (the line segments). I'm not sure if and how we can
- # use the window object so I will extract only the LINESTRINGs.
- x_linestring = st_collection_extract(x_sf, "LINESTRING")
- # Apply as_sfnetwork.sf.
- as_sfnetwork(x_linestring, ...)
-}
-
-#' @name as_sfnetwork
-#' @importFrom sf st_as_sf
-#' @export
-as_sfnetwork.sfc = function(x, ...) {
- as_sfnetwork(st_as_sf(x), ...)
-}
-
-#' @name as_sfnetwork
-#' @importFrom igraph is_directed
-#' @export
-as_sfnetwork.sfNetwork = function(x, ...) {
- args = list(...)
- # Retrieve the @sl slot, which contains the linestring of the network.
- args$x = x@sl
- # Define the directed argument automatically if not given, using the @g slot.
- dir_missing = is.null(args$directed)
- args$directed = if (dir_missing) is_directed(x@g) else args$directed
- # Call as_sfnetwork.sf to build the sfnetwork.
- do.call("as_sfnetwork.sf", args)
-}
-
-#' @name as_sfnetwork
-#' @export
-as_sfnetwork.sfnetwork = function(x, ...) {
- as_sfnetwork(as_tbl_graph(x), ...)
-}
-
-#' @name as_sfnetwork
-#' @importFrom igraph is_directed
-#' @export
-as_sfnetwork.tbl_graph = function(x, ...) {
- # Get nodes and edges from the graph and add to the other given arguments.
- args = c(as.list(x), list(...))
- # If no directedness is specified, use the directedness from the tbl_graph.
- dir_missing = is.null(args$directed)
- args$directed = if (dir_missing) is_directed(x) else args$directed
- # Call the sfnetwork construction function.
- do.call("sfnetwork", args)
-}
-
-#' @importFrom igraph ecount vcount
-#' @importFrom sf st_crs
-#' @importFrom tibble as_tibble
-#' @importFrom tidygraph as_tbl_graph
-#' @export
-print.sfnetwork = function(x, ...) {
- # Define active and inactive component.
- active = attr(x, "active")
- inactive = if (active == "nodes") "edges" else "nodes"
- # Count number of nodes and edges in the network.
- nN = vcount(x) # Number of nodes in network.
- nE = ecount(x) # Number of edges in network.
- # Print header.
- cat_subtle(c("# A sfnetwork with", nN, "nodes and", nE, "edges\n"))
- cat_subtle("#\n")
- cat_subtle(c("# CRS: ", st_crs(x)$input, "\n"))
- precision = st_precision(x)
- if (precision != 0.0) {
- cat_subtle(c("# Precision: ", precision, "\n"))
- }
- cat_subtle("#\n")
- cat_subtle("#", describe_graph(as_tbl_graph(x)))
- if (has_explicit_edges(x)) {
- cat_subtle(" with spatially explicit edges\n")
- } else {
- cat_subtle(" with spatially implicit edges\n")
- }
- cat_subtle("#\n")
- # Print active data summary.
- active_data = summarise_network_element(
- data = as_tibble(x, active),
- name = substr(active, 1, 4),
- active = TRUE,
- ...
- )
- print(active_data)
- cat_subtle("#\n")
- # Print inactive data summary.
- inactive_data = summarise_network_element(
- data = as_tibble(x, inactive),
- name = substr(inactive, 1, 4),
- active = FALSE,
- ...
- )
- print(inactive_data)
- invisible(x)
-}
-
-#' @importFrom sf st_geometry
-#' @importFrom tibble trunc_mat
-#' @importFrom tools toTitleCase
-#' @importFrom utils modifyList
-summarise_network_element = function(data, name, active = TRUE,
- n_active = getOption("sfn_max_print_active", 6L),
- n_inactive = getOption("sfn_max_print_inactive", 3L),
- ...
- ) {
- # Capture ... arguments.
- args = list(...)
- # Truncate data.
- n = if (active) n_active else n_inactive
- x = do.call(trunc_mat, modifyList(args, list(x = data, n = n)))
- # Write summary.
- x$summary[1] = paste(x$summary[1], if (active) "(active)" else "")
- if (!has_sfc(data) || nrow(data) == 0) {
- names(x$summary)[1] = toTitleCase(paste(name, "data"))
- } else {
- geom = st_geometry(data)
- x$summary[2] = substr(class(geom)[1], 5, nchar(class(geom)[1]))
- x$summary[3] = class(geom[[1]])[1]
- bb = signif(attr(geom, "bbox"), options("digits")$digits)
- x$summary[4] = paste(paste(names(bb), bb[], sep = ": "), collapse = " ")
- names(x$summary) = c(
- toTitleCase(paste(name, "data")),
- "Geometry type",
- "Dimension",
- "Bounding box"
- )
- }
- x
-}
-
-#' @importFrom sf st_crs
-#' @importFrom utils capture.output
-#' @export
-print.morphed_sfnetwork = function(x, ...) {
- x_tbg = structure(x, class = setdiff(class(x), "morphed_sfnetwork"))
- out = capture.output(print(x_tbg), ...)
- cat_subtle(gsub("tbl_graph", "sfnetwork", out[[1]]), "\n")
- cat_subtle(out[[2]], "\n")
- cat_subtle(out[[3]], "\n")
- cat_subtle(out[[4]], "\n")
- cat_subtle("# with CRS", st_crs(attr(x, ".orig_graph"))$input, "\n")
- invisible(x)
-}
-
-#' Check if an object is a sfnetwork
-#'
-#' @param x Object to be checked.
-#'
-#' @return \code{TRUE} if the given object is an object of class
-#' \code{\link{sfnetwork}}, \code{FALSE} otherwise.
-#'
-#' @examples
-#' library(tidygraph, quietly = TRUE, warn.conflicts = FALSE)
-#'
-#' net = as_sfnetwork(roxel)
-#' is.sfnetwork(net)
-#' is.sfnetwork(as_tbl_graph(net))
-#'
-#' @export
-is.sfnetwork = function(x) {
- inherits(x, "sfnetwork")
-}
diff --git a/R/simplify.R b/R/simplify.R
new file mode 100644
index 00000000..794890d2
--- /dev/null
+++ b/R/simplify.R
@@ -0,0 +1,94 @@
+#' Simplify a spatial network
+#'
+#' Construct a simple version of the network. A simple network is defined as a
+#' network without loop edges and multiple edges. A loop edge is an edge that
+#' starts and ends at the same node. Multiple edges are different edges between
+#' the same node pair.
+#'
+#' @param x An object of class \code{\link{sfnetwork}}.
+#'
+#' @param remove_multiple Should multiple edges be merged into one. Defaults
+#' to \code{TRUE}.
+#'
+#' @param remove_loops Should loop edges be removed. Defaults to \code{TRUE}.
+#'
+#' @param attribute_summary How should the attributes of merged multiple
+#' edges be summarized? There are several options, see
+#' \code{\link[igraph]{igraph-attribute-combination}} for details.
+#'
+#' @param store_original_ids For each group of merged multiple edges, should
+#' the indices of the original edges be stored as an attribute of the new edge,
+#' in a column named \code{.tidygraph_edge_index}? This is in line with the
+#' design principles of \code{tidygraph}. Defaults to \code{FALSE}.
+#'
+#' @param store_original_data For each group of merged multiple edges, should
+#' the data of the original edges be stored as an attribute of the new edge, in
+#' a column named \code{.orig_data}? This is in line with the design principles
+#' of \code{tidygraph}. Defaults to \code{FALSE}.
+#'
+#' @note When merging groups of multiple edges into a single edge, the geometry
+#' of the first edge in each group is preserved. The order of the edges can be
+#' influenced by calling \code{\link[dplyr]{arrange}} before simplifying.
+#'
+#' @returns The simple network as object of class \code{\link{sfnetwork}}.
+#'
+#' @importFrom igraph simplify
+#' @importFrom sf st_as_sf st_crs st_crs<- st_precision st_precision<- st_sfc
+#' @export
+simplify_network = function(x, remove_multiple = TRUE, remove_loops = TRUE,
+ attribute_summary = "first",
+ store_original_ids = FALSE,
+ store_original_data = FALSE) {
+ # Add index columns if not present.
+ # These keep track of original node and edge indices.
+ x = add_original_ids(x)
+ ## ==================================================
+ # STEP I: REMOVE LOOP EDGES AND MERGE MULTIPLE EDGES
+ # For this we simply rely on igraphs simplify function
+ ## ==================================================
+ # Update the attribute summary instructions.
+ # In the summarise attributes only real attribute columns were referenced.
+ # On top of those, we need to include:
+ # --> The geometry column, if present.
+ # --> The tidygraph edge index column.
+ if (! inherits(attribute_summary, "list")) {
+ attribute_summary = list(attribute_summary)
+ }
+ edge_geomcol = edge_geom_colname(x)
+ if (! is.null(edge_geomcol)) attribute_summary[edge_geomcol] = "first"
+ attribute_summary[".tidygraph_edge_index"] = "concat"
+ # Simplify the network.
+ x_new = simplify(
+ x,
+ remove.multiple = remove_multiple,
+ remove.loops = remove_loops,
+ edge.attr.comb = attribute_summary
+ ) %preserve_all_attrs% x
+ ## ====================================
+ # STEP II: RECONSTRUCT EDGE GEOMETRIES
+ # Igraph does not know about geometry list columns:
+ # --> Summarizing them results in a list of sfg objects.
+ # --> We should reconstruct the sfc geometry list column out of that.
+ ## ====================================
+ if (! is.null(edge_geomcol)) {
+ new_edges = edges_as_regular_tibble(x_new)
+ new_edges[edge_geomcol] = list(st_sfc(new_edges[[edge_geomcol]]))
+ new_edges = st_as_sf(new_edges, sf_column_name = edge_geomcol)
+ st_crs(new_edges) = st_crs(x)
+ st_precision(new_edges) = st_precision(x)
+ st_agr(new_edges) = edge_agr(x)
+ edge_data(x_new) = new_edges
+ }
+ ## ==================================
+ # STEP III: POST-PROCESS AND RETURN
+ ## ==================================
+ # Store original data if requested.
+ if (store_original_data) {
+ x_new = add_original_edge_data(x_new, orig = edge_data(x, focused = FALSE))
+ }
+ # Remove original indices if requested.
+ if (! store_original_ids) {
+ x_new = drop_original_ids(x_new)
+ }
+ x_new
+}
\ No newline at end of file
diff --git a/R/smooth.R b/R/smooth.R
new file mode 100644
index 00000000..c4596c18
--- /dev/null
+++ b/R/smooth.R
@@ -0,0 +1,350 @@
+#' Smooth pseudo nodes
+#'
+#' Construct a smoothed version of the network by iteratively removing pseudo
+#' nodes, while preserving the connectivity of the network. In the case of
+#' directed networks, pseudo nodes are those nodes that have only one incoming
+#' and one outgoing edge. In undirected networks, pseudo nodes are those nodes
+#' that have two incident edges. Equality of attribute values among the two
+#' edges can be defined as an additional requirement by setting the
+#' \code{require_equal} parameter. Connectivity of the network is preserved by
+#' concatenating the incident edges of each removed pseudo node.
+#'
+#' @param x An object of class \code{\link{sfnetwork}}.
+#'
+#' @param protect An integer vector of edge indices specifying which nodes
+#' should be protected from being removed. Defaults to \code{NULL}, meaning
+#' that none of the nodes is protected.
+#'
+#' @param require_equal A character vector of edge column names specifying
+#' which attributes of the incident edges of a pseudo node should be equal in
+#' order for the pseudo node to be removed? Defaults to \code{NULL}, meaning
+#' that attribute equality is not considered for pseudo node removal.
+#'
+#' @param attribute_summary How should the attributes of concatenated edges
+#' be summarized? There are several options, see
+#' \code{\link[igraph]{igraph-attribute-combination}} for details.
+#'
+#' @param store_original_ids For each concatenated edge, should the indices of
+#' the original edges be stored as an attribute of the new edge, in a column
+#' named \code{.tidygraph_edge_index}? This is in line with the design
+#' principles of \code{tidygraph}. Defaults to \code{FALSE}.
+#'
+#' @param store_original_data For each concatenated edge, should the data of
+#' the original edges be stored as an attribute of the new edge, in a column
+#' named \code{.orig_data}? This is in line with the design principles of
+#' \code{tidygraph}. Defaults to \code{FALSE}.
+#'
+#' @returns The smoothed network as object of class \code{\link{sfnetwork}}.
+#'
+#' @importFrom cli cli_abort
+#' @importFrom dplyr distinct slice
+#' @importFrom igraph adjacent_vertices decompose degree delete_vertices
+#' edge_attr get_edge_ids igraph_opt igraph_options incident_edges
+#' induced_subgraph is_directed vertex_attr
+#' @importFrom sf st_as_sf st_cast st_combine st_crs st_drop_geometry
+#' st_equals st_is st_line_merge
+#' @export
+smooth_pseudo_nodes = function(x, protect = NULL,
+ require_equal = NULL,
+ attribute_summary = "ignore",
+ store_original_ids = FALSE,
+ store_original_data = FALSE) {
+ # Change default igraph options.
+ # This prevents igraph returns node or edge indices as formatted sequences.
+ # We only need the "raw" integer indices.
+ # Changing this option improves performance especially on large networks.
+ default_igraph_opt = igraph_opt("return.vs.es")
+ igraph_options(return.vs.es = FALSE)
+ on.exit(igraph_options(return.vs.es = default_igraph_opt))
+ # Add index columns if not present.
+ # These keep track of original node and edge indices.
+ x = add_original_ids(x)
+ # Retrieve nodes and edges from the network.
+ nodes = nodes_as_sf(x)
+ edges = edge_data(x, focused = FALSE)
+ # For later use:
+ # --> Check if x is directed.
+ # --> Check if x has spatially explicit edges.
+ # --> Retrieve the name of the geometry column of the edges in x.
+ directed = is_directed(x)
+ explicit_edges = is_sf(edges)
+ edge_geomcol = attr(edges, "sf_column")
+ ## ==========================
+ # STEP I: DETECT PSEUDO NODES
+ # The first step is to detect which nodes in x are pseudo nodes.
+ # In directed networks, we define a pseudo node as follows:
+ # --> A node with only one incoming and one outgoing edge.
+ # In undirected networks, we define a pseudo node as follows:
+ # --> A node with only two connections.
+ ## ==========================
+ pseudo = is_pseudo_node(x)
+ # Detected pseudo nodes that are protected should be filtered out.
+ if (! is.null(protect)) {
+ pseudo[protect] = FALSE
+ }
+ # Check for equality of certain attributes between incident edges.
+ # Detected pseudo nodes that fail this check should be filtered out.
+ if (! is.null(require_equal)) {
+ pseudo_ids = which(pseudo)
+ edge_attrs = st_drop_geometry(edges)
+ edge_attrs = edge_attrs[, names(edge_attrs) %in% require_equal]
+ incident_ids = incident_edges(x, pseudo_ids, mode = "all")
+ check_equality = function(i) nrow(distinct(slice(edge_attrs, i + 1))) < 2
+ pass = do.call("c", lapply(incident_ids, check_equality))
+ pseudo[pseudo_ids[!pass]] = FALSE
+ }
+ # If there are no pseudo nodes left:
+ # --> We do not have to smooth anything.
+ if (! any(pseudo)) {
+ # Store original edge data in a .orig_data column if requested.
+ if (store_original_data) {
+ x = add_original_edge_data(x, edges)
+ }
+ # Remove original indices if requested.
+ if (! store_original_ids) {
+ x = drop_original_ids(x)
+ }
+ # Return x without smoothing.
+ return(x)
+ }
+ ## ====================================
+ # STEP II: INITIALIZE REPLACEMENT EDGES
+ # When removing pseudo nodes their incident edges get removed to.
+ # To preserve the network connectivity we need to:
+ # --> Find the two adjacent nodes of a pseudo node.
+ # --> Connect these by merging the incident edges of the pseudo node.
+ # An adjacent node of a pseudo node can also be another pseudo node.
+ # Instead of processing each pseudo node on its own, we will:
+ # --> Find connected sets of pseudo nodes.
+ # --> Find the adjacent non-pseudo nodes (junction or pendant) to that set.
+ # --> Connect them by merging the edges in the set plus its incident edges.
+ ## ====================================
+ # Subset x to only contain pseudo nodes and the edges between them.
+ # Decompose this subgraph to find connected sets of pseudo nodes.
+ pseudo_sets = decompose(induced_subgraph(x, pseudo))
+ # For each set of connected pseudo nodes:
+ # --> Find the indices of the adjacent nodes.
+ # --> Find the indices of the edges that need to be merged.
+ # The workflow for this is different for directed and undirected networks.
+ if (directed) {
+ initialize_replacement_edge = function(S) {
+ # Retrieve the original node indices of the pseudo nodes in this set.
+ # Retrieve the original edge indices of the edges that connect them.
+ N = vertex_attr(S, ".tidygraph_node_index")
+ E = edge_attr(S, ".tidygraph_edge_index")
+ # Find the following:
+ # --> The index of the pseudo node where an edge comes into the set.
+ # --> The index of the pseudo node where an edge goes out of the set.
+ n_i = N[degree(S, mode = "in") == 0]
+ n_o = N[degree(S, mode = "out") == 0]
+ # If these nodes do not exists:
+ # --> We are dealing with a loop of connected pseudo nodes.
+ # --> The loop is by definition not connected to the rest of the network.
+ # --> Hence, there is no need to create a new edge.
+ # --> Therefore we should not return a path.
+ if (length(n_i) == 0) return (NULL)
+ # Find the following:
+ # --> The index of the edge that comes in to the pseudo node set.
+ # --> The index of the non-pseudo node at the other end of that edge.
+ # We'll call this the source node and source edge of the set.
+ # Note the + 1 since adjacent_vertices returns indices starting from 0.
+ source_node = adjacent_vertices(x, n_i, mode = "in")[[1]] + 1
+ source_edge = get_edge_ids(x, c(source_node, n_i))
+ # Find the following:
+ # --> The index of the edge that goes out of the pseudo node set.
+ # --> The index of the non-pseudo node at the other end of that edge.
+ # We'll call this the sink node and sink edge of the set.
+ # Note the + 1 since adjacent_vertices returns indices starting from 0.
+ sink_node = adjacent_vertices(x, n_o, mode = "out")[[1]] + 1
+ sink_edge = get_edge_ids(x, c(n_o, sink_node))
+ # List indices of all edges that will be merged into the replacement edge.
+ edge_idxs = c(source_edge, E, sink_edge)
+ # Return all retrieved information in a list.
+ list(
+ from = as.integer(source_node),
+ to = as.integer(sink_node),
+ .tidygraph_edge_index = as.integer(edge_idxs)
+ )
+ }
+ } else {
+ initialize_replacement_edge = function(S) {
+ # Retrieve the original node indices of the pseudo nodes in this set.
+ # Retrieve the original edge indices of the edges that connect them.
+ N = vertex_attr(S, ".tidygraph_node_index")
+ E = edge_attr(S, ".tidygraph_edge_index")
+ # Find the following:
+ # --> The two adjacent non-pseudo nodes to the set.
+ # --> The edges that connect these nodes to the set.
+ # We'll call these the adjacent nodes and incident edges of the set.
+ # --> The adjacent node with the lowest index will be the source node.
+ # --> The adjacent node with the higest index will be the sink node.
+ if (length(N) == 1) {
+ # When we have a single pseudo node that forms a set:
+ # --> It will be adjacent to both adjacent nodes of the set.
+ # Note the + 1 since adjacent_vertices returns indices starting from 0.
+ adjacent = adjacent_vertices(x, N)[[1]] + 1
+ if (length(adjacent) == 1) {
+ # If there is only one adjacent node to the pseudo node:
+ # --> The two adjacent nodes of the set are the same node.
+ # --> We only have to query for incident edges of the set once.
+ incident = get_edge_ids(x, c(adjacent, N))
+ source_node = adjacent
+ source_edge = incident[1]
+ sink_node = adjacent
+ sink_edge = incident[2]
+ } else {
+ # If there are two adjacent nodes to the pseudo node:
+ # --> The one with the lowest index will be source node.
+ # --> The one with the highest index will be sink node.
+ source_node = min(adjacent)
+ source_edge = get_edge_ids(x, c(source_node, N))
+ sink_node = max(adjacent)
+ sink_edge = get_edge_ids(x, c(N, sink_node))
+ }
+ } else {
+ # When we have a set of multiple pseudo nodes:
+ # --> There are two pseudo nodes that form the boundary of the set.
+ # --> These are the ones connected to only one other pseudo node.
+ N_b = N[degree(S) == 1]
+ # If these boundaries do not exist:
+ # --> We are dealing with a loop of connected pseudo nodes.
+ # --> The loop is by definition not connected to the rest of the network.
+ # --> Hence, there is no need to create a new edge.
+ # --> Therefore we should not return a path.
+ if (length(N_b) == 0) return (NULL)
+ # Find the adjacent nodes of the set.
+ # These are the adjacent non-pseudo nodes to the boundaries of the set.
+ # We find them iteratively for the two boundary nodes of the set:
+ # --> A boundary connects to one pseudo node and one non-pseudo node.
+ # --> The non-pseudo node is the one not present in the pseudo set.
+ # Note the + 1 since adjacent_vertices returns indices starting from 0.
+ get_set_neighbour = function(n) {
+ all = adjacent_vertices(x, n)[[1]] + 1
+ all[!(all %in% N)]
+ }
+ adjacent = do.call("c", lapply(N_b, get_set_neighbour))
+ # The adjacent node with the lowest index will be source node.
+ # The adjacent node with the highest index will be sink node.
+ N_b = N_b[order(adjacent)]
+ source_node = min(adjacent)
+ source_edge = get_edge_ids(x, c(source_node, N_b[1]))
+ sink_node = max(adjacent)
+ sink_edge = get_edge_ids(x, c(N_b[2], sink_node))
+ }
+ # List indices of all edges that will be merged into the replacement edge.
+ edge_idxs = c(source_edge, E, sink_edge)
+ # Return all retrieved information in a list.
+ list(
+ from = as.integer(source_node),
+ to = as.integer(sink_node),
+ .tidygraph_edge_index = as.integer(edge_idxs)
+ )
+ }
+ }
+ new_idxs = lapply(pseudo_sets, initialize_replacement_edge)
+ new_idxs = new_idxs[lengths(new_idxs) != 0] # Remove NULLs.
+ ## ===================================
+ # STEP III: SUMMARISE EDGE ATTRIBUTES
+ # Each replacement edge replaces multiple original edges.
+ # Their attributes should all be summarised in a single value.
+ # The summary techniques to be used are given as attribute_summary.
+ ## ===================================
+ # Obtain the attribute values of all original edges in the network.
+ # These should not include the geometries and original edge indices.
+ exclude = c(".tidygraph_edge_index", edge_geomcol)
+ edge_attrs = edge_attr(x)
+ edge_attrs = edge_attrs[!(names(edge_attrs) %in% exclude)]
+ # For each replacement edge:
+ # --> Summarise the attributes of the edges it replaces into single values.
+ merge_attrs = function(E) {
+ ids = E$.tidygraph_edge_index
+ summarize_attributes(edge_attrs, attribute_summary, subset = ids)
+ }
+ new_attrs = lapply(new_idxs, merge_attrs)
+ ## ===================================
+ # STEP VI: CONCATENATE EDGE GEOMETRIES
+ # If the edges to be replaced have geometries:
+ # --> These geometries have to be concatenated into a single new geometry.
+ # --> The new geometry should go from the defined source to sink node.
+ ## ===================================
+ if (explicit_edges) {
+ # Obtain geometries of all original edges and nodes in the network.
+ edge_geoms = st_geometry(edges)
+ node_geoms = st_geometry(nodes)
+ # For each replacement edge:
+ # --> Merge geometries of the edges it replaces into a single geometry.
+ merge_geoms = function(E) {
+ orig_edges = E$.tidygraph_edge_index
+ orig_geoms = edge_geoms[orig_edges]
+ new_geom = st_line_merge(st_combine(orig_geoms))
+ # There are two situations where merging lines like this is problematic.
+ # 1. When the source and sink node of the new edge are the same.
+ # --> In this case the original edges to be replaced form a closed loop.
+ # --> Any original endpoint can then be the startpoint of the new edge.
+ # --> st_line_merge chooses the point with the lowest x coordinate.
+ # --> This is not necessarily the source node we defined.
+ # --> This behaviour comes from third partly libs and can not be tuned.
+ # --> Hence, we manually need to reorder the points in the merged line.
+ if (E$from == E$to && length(orig_edges) > 1) {
+ pts = st_cast(new_geom, "POINT")
+ from_idx = st_equals(node_geoms[E$from], pts)[[1]]
+ if (length(from_idx) == 1) {
+ n = length(pts)
+ ordered_pts = c(pts[c(from_idx:n)], pts[c(2:from_idx)])
+ new_geom = st_cast(st_combine(ordered_pts), "LINESTRING")
+ }
+ }
+ # 2. When the new edge crosses itself.
+ # --> In this case st_line_merge creates a multilinestring geometry.
+ # --> We just want a regular linestring (even if this is invalid).
+ if (any(st_is(new_geom, "MULTILINESTRING"))) {
+ new_geom = force_multilinestrings_to_linestrings(new_geom)
+ }
+ new_geom
+ }
+ new_geoms = do.call("c", lapply(new_idxs, merge_geoms))
+ }
+ ## ============================================
+ # STEP V: ADD REPLACEMENT EDGES TO THE NETWORK
+ # The newly created edges should be added to the original network.
+ # This must happen before removing the pseudo nodes.
+ # Otherwise their from and to values do not match the correct node indices.
+ ## ============================================
+ # Create the data frame for the new edges.
+ new_edges = cbind(
+ data.frame(do.call("rbind", new_idxs)),
+ data.frame(do.call("rbind", new_attrs))
+ )
+ # Bind together with the original edges.
+ # Merged edges may have list-columns for some attributes.
+ # This requires a bit more complicated rowbinding.
+ if (explicit_edges) {
+ new_edges[edge_geomcol] = list(new_geoms)
+ all_edges = bind_rows_list(edges, new_edges)
+ all_edges = st_as_sf(all_edges, sf_column_name = edge_geomcol)
+ } else {
+ all_edges = bind_rows_list(edges, new_edges)
+ }
+ # Recreate an sfnetwork.
+ x_new = sfnetwork_(nodes, all_edges, directed = directed)
+ ## ============================================
+ # STEP VI: REMOVE PSEUDO NODES FROM THE NETWORK
+ # Remove all the detected pseudo nodes from the original network.
+ # This will automatically also remove their incident edges.
+ # Remember that their replacement edges have already been added in step IV.
+ # From and to indices will be updated automatically.
+ ## ============================================
+ x_new = delete_vertices(x_new, pseudo) %preserve_all_attrs% x
+ ## =================================
+ # STEP VII: POST-PROCESS AND RETURN
+ ## =================================
+ # Store original data if requested.
+ if (store_original_data) {
+ x_new = add_original_edge_data(x_new, edges)
+ }
+ # Remove original indices if requested.
+ if (! store_original_ids) {
+ x_new = drop_original_ids(x_new)
+ }
+ x_new
+}
diff --git a/R/spatstat.R b/R/spatstat.R
index bee92db2..f07718f4 100644
--- a/R/spatstat.R
+++ b/R/spatstat.R
@@ -1,53 +1,10 @@
-# Auxiliary function which is used to test that:
-# --> The relevant spatstat packages are installed.
-# --> The spatstat version is 2.0.0 or greater.
-# For details, see:
-# --> https://github.com/rubak/spatstat.revdep/blob/main/README.md
-# --> https://github.com/luukvdmeer/sfnetworks/issues/137
-#' @importFrom utils packageVersion
-check_spatstat = function(pkg) {
- if (!requireNamespace(pkg, quietly = TRUE)) {
- stop(
- "Package ",
- pkg,
- "required; please install it (or the full spatstat package) first",
- call. = FALSE
- )
- } else {
- spst_ver = try(packageVersion("spatstat"), silent = TRUE)
- if (!inherits(spst_ver, "try-error") && spst_ver < "2.0-0") {
- stop(
- "You have an old version of spatstat which is incompatible with ",
- pkg,
- "; please update spatstat (or uninstall it)",
- call. = FALSE
- )
- }
- }
- check_spatstat_sf()
-}
-
-# Auxiliary function which is used to test that:
-# --> The sf version is compatible with the new spatstat structure
-# For details, see:
-# --> https://github.com/luukvdmeer/sfnetworks/pull/138#issuecomment-803430686
-#' @importFrom utils packageVersion
-check_spatstat_sf = function() {
- if (packageVersion("sf") < "0.9.8") {
- stop(
- "spatstat code requires sf >= 0.9.8; please update sf",
- call. = FALSE
- )
- }
-}
-
#' Convert a sfnetwork into a linnet
#'
#' A method to convert an object of class \code{\link{sfnetwork}} into
#' \code{\link[spatstat.linnet]{linnet}} format and enhance the
#' interoperability between \code{sfnetworks} and \code{spatstat}. Use
#' this method without the .sfnetwork suffix and after loading the
-#' \code{spatstat} package.
+#' \pkg{spatstat} package.
#'
#' @param X An object of class \code{\link{sfnetwork}} with a projected CRS.
#'
@@ -59,21 +16,18 @@ check_spatstat_sf = function() {
#' \code{\link[spatstat.linnet]{linnet}} into objects of class
#' \code{\link{sfnetwork}}.
#'
+#' @importFrom rlang check_installed is_installed
#' @name as.linnet
as.linnet.sfnetwork = function(X, ...) {
# Check the presence and the version of spatstat.geom and spatstat.linnet
- check_spatstat("spatstat.geom")
- check_spatstat("spatstat.linnet")
- # Extract the vertices of the sfnetwork.
- X_vertices_ppp = spatstat.geom::as.ppp(pull_node_geom(X))
+ check_installed("spatstat.geom")
+ check_installed("spatstat.linnet")
+ check_installed("sf (>= 1.0)")
+ if (is_installed("spatstat")) check_installed("spatstat (>= 2.0)")
+ # Convert nodes to ppp.
+ V = spatstat.geom::as.ppp(pull_node_geom(X))
# Extract the edge list.
- X_edge_list = as.matrix(
- (as.data.frame(activate(X, "edges")))[, c("from", "to")]
- )
+ E = as.matrix(edges_as_regular_tibble(X)[, c("from", "to")])
# Build linnet.
- spatstat.linnet::linnet(
- vertices = X_vertices_ppp,
- edges = X_edge_list,
- ...
- )
+ spatstat.linnet::linnet(vertices = V, edges = E, ...)
}
diff --git a/R/subdivide.R b/R/subdivide.R
new file mode 100644
index 00000000..65d4b0bb
--- /dev/null
+++ b/R/subdivide.R
@@ -0,0 +1,233 @@
+#' Subdivide edges at interior points
+#'
+#' Construct a subdivision of the network by subdividing edges at interior
+#' points. Subdividing means that a new node is added on an edge, and the edge
+#' is split in two at that location. Interior points are those points that
+#' shape a linestring geometry feature but are not endpoints of it.
+#'
+#' @param x An object of class \code{\link{sfnetwork}} with spatially explicit
+#' edges.
+#'
+#' @param protect An integer vector of edge indices specifying which edges
+#' should be protected from being subdivided. Defaults to \code{NULL}, meaning
+#' that none of the edges is protected.
+#'
+#' @param all Should edges be subdivided at all their interior points? If set
+#' to \code{FALSE}, edges are only subdivided at those interior points that
+#' share their location with any other interior or boundary point (a node) in
+#' the edges table. Defaults to \code{FALSE}.
+#'
+#' @param merge Should multiple subdivision points at the same location be
+#' merged into a single node, and should subdivision points at the same
+#' location as an existing node be merged into that node? Defaults to
+#' \code{TRUE}. If set to \code{FALSE}, each subdivision point is added
+#' separately as a new node to the network.
+#'
+#' @note By default sfnetworks rounds coordinates to 12 decimal places to
+#' determine spatial equality. You can influence this behavior by explicitly
+#' setting the precision of the network using
+#' \code{\link[sf]{st_set_precision}}.
+#'
+#' @returns The subdivision of x as object of class \code{\link{sfnetwork}}.
+#'
+#' @importFrom dplyr arrange bind_rows
+#' @importFrom igraph is_directed
+#' @importFrom sf st_geometry<-
+#' @importFrom sfheaders sf_to_df
+#' @export
+subdivide_edges = function(x, protect = NULL, all = FALSE, merge = TRUE) {
+ if (will_assume_constant(x)) raise_assume_constant("subdivide_edges")
+ nodes = nodes_as_sf(x)
+ edges = edges_as_sf(x)
+ ## ===========================
+ # STEP I: DECOMPOSE THE EDGES
+ # Decompose the edges linestring geometries into the points that shape them.
+ ## ===========================
+ # Decompose edge linestrings into points.
+ edge_pts = sf_to_df(edges)
+ # Define the total number of edge points.
+ n = nrow(edge_pts)
+ # Store additional information for each edge point.
+ edge_pts$pid = seq_len(n) # Unique id for each edge point.
+ edge_pts$eid = edge_pts$linestring_id # Edge index for each edge point.
+ # Clean up.
+ edge_pts$sfg_id = NULL
+ edge_pts$linestring_id = NULL
+ ## =======================================
+ # STEP II: DEFINE WHERE TO SUBDIVIDE EDGES
+ # If all = TRUE, edges should be split at all interior points.
+ # Otherwise, edges should be split only at those interior points that:
+ # --> Are equal to a boundary point in another edge.
+ # --> Are equal to an interior point in another edge.
+ ## =======================================
+ # Define which edge points are boundaries.
+ is_startpoint = !duplicated(edge_pts$eid)
+ is_endpoint = !duplicated(edge_pts$eid, fromLast = TRUE)
+ is_boundary = is_startpoint | is_endpoint
+ # Store for each edge point the node index, if it is a boundary.
+ edge_nids = rep(NA, n)
+ edge_nids[is_boundary] = edge_incident_ids(x)
+ edge_pts$nid = edge_nids
+ # Compute for each edge point a unique location index.
+ # Edge points that are spatially equal get the same location index.
+ # Note that this is only needed if:
+ # --> Only shared interior points should be split.
+ # --> Shared interior points should be merged into a single node afterwards.
+ edge_coords = edge_pts[names(edge_pts) %in% c("x", "y", "z", "m")]
+ if (merge | !all) {
+ edge_lids = st_match_points_df(edge_coords, network_precision(x))
+ edge_pts$lid = edge_lids
+ }
+ # Define which edges to protect from being subdivided.
+ is_protected = rep(FALSE, nrow(edge_pts))
+ if (! is.null(protect)) {
+ is_protected[edge_pts$eid %in% protect] = TRUE
+ }
+ # Define the subdivision points.
+ if (all) {
+ is_split = !is_boundary & !is_protected
+ } else {
+ has_duplicate_desc = duplicated(edge_lids)
+ has_duplicate_asc = duplicated(edge_lids, fromLast = TRUE)
+ has_duplicate = has_duplicate_desc | has_duplicate_asc
+ is_split = has_duplicate & !is_boundary & !is_protected
+ }
+ ## ==========================================
+ # STEP III: CONSTRUCT THE NEW EDGE GEOMETRIES
+ # First we duplicate each split point.
+ # They become the endpoint of one edge *and* the startpoint of another.
+ # With those extended edge points we need to recreate linestrings.
+ # First we define for each edge point the new edge index.
+ # Then we need to build a linestring geometry for each new edge.
+ ## ==========================================
+ # Create the new set of edge points by duplicating split points.
+ new_edge_pts = create_new_edge_df(edge_pts, is_split)
+ # Define the new edge index of each new edge point.
+ new_edge_ids = create_new_edge_ids(new_edge_pts, is_split)
+ # Construct the new edge linestring geometries.
+ new_edge_geoms = create_new_edge_geoms(new_edge_pts, new_edge_ids, edges)
+ ## ===================================
+ # STEP IV: CONSTRUCT THE NEW EDGE DATA
+ # We now have the geometries of the new edges.
+ # However, the original edge attributes got lost.
+ # We will restore them by:
+ # --> Adding back the attributes to edges that were not split.
+ # --> Duplicating original attributes within splitted edges.
+ # Beware that from and to columns will remain unchanged at this stage.
+ # We will update them later.
+ ## ===================================
+ # Define at which new edge points a new edge starts and ends.
+ is_new_startpoint = !duplicated(new_edge_ids)
+ is_new_endpoint = !duplicated(new_edge_ids, fromLast = TRUE)
+ # Use the original edge ids of the startpoints to copy original attributes.
+ new_edges = edges[new_edge_pts$eid[is_new_startpoint], ]
+ # Insert the newly constructed edge geometries.
+ st_geometry(new_edges) = new_edge_geoms
+ ## ===================================
+ # STEP V: CONSTRUCT THE NEW NODE DATA
+ # New nodes are added at the split points.
+ # Depending on settings these may be merged with nodes at the same location.
+ # The nodes that are added should get a valid node index.
+ ## ===================================
+ # Identify and select the edge points that become a node in the new network.
+ is_new_node = is_new_startpoint | is_new_endpoint
+ new_node_pts = new_edge_pts[is_new_node, ]
+ # Define the new node indices of those points.
+ # If merge is set to TRUE:
+ # --> Equal split points should be added as the same node.
+ # --> Split points equal to an existing node should get that existing index.
+ # If merge equal is set to FALSE:
+ # --> Each split point is added as a separate node.
+ if (merge) {
+ # Arrange the new nodes table such that:
+ # --> Existing nodes come before split points.
+ new_node_pts = arrange(new_node_pts, nid)
+ # Update the unique location indices.
+ # Such that they match the length and order of the arranged node table.
+ new_node_lids = match(new_node_pts$lid, unique(new_node_pts$lid))
+ # If all existing nodes are at unique locations:
+ # --> The location indices become the new node indices.
+ # If there are multiple existing nodes at the same location:
+ # --> We do not want those nodes to be merged.
+ # --> Some more work is needed to define the new node indices.
+ if (any(duplicated(edge_lids[is_boundary]))) {
+ # First extract the current node indices.
+ new_node_ids = new_node_pts$nid
+ # Define which of the new nodes do not have an index yet.
+ # These are those nodes that were not existing before.
+ is_na = is.na(new_node_ids)
+ # If such a node is equal to an existing node
+ # --> Use the index of the existing node.
+ # Otherwise:
+ # --> Give it a new index continuing the current sequence of indices.
+ k = max(new_node_lids[!is_na])
+ give_index = function(i) ifelse(i > k, i - k + nrow(nodes), i)
+ na_lids = new_node_lids[is_na]
+ new_node_ids[is_na] = vapply(na_lids, give_index, FUN.VALUE = integer(1))
+ new_node_pts$nid = new_node_ids
+ } else {
+ new_node_pts$nid = new_node_lids
+ }
+ # Arrange the new nodes table back into the original order.
+ new_node_pts = arrange(new_node_pts, eid, pid)
+ # Define for each of the new nodes:
+ # --> Which of them did not exist yet in the original network.
+ is_add = new_node_pts$nid > nrow(nodes)
+ add_node_ids = new_node_pts$nid[is_add]
+ } else {
+ # If equal locations should not become the same node:
+ # --> All split points did not exist yet as a node.
+ is_add = is.na(new_node_pts$nid)
+ add_node_pids = new_node_pts$pid[is_add]
+ add_node_ids = match(add_node_pids, unique(add_node_pids)) + nrow(nodes)
+ new_node_pts[is_add, ]$nid = add_node_ids
+ }
+ # Construct the geometries of the nodes that need to be added to the network.
+ add_node_pts = new_node_pts[is_add, ][!duplicated(add_node_ids), ]
+ add_node_geoms = df_to_points(add_node_pts, nodes)
+ # Construct the new node data.
+ # This is done by simply binding original node data with added geometries.
+ add_nodes = sfc_to_sf(add_node_geoms, colname = attr(nodes, "sf_column"))
+ new_nodes = bind_rows(nodes, add_nodes)
+ ## ==================================================
+ # STEP VI: UPDATE FROM AND TO INDICES OF NEW EDGES
+ # Now we constructed the new node data with updated node indices.
+ # Therefore we need to update the from and to columns of the edges as well.
+ ## ==================================================
+ new_edges$from = new_node_pts$nid[is_new_startpoint[is_new_node]]
+ new_edges$to = new_node_pts$nid[is_new_endpoint[is_new_node]]
+ ## ============================
+ # STEP VII: RECREATE THE NETWORK
+ # Use the new nodes data and the new edges data to create the new network.
+ ## ============================
+ x_new = sfnetwork_(new_nodes, new_edges, directed = is_directed(x))
+ x_new %preserve_network_attrs% x
+}
+
+create_new_edge_df = function(df, splits) {
+ # Determine the number of edge points.
+ n = nrow(df)
+ # Create the repetition vector:
+ # --> This defines for each edge point if it should be duplicated.
+ # --> A value of '1' means 'store once', i.e. don't duplicate.
+ # --> A value of '2' means 'store twice', i.e. duplicate.
+ # --> Split points will be part of two new edges and should be duplicated.
+ reps = rep(1L, n)
+ reps[splits] = 2L
+ # Create the new set of edge points by duplicating split points.
+ df[rep(seq_len(n), reps), ]
+}
+
+create_new_edge_ids = function(df, splits, id_col = "eid") {
+ # Define the new edge index of each new edge point.
+ # We do so by incrementing each original edge index by 1 at each split point.
+ incs = rep(0L, nrow(df))
+ incs[which(splits) + 1:sum(splits)] = 1L
+ df[[id_col]] + cumsum(incs)
+}
+
+create_new_edge_geoms = function(df, ids, sf_obj) {
+ coords = df[names(df) %in% c("x", "y", "z", "m")]
+ coords$eid = ids
+ new_edge_geoms = df_to_lines(coords, sf_obj, "eid", select = FALSE)
+}
\ No newline at end of file
diff --git a/R/summarize.R b/R/summarize.R
new file mode 100644
index 00000000..1635c760
--- /dev/null
+++ b/R/summarize.R
@@ -0,0 +1,83 @@
+#' Summarize attribute values into a single value
+#'
+#' @param attrs A named list with each element containing the values of an
+#' attribute. To obtain this from a network object, call
+#' \code{\link[igraph]{vertex_attrs}} or \code{\link[igraph]{edge_attrs}}.
+#'
+#' @param summary Specification of how attributes should be summarized. There
+#' are several options, see \code{\link[igraph]{igraph-attribute-combination}}
+#' for details.
+#'
+#' @param subset Integer vector specifying which rows should be summarized.
+#' If \code{NULL}, all provided values are summarized.
+#'
+#' @returns A named list with each element containing the summarized values
+#' of the provided attributes.
+#'
+#' @noRd
+summarize_attributes = function(attrs, summary = "concat", subset = NULL) {
+ if (! is.null(subset)) attrs = lapply(attrs, `[`, subset)
+ names = names(attrs)
+ summarizers = lapply(names, get_summary_function, summary)
+ out = mapply(\(x, f) f(x), attrs, summarizers, SIMPLIFY = FALSE)
+ names(out) = names
+ out
+}
+
+#' Get the specified summary function for an attribute column.
+#'
+#' @param attr Name of the attribute.
+#'
+#' @param summary Specification of how attributes should be summarized. There
+#' are several options, see \code{\link[igraph]{igraph-attribute-combination}}
+#' for details.
+#'
+#' @return A function that takes a vector of attribute values as input and
+#' returns a single value.
+#'
+#' @noRd
+get_summary_function = function(attr, summary) {
+ if (!is.list(summary)) {
+ func = summary
+ } else {
+ names = names(summary)
+ if (is.null(names)) {
+ func = summary[[1]]
+ } else {
+ func = summary[[attr]]
+ if (is.null(func)) {
+ default = which(names == "")
+ if (length(default) > 0) {
+ func = summary[[default[1]]]
+ } else {
+ func = "ignore"
+ }
+ }
+ }
+ }
+ if (is.function(func)) {
+ func
+ } else {
+ summarizer(func)
+ }
+}
+
+#' @importFrom stats median
+#' @importFrom utils head tail
+summarizer = function(name) {
+ switch(
+ name,
+ ignore = function(x) NA,
+ sum = function(x) sum(x),
+ prod = function(x) prod(x),
+ min = function(x) min(x),
+ max = function(x) max(x),
+ random = function(x) sample(x, 1),
+ first = function(x) head(x, 1),
+ last = function(x) tail(x, 1),
+ mean = function(x) mean(x),
+ median = function(x) median(x),
+ concat = function(x) c(x),
+ raise_unknown_summarizer(name)
+ )
+}
diff --git a/R/tibble.R b/R/tibble.R
index 32ebb302..461bc00e 100644
--- a/R/tibble.R
+++ b/R/tibble.R
@@ -16,6 +16,10 @@
#' extracting. If \code{NULL}, it will be set to the current active element of
#' the given network. Defaults to \code{NULL}.
#'
+#' @param focused Should only features that are in focus be extracted? Defaults
+#' to \code{TRUE}. See \code{\link[tidygraph]{focus}} for more information on
+#' focused networks.
+#'
#' @param spatial Should the extracted tibble be a 'spatial tibble', i.e. an
#' object of class \code{c('sf', 'tbl_df')}, if it contains a geometry list
#' column. Defaults to \code{TRUE}.
@@ -23,7 +27,9 @@
#' @param ... Arguments passed on to \code{\link[tibble]{as_tibble}}.
#'
#' @return The active element of the network as an object of class
-#' \code{\link[tibble]{tibble}}.
+#' \code{\link[sf]{sf}} if a geometry list column is present and
+#' \code{spatial = TRUE}, and object of class \code{\link[tibble]{tibble}}
+#' otherwise.
#'
#' @name as_tibble
#'
@@ -44,33 +50,58 @@
#' @importFrom tibble as_tibble
#' @importFrom tidygraph as_tbl_graph
#' @export
-as_tibble.sfnetwork = function(x, active = NULL, spatial = TRUE, ...) {
+as_tibble.sfnetwork = function(x, active = NULL, focused = TRUE,
+ spatial = TRUE, ...) {
if (is.null(active)) {
active = attr(x, "active")
}
if (spatial) {
switch(
active,
- nodes = nodes_as_sf(x),
- edges = edges_as_table(x),
- raise_unknown_input(active)
+ nodes = nodes_as_spatial_tibble(x, focused = focused, ...),
+ edges = edges_as_spatial_tibble(x, focused = focused, ...),
+ raise_invalid_active(active)
)
} else {
switch(
active,
- nodes = as_tibble(as_tbl_graph(x), "nodes"),
- edges = as_tibble(as_tbl_graph(x), "edges"),
- raise_unknown_input(active)
+ nodes = nodes_as_regular_tibble(x, focused = focused, ...),
+ edges = edges_as_regular_tibble(x, focused = focused, ...),
+ raise_invalid_active(active)
)
}
}
-#' @importFrom tibble as_tibble
-#' @importFrom tidygraph as_tbl_graph
-edges_as_table = function(x) {
+#' @importFrom sf st_as_sf
+nodes_as_spatial_tibble = function(x, focused = FALSE, ...) {
+ st_as_sf(
+ nodes_as_regular_tibble(x, focused = focused, ...),
+ agr = node_agr(x),
+ sf_column_name = node_geom_colname(x)
+ )
+}
+
+#' @importFrom sf st_as_sf
+edges_as_spatial_tibble = function(x, focused = FALSE, ...) {
if (has_explicit_edges(x)) {
- edges_as_sf(x)
+ st_as_sf(
+ edges_as_regular_tibble(x, focused = focused, ...),
+ agr = edge_agr(x),
+ sf_column_name = edge_geom_colname(x)
+ )
} else {
- as_tibble(as_tbl_graph(x), "edges")
+ edges_as_regular_tibble(x, ...)
}
}
+
+#' @importFrom tibble as_tibble
+#' @importFrom tidygraph as_tbl_graph
+nodes_as_regular_tibble = function(x, focused = FALSE, ...) {
+ as_tibble(as_tbl_graph(x), active = "nodes", focused = focused, ...)
+}
+
+#' @importFrom tibble as_tibble
+#' @importFrom tidygraph as_tbl_graph
+edges_as_regular_tibble = function(x, focused = FALSE, ...) {
+ as_tibble(as_tbl_graph(x), active = "edges", focused = focused, ...)
+}
diff --git a/R/tidygraph.R b/R/tidygraph.R
index dc82e475..56a081e1 100644
--- a/R/tidygraph.R
+++ b/R/tidygraph.R
@@ -6,10 +6,73 @@ tidygraph::activate
#' @export
tidygraph::active
+#' @importFrom tidygraph morph
+#' @export
+tidygraph::morph
+
+#' @importFrom tidygraph unmorph
+#' @export
+tidygraph::unmorph
+
+#' @importFrom tidygraph convert
+#' @export
+tidygraph::convert
+
+#' @importFrom tidygraph crystallize
+#' @export
+tidygraph::crystallize
+
+#' @importFrom tidygraph crystallise
+#' @export
+tidygraph::crystallise
+
+#' @importFrom tidygraph with_graph
+#' @export
+tidygraph::with_graph
+
#' @importFrom tidygraph %>%
#' @export
tidygraph::`%>%`
+#' tidygraph methods for sfnetworks
+#'
+#' Normally tidygraph functions should work out of the box on
+#' \code{\link{sfnetwork}} objects, but in some cases special treatment is
+#' needed especially for the geometry column, requiring a specific method.
+#'
+#' @param x An object of class \code{\link{sfnetwork}}.
+#'
+#' @param .data An object of class \code{\link{sfnetwork}}.
+#'
+#' @param ... Arguments passed on the corresponding \code{tidygraph} function.
+#'
+#' @return The method for \code{\link[tidygraph]{as_tbl_graph}} returns an
+#' object of class \code{\link[tidygraph]{tbl_graph}}. The method for
+#' \code{\link[tidygraph]{morph}} returns a \code{morphed_sfnetwork} if the
+#' morphed network is still spatial, and a \code{morphed_tbl_graph} otherwise.
+#' All other methods return an object of class \code{\link{sfnetwork}}.
+#'
+#' @details See the \code{\link[tidygraph]{tidygraph}} documentation. The
+#' following methods have a special behavior:
+#'
+#' \itemize{
+#' \item \code{reroute}: To preserve the valid spatial network structure,
+#' this method will replace the boundaries of edge geometries by the location
+#' of the node those edges are rerouted to or from. Note that when the goal
+#' is to reverse edges in a spatial network, reroute will not simply reverse
+#' the edge geometries. In that case it is recommended to use the sfnetwork
+#' method for \code{\link[sf]{st_reverse}} instead.
+#' \item \code{morph}: This method checks if the morphed network still has
+#' spatially embedded nodes. In that case a \code{morphed_sfnetwork} is
+#' returned. If not, a \code{morphed_tbl_graph} is returned instead.
+#' \item \code{unmorph}: This method makes sure the geometry list column is
+#' correctly handled during the unmorphing process.
+#' }
+#'
+#' @name tidygraph_methods
+NULL
+
+#' @name tidygraph_methods
#' @importFrom tidygraph as_tbl_graph
#' @export
as_tbl_graph.sfnetwork = function(x, ...) {
@@ -17,141 +80,72 @@ as_tbl_graph.sfnetwork = function(x, ...) {
x
}
-#' @importFrom tidygraph as_tbl_graph morph
+#' @name tidygraph_methods
+#' @importFrom igraph is_directed
+#' @importFrom tidygraph reroute
+#' @export
+reroute.sfnetwork = function(.data, ...) {
+ if (is_directed(.data)) .data = make_edges_follow_indices(.data)
+ rerouted = NextMethod()
+ make_edges_valid(rerouted)
+}
+
+#' @name tidygraph_methods
+#' @importFrom tidygraph morph
#' @export
morph.sfnetwork = function(.data, ...) {
- # Morph using tidygraphs morphing functionality:
- # --> First try to morph the sfnetwork object directly.
- # --> If this gives errors, convert to tbl_graph and then morph.
- # --> If that also gives errors, return the first error found.
- morphed_data = tryCatch(
- NextMethod(),
- error = function(e1) {
- tryCatch(
- morph(as_tbl_graph(.data), ...),
- error = function(e2) stop(e1)
- )
- }
- )
+ # Morph using tidygraphs morphing functionality.
+ morphed = NextMethod()
# If morphed data still consist of valid sfnetworks:
# --> Convert the morphed_tbl_graph into a morphed_sfnetwork.
# --> Otherwise, just return the morphed_tbl_graph.
- if (is.sfnetwork(morphed_data[[1]])) {
+ if (is_sfnetwork(morphed[[1]])) {
structure(
- morphed_data,
- class = c("morphed_sfnetwork", class(morphed_data))
+ morphed,
+ class = c("morphed_sfnetwork", class(morphed))
)
- } else if (has_spatial_nodes(morphed_data[[1]])) {
- attrs = attributes(morphed_data)
- morphed_data = lapply(morphed_data, tbg_to_sfn)
- attributes(morphed_data) = attrs
+ } else if (has_spatial_nodes(morphed[[1]])) {
+ morphed[] = lapply(morphed, tbg_to_sfn)
structure(
- morphed_data,
- class = c("morphed_sfnetwork", class(morphed_data))
+ morphed,
+ class = c("morphed_sfnetwork", class(morphed))
)
} else {
- morphed_data
+ morphed
}
}
-#' @importFrom igraph delete_edge_attr delete_vertex_attr edge_attr vertex_attr
-#' edge_attr_names vertex_attr_names
+#' @name tidygraph_methods
+#' @importFrom igraph edge_attr vertex_attr
+#' @importFrom tibble as_tibble
#' @importFrom tidygraph unmorph
#' @export
unmorph.morphed_sfnetwork = function(.data, ...) {
- # Unmorphing needs special treatment for morphed sfnetworks when:
+ # Unmorphing needs additional preparation for morphed sfnetworks when:
# --> Features were merged and original data stored in a .orig_data column.
- # --> A new geometry column exists next to this .orig_data column.
- # This new geometry is a geometry describing the merged features.
- # When unmorphing the merged features get unmerged again.
- # Hence, the geometry column for the merged features should not be preserved.
- x_first = .data[[1]] # Extract the first element to run checks on.
- # If nodes were merged:
- # --> Remove the geometry column of the merged features before proceeding.
- n_idxs = vertex_attr(x_first, ".tidygraph_node_index")
- e_idxs = vertex_attr(x_first, ".tidygraph_edge_index")
- if (is.list(n_idxs) || is.list(e_idxs)) {
- geom_colname = node_geom_colname(attr(.data, ".orig_graph"))
- if (geom_colname %in% vertex_attr_names(x_first)) {
- attrs = attributes(.data)
- .data = lapply(.data, delete_vertex_attr, geom_colname)
- attributes(.data) = attrs
+ # --> In this case tidygraph attempts to bind columns of two tibbles.
+ # --> The sticky geometry of sf creates problems in that process.
+ # --> We can work around this by making sure .orig_data has no sf objects.
+ if (! is.null(vertex_attr(.data[[1]], ".orig_data"))) {
+ orig_data_to_tibble = function(x) {
+ vertex_attr(x, ".orig_data") = as_tibble(vertex_attr(x, ".orig_data"))
+ x
}
+ .data[] = lapply(.data, orig_data_to_tibble)
}
- # If edges were merged:
- # --> Remove the geometry column of the merged features before proceeding.
- n_idxs = edge_attr(x_first, ".tidygraph_node_index")
- e_idxs = edge_attr(x_first, ".tidygraph_edge_index")
- if (is.list(e_idxs) || is.list(n_idxs)) {
- geom_colname = edge_geom_colname(attr(.data, ".orig_graph"))
- if (!is.null(geom_colname) && geom_colname %in% edge_attr_names(x_first)) {
- attrs = attributes(.data)
- .data = lapply(.data, delete_edge_attr, geom_colname)
- attributes(.data) = attrs
+ if (! is.null(edge_attr(.data[[1]], ".orig_data"))) {
+ orig_data_to_tibble = function(x) {
+ edge_attr(x, ".orig_data") = as_tibble(edge_attr(x, ".orig_data"))
+ x
}
+ .data[] = lapply(.data, orig_data_to_tibble)
}
# Call tidygraphs unmorph.
NextMethod(.data, ...)
}
-# nocov start
-
-#' Describe graph function for print method
-#' From: https://github.com/thomasp85/tidygraph/blob/master/R/tbl_graph.R
-#' November 5, 2020
-#'
-#' @importFrom igraph is_simple is_directed is_bipartite is_connected is_dag
-#' gorder
-#' @noRd
-describe_graph = function(x) {
- if (gorder(x) == 0) return("An empty graph")
- prop = list(
- simple = is_simple(x),
- directed = is_directed(x),
- bipartite = is_bipartite(x),
- connected = is_connected(x),
- tree = is_tree(x),
- forest = is_forest(x),
- DAG = is_dag(x))
- desc = c()
- if (prop$tree || prop$forest) {
- desc[1] = if (prop$directed) "A rooted"
- else "An unrooted"
- desc[2] = if (prop$tree) "tree"
- else paste0(
- "forest with ",
- count_components(x),
- " trees"
- )
- } else {
- desc[1] = if (prop$DAG) "A directed acyclic"
- else if (prop$bipartite) "A bipartite"
- else if (prop$directed) "A directed"
- else "An undirected"
- desc[2] = if (prop$simple) "simple graph"
- else "multigraph"
- n_comp = count_components(x)
- desc[3] = paste0(
- "with ", n_comp, " component",
- if (n_comp > 1) "s" else ""
- )
- }
- paste(desc, collapse = " ")
-}
-
-#' @importFrom igraph is_connected is_simple gorder gsize is_directed
-is_tree = function(x) {
- is_connected(x) &&
- is_simple(x) &&
- (gorder(x) - gsize(x) == 1)
-}
-
-#' @importFrom igraph is_connected is_simple gorder gsize count_components
-#' is_directed
-is_forest = function(x) {
- !is_connected(x) &&
- is_simple(x) &&
- (gorder(x) - gsize(x) - count_components(x) == 0)
+#' @importFrom tidygraph unfocus
+#' @export
+unfocus.sfnetwork = function(.data, ...) {
+ .data
}
-
-# nocov end
diff --git a/R/travel.R b/R/travel.R
new file mode 100644
index 00000000..e26994e9
--- /dev/null
+++ b/R/travel.R
@@ -0,0 +1,204 @@
+#' Find the optimal route through a set of nodes in a spatial network
+#'
+#' Solve the travelling salesman problem by finding the shortest route through
+#' a set of nodes that visits each of those nodes once.
+#'
+#' @param x An object of class \code{\link{sfnetwork}}.
+#'
+#' @param nodes Nodes to be visited. Evaluated by
+#' \code{\link{evaluate_node_query}}.
+#'
+#' @param weights The edge weights to be used in the shortest path calculation.
+#' Evaluated by \code{\link{evaluate_weight_spec}}. The default is
+#' \code{\link{edge_length}}, which computes the geographic lengths of the
+#' edges.
+#'
+#' @param optimizer The optimization backend to use for defining the optimal
+#' visiting order of the given nodes. Currently the only supported option is
+#' \code{'TSP'}. See Details.
+#'
+#' @param router The routing backend to use for the cost matrix computation and
+#' the path computation. Currently supported options are \code{'igraph'} and
+#' \code{'dodgr'}. See Details.
+#'
+#' @param return_paths After defining the optimal visiting order of nodes,
+#' should the actual paths connecting those nodes be computed and returned?
+#' Defaults to \code{TRUE}. If set to \code{FALSE}, a vector of indices in
+#' visiting order is returned instead, with each index specifying the position
+#' of the visited node in the \code{from} argument.
+#'
+#' @param use_names If a column named \code{name} is present in the nodes
+#' table, should these names be used to encode the nodes in the route, instead
+#' of the node indices? Defaults to \code{FALSE}. Ignored when the nodes table
+#' does not have a column named \code{name} and if \code{return_paths = FALSE}.
+#'
+#' @param return_cost Should the total cost of each path between two subsequent
+#' nodes be computed? Defaults to \code{TRUE}.
+#' Ignored if \code{return_paths = FALSE}.
+#'
+#' @param return_geometry Should a linestring geometry be constructed for each
+#' path between two subsequent nodes? Defaults to \code{TRUE}. The geometries
+#' are constructed by calling \code{\link[sf]{st_line_merge}} on the linestring
+#' geometries of the edges in the path. Ignored if \code{return_paths = FALSE}
+#' and for networks with spatially implicit edges.
+#'
+#' @param ... Additional arguments passed on to the underlying function of the
+#' chosen optimization backend. See Details.
+#'
+#' @details The sfnetworks package does not implement its own route optimization
+#' algorithms. Instead, it relies on "optimization backends", i.e. other R
+#' packages that have implemented such algorithms. Currently the only supported
+#' optimization backend to solve the travelling salesman problem is the
+#' \code{\link[TSP:TSP-package]{TSP}} package, which provides the
+#' \code{\link[TSP]{solve_TSP}} function for this task.
+#'
+#' An input for most route optimization algorithms is the matrix containing the
+#' travel costs between the nodes to be visited. This is computed using
+#' \code{\link{st_network_cost}}. The output of most route optimization
+#' algorithms is the optimal order in which the given nodes should be visited.
+#' To compute the actual paths that connect the nodes in that order, the
+#' \code{\link{st_network_paths}} function is used. Both cost matrix computation
+#' and shortest paths computation allow to specify a "routing backend", i.e. an
+#' R package that implements algorithms to solve those tasks. See the
+#' documentation of the corresponding functions for details.
+#'
+#' @seealso \code{\link{st_network_paths}}, \code{\link{st_network_cost}}
+#'
+#' @return An object of class \code{\link[sf]{sf}} with one row per leg of the
+#' optimal route, containing the path of that leg.
+#' If \code{return_geometry = FALSE} or edges are spatially implicit, a
+#' \code{\link[tibble]{tbl_df}} is returned instead. See the documentation of
+#' \code{\link{st_network_paths}} for details. If \code{return_paths = FALSE},
+#' a vector of indices in visiting order is returned, with each index
+#' specifying the position of the visited node in the \code{from} argument.
+#'
+#' @examples
+#' library(sf, quietly = TRUE)
+#'
+#' oldpar = par(no.readonly = TRUE)
+#' par(mar = c(1,1,1,1))
+#'
+#' net = as_sfnetwork(roxel, directed = FALSE) |>
+#' st_transform(3035)
+#'
+#' # Compute the optimal route through three nodes.
+#' # Note that geographic edge length is used as edge weights by default.
+#' route = st_network_travel(net, c(1, 10, 100))
+#' route
+#'
+#' plot(net, col = "grey")
+#' plot(st_geometry(net)[route$from], pch = 20, cex = 2, add = TRUE)
+#' plot(st_geometry(route), col = "orange", lwd = 3, add = TRUE)
+#'
+#' # Instead of returning a path we can return a vector of visiting order.
+#' st_network_travel(net, c(1, 10, 100), return_paths = FALSE)
+#'
+#' # Use spatial point features to specify the visiting locations.
+#' # These are snapped to their nearest node before finding the path.
+#' p1 = st_geometry(net, "nodes")[1] + st_sfc(st_point(c(50, -50)))
+#' p2 = st_geometry(net, "nodes")[10] + st_sfc(st_point(c(-10, 100)))
+#' p3 = st_geometry(net, "nodes")[100] + st_sfc(st_point(c(-10, 100)))
+#' pts = c(p1, p2, p3)
+#' st_crs(pts) = st_crs(net)
+#'
+#' route = st_network_travel(net, pts)
+#' route
+#'
+#' plot(net, col = "grey")
+#' plot(pts, pch = 20, cex = 2, add = TRUE)
+#' plot(st_geometry(net)[route$from], pch = 4, cex = 2, add = TRUE)
+#' plot(st_geometry(route), col = "orange", lwd = 3, add = TRUE)
+#'
+#' par(oldpar)
+#'
+#' @export
+st_network_travel = function(x, nodes, weights = edge_length(),
+ optimizer = "TSP",
+ router = getOption("sfn_default_router", "igraph"),
+ return_paths = TRUE, use_names = FALSE,
+ return_cost = TRUE, return_geometry = TRUE, ...) {
+ UseMethod("st_network_travel")
+}
+
+#' @importFrom rlang enquo
+#' @export
+st_network_travel.sfnetwork = function(x, nodes, weights = edge_length(),
+ optimizer = "TSP",
+ router = getOption("sfn_default_router", "igraph"),
+ return_paths = TRUE, use_names = FALSE,
+ return_cost = TRUE,
+ return_geometry = TRUE, ...) {
+ # Evaluate the node query for the given nodes.
+ nodes = evaluate_node_query(x, enquo(nodes))
+ if (any(is.na(nodes))) raise_na_values("nodes")
+ # Evaluate the given weights specification.
+ weights = evaluate_weight_spec(x, enquo(weights))
+ # Compute the optimal route.
+ find_optimal_route(
+ x, nodes, weights,
+ optimizer = optimizer,
+ router = router,
+ use_names = use_names,
+ return_paths = return_paths,
+ return_cost = return_cost,
+ return_geometry = return_geometry,
+ ...
+ )
+}
+
+#' @importFrom dplyr bind_rows
+find_optimal_route = function(x, nodes, weights = edge_length(),
+ optimizer = "TSP",
+ router = getOption("sfn_default_router", "igraph"),
+ return_paths = TRUE, use_names = FALSE,
+ return_cost = TRUE, return_geometry = TRUE, ...) {
+ # Compute cost matrix with the given router.
+ costmat = compute_costs(x, nodes, nodes, weights = weights, router = router)
+ # Use numeric row and column names.
+ rownames(costmat) = nodes
+ colnames(costmat) = nodes
+ # Find the optimal visiting order with the given optimizer.
+ route = switch(
+ optimizer,
+ TSP = tsp_route(costmat),
+ raise_unknown_input("optimizer", optimizer, c("TSP"))
+ )
+ if (! return_paths) return(match(route, nodes))
+ # Each leg of the route will require a shortest path computation.
+ # Define the from and to nodes for each path computation.
+ from = route
+ to = c(route[-1], route[1])
+ # Calculate the shortest path for each route leg.
+ find_leg = function(...) {
+ find_paths(
+ x = x,
+ ...,
+ weights = weights,
+ use_names = use_names,
+ return_cost = return_cost,
+ return_geometry = return_geometry
+ )
+ }
+ bind_rows(Map(find_leg, from = from, to = to))
+}
+
+#' @importFrom rlang check_installed
+#' @importFrom stats as.dist
+#' @importFrom units drop_units
+tsp_route = function(x, ...) {
+ check_installed("TSP") # Package TSP is required for this function.
+ # Drop units if present.
+ if (inherits(x, "units")) x = drop_units(x)
+ # Create the object that formulates the travelling salesman problem.
+ # If the cost matrix is symmetric this should be a TSP object.
+ # Otherwise it should be a ATSP object.
+ if (isSymmetric(x)) {
+ tsp_obj = TSP::TSP(as.dist(x))
+ } else {
+ tsp_obj = TSP::ATSP(as.dist(x))
+ }
+ # Solve the problem.
+ tour = TSP::solve_TSP(tsp_obj, ...)
+ # Return the node indices in order of visit.
+ as.numeric(names(tour))
+}
diff --git a/R/utils.R b/R/utils.R
index 2152342a..660c2038 100644
--- a/R/utils.R
+++ b/R/utils.R
@@ -1,361 +1,351 @@
-#' List-column friendly version of bind_rows
+#' Run an igraph function on an sfnetwork object
#'
-#' @param ... Tables to be row-binded.
+#' Since \code{\link{sfnetwork}} objects inherit \code{\link[igraph]{igraph}}
+#' objects, any igraph function can be called on a sfnetwork. However, if this
+#' function returns a network, it will be an igraph object rather than a
+#' sfnetwork object. With \code{\link{wrap_igraph}}, such a function will
+#' preserve the sfnetwork class, after checking if the network returned by
+#' igraph still has a valid spatial network structure.
#'
-#' @details Behaviour of this function should be similar to rbindlist from the
-#' data.table package.
+#' @param .data An object of class \code{\link{sfnetwork}}.
#'
-#' @importFrom dplyr across bind_rows mutate
-#' @noRd
-bind_rows_list = function(...) {
- cols_as_list = function(x) list2DF(lapply(x, function(y) unname(as.list(y))))
- ins = lapply(list(...), cols_as_list)
- out = bind_rows(ins)
- is_listcol = vapply(out, function(x) any(lengths(x) > 1), logical(1))
- mutate(out, across(which(!is_listcol), unlist))
-}
-
-#' Print a string with a subtle style.
+#' @param .f An function from the \code{\link[igraph]{igraph}} package that
+#' accepts a graph as its first argument, and returns a graph.
#'
-#' @param ... A string to print.
+#' @param ... Arguments passed on to \code{.f}.
#'
-#' @return A printed string to console with subtle style.
+#' @param .force Should network validity checks be skipped? Defaults to
+#' \code{FALSE}, meaning that network validity checks are executed when
+#' returning the new network. These checks guarantee a valid spatial network
+#' structure. For the nodes, this means that they all should have \code{POINT}
+#' geometries. In the case of spatially explicit edges, it is also checked that
+#' all edges have \code{LINESTRING} geometries, nodes and edges have the same
+#' CRS and boundary points of edges match their corresponding node coordinates.
+#' These checks are important, but also time consuming. If you are already sure
+#' your input data meet the requirements, the checks are unnecessary and can be
+#' turned off to improve performance.
#'
-#' @importFrom crayon silver
-#' @noRd
-cat_subtle = function(...) { # nocov start
- # Util function for print method, testing should be up to crayon
- cat(silver(...))
-} # nocov end
-
-#' Create edges from nodes
+#' @param .message Should informational messages (those messages that are
+#' neither warnings nor errors) be printed when constructing the network?
+#' Defaults to \code{TRUE}.
#'
-#' @param nodes An object of class \code{\link[sf]{sf}} with \code{POINT}
-#' geometries.
+#' @return An object of class \code{\link{sfnetwork}}.
#'
-#' @details It is assumed that the given POINT geometries form the nodes. Edges
-#' need to be created as linestrings between those nodes. It is assumed that
-#' the given nodes are connected sequentially.
+#' @examples
+#' oldpar = par(no.readonly = TRUE)
+#' par(mar = c(1,1,1,1), mfrow = c(1,2))
#'
-#' @return A list with the nodes as an object of class \code{\link[sf]{sf}}
-#' with \code{POINT} geometries and the created edges as an object of class
-#' \code{\link[sf]{sf}} with \code{LINESTRING} geometries.
+#' net = as_sfnetwork(mozart, "delaunay", directed = FALSE)
+#' mst = wrap_igraph(net, igraph::mst, .message = FALSE)
+#' mst
#'
-#' @importFrom sf st_geometry st_sf
-#' @noRd
-create_edges_from_nodes = function(nodes) {
- # Define indices for source and target nodes.
- source_ids = 1:(nrow(nodes) - 1)
- target_ids = 2:nrow(nodes)
- # Create separate tables for source and target nodes.
- sources = nodes[source_ids, ]
- targets = nodes[target_ids, ]
- # Create linestrings between the source and target nodes.
- edges = st_sf(
- from = source_ids,
- to = target_ids,
- geometry = draw_lines(st_geometry(sources), st_geometry(targets))
- )
- # Use the same sf column name as in the nodes.
- nodes_geom_colname = attr(nodes, "sf_column")
- if (nodes_geom_colname != "geometry") {
- names(edges)[3] = nodes_geom_colname
- attr(edges, "sf_column") = nodes_geom_colname
- }
- # Return a list with both the nodes and edges.
- class(edges) = class(nodes)
- list(nodes = nodes, edges = edges)
+#' plot(net)
+#' plot(mst)
+#'
+#' par(oldpar)
+#'
+#' @export
+wrap_igraph = function(.data, .f, ..., .force = FALSE, .message = TRUE) {
+ out = .f(.data, ...) %preserve_all_attrs% .data
+ if (! .force) validate_network(out, message = .message)
+ out
}
-#' Create nodes from edges
+#' Determine duplicated geometries
#'
-#' @param edges An object of class \code{\link[sf]{sf}} with \code{LINESTRING}
-#' geometries.
+#' @param x An object of class \code{\link[sf]{sf}} or \code{\link[sf]{sfc}}.
#'
-#' @details It is assumed that the given LINESTRING geometries form the edges.
-#' Nodes need to be created at the boundary points of the edges. Identical
-#' boundary points should become the same node.
+#' @return A logical vector specifying for each feature in \code{x} if its
+#' geometry is equal to a previous feature in \code{x}.
#'
-#' @return A list with the edges as an object of class \code{\link[sf]{sf}}
-#' with \code{LINESTRING} geometries and the created nodes as an object of
-#' class \code{\link[sf]{sf}} with \code{POINT} geometries.
+#' @seealso \code{\link{duplicated}}
#'
-#' @importFrom sf st_sf
-#' @noRd
-create_nodes_from_edges = function(edges) {
- # Get the boundary points of the edges.
- nodes = linestring_boundary_points(edges)
- # Give each unique location a unique ID.
- indices = st_match(nodes)
- # Define for each endpoint if it is a source or target node.
- is_source = rep(c(TRUE, FALSE), length(nodes) / 2)
- # Define for each edges which node is its source and target node.
- if ("from" %in% colnames(edges)) raise_overwrite("from")
- edges$from = indices[is_source]
- if ("to" %in% colnames(edges)) raise_overwrite("to")
- edges$to = indices[!is_source]
- # Remove duplicated nodes from the nodes table.
- nodes = nodes[!duplicated(indices)]
- # Convert to sf object
- nodes = st_sf(geometry = nodes)
- # Use the same sf column name as in the edges.
- edges_geom_colname = attr(edges, "sf_column")
- if (edges_geom_colname != "geometry") {
- names(nodes)[1] = edges_geom_colname
- attr(nodes, "sf_column") = edges_geom_colname
- }
- # Return a list with both the nodes and edges.
- class(nodes) = class(edges)
- list(nodes = nodes, edges = edges)
+#' @examples
+#' library(sf, quietly = TRUE)
+#'
+#' p1 = st_sfc(st_point(c(1, 1)))
+#' p2 = st_sfc(st_point(c(0, 0)))
+#' p3 = st_sfc(st_point(c(1, 0)))
+#'
+#' st_duplicated(c(p1, p2, p2, p3, p1))
+#'
+#' @importFrom sf st_equals st_geometry
+#' @export
+st_duplicated = function(x) {
+ dup = rep(FALSE, length(st_geometry(x)))
+ dup[unique(do.call("c", lapply(st_equals(x), `[`, - 1)))] = TRUE
+ dup
}
-#' Draw lines between two sets of points, row-wise
+#' @importFrom sf st_geometry
+#' @importFrom sfheaders sfc_to_df
+st_duplicated_points = function(x, precision = attr(x, "precision")) {
+ x_df = sfc_to_df(x)
+ coords = x_df[, names(x_df) %in% c("x", "y", "z", "m")]
+ st_duplicated_points_df(coords, precision = precision)
+}
+
+st_duplicated_points_df = function(x, precision = NULL) {
+ x_trim = lapply(x, round, digits = precision_digits(precision))
+ x_concat = do.call(paste, x_trim)
+ duplicated(x_concat)
+}
+
+#' Geometry matching
#'
-#' @param x An object of class \code{\link[sf]{sfc}} with \code{POINT}
-#' geometries, representing the points where lines need to start at.
+#' @param x An object of class \code{\link[sf]{sf}} or \code{\link[sf]{sfc}}.
#'
-#' @param y An object of class \code{\link[sf]{sfc}} with \code{POINT}
-#' geometries, representing the points where lines need to end at.
+#' @return A numeric vector giving for each feature in \code{x} the position of
+#' the first feature in \code{x} that has an equal geometry.
#'
-#' @return An object of class \code{\link[sf]{sfc}} with \code{LINESTRING}
-#' geometries.
+#' @seealso \code{\link{match}}
#'
-#' @details Lines are drawn row-wise. That is, between the first point in x
-#' and the first point in y, the second point in x and the second point in y,
-#' et cetera.
+#' @examples
+#' library(sf, quietly = TRUE)
#'
-#' @importFrom sf st_crs st_crs<- st_precision st_precision<-
-#' @importFrom sfheaders sfc_linestring sfc_to_df
-#' @noRd
-draw_lines = function(x, y) {
- df = rbind(sfc_to_df(x), sfc_to_df(y))
- df = df[order(df$point_id), ]
- lines = sfc_linestring(df, x = "x", y = "y", linestring_id = "point_id")
- st_crs(lines) = st_crs(x)
- st_precision(lines) = st_precision(x)
- lines
-}
-
-#' Get the geometries of the boundary nodes of edges in an sfnetwork
+#' p1 = st_sfc(st_point(c(1, 1)))
+#' p2 = st_sfc(st_point(c(0, 0)))
+#' p3 = st_sfc(st_point(c(1, 0)))
#'
-#' @param x An object of class \code{\link{sfnetwork}}.
+#' st_match(c(p1, p2, p2, p3, p1))
#'
-#' @return An object of class \code{\link[sf]{sfc}} with \code{POINT}
-#' geometries, of length equal to twice the number of edges in x, and ordered
-#' as [start of edge 1, end of edge 1, start of edge 2, end of edge 2, ...].
-#'
-#' @details Boundary nodes differ from boundary points in the sense that
-#' boundary points are retrieved by taking the boundary points of the
-#' \code{LINESTRING} geometries of edges, while boundary nodes are retrieved
-#' by querying the nodes table of a network with the `to` and `from` columns
-#' in the edges table. In a valid network structure, boundary nodes should be
-#' equal to boundary points.
-#'
-#' @importFrom igraph E ends
-#' @importFrom sf st_as_sf st_geometry
-#' @noRd
-edge_boundary_nodes = function(x) {
- nodes = pull_node_geom(x)
- id_mat = ends(x, E(x), names = FALSE)
- id_vct = as.vector(t(id_mat))
- nodes[id_vct]
+#' @importFrom sf st_equals
+#' @export
+st_match = function(x) {
+ idxs = do.call("c", lapply(st_equals(x), `[`, 1))
+ match(idxs, unique(idxs))
+}
+
+#' @importFrom sf st_geometry
+#' @importFrom sfheaders sfc_to_df
+st_match_points = function(x, precision = attr(x, "precision")) {
+ x_df = sfc_to_df(x)
+ coords = x_df[, names(x_df) %in% c("x", "y", "z", "m")]
+ st_match_points_df(coords, precision = precision)
}
-#' Get the indices of the boundary nodes of edges in an sfnetwork
+st_match_points_df = function(x, precision = NULL) {
+ x_trim = lapply(x, round, digits = precision_digits(precision))
+ x_concat = do.call(paste, x_trim)
+ match(x_concat, unique(x_concat))
+}
+
+#' Rounding of geometry coordinates
+#'
+#' @param x An object of class \code{\link[sf]{sf}} or \code{\link[sf]{sfc}}.
#'
-#' @param x An object of class \code{\link{sfnetwork}}.
+#' @param digits Integer indicating the number of decimal places to be used.
#'
-#' @param matrix Should te result be returned as a two-column matrix? Defaults
-#' to \code{FALSE}.
+#' @return An object of class \code{\link[sf]{sf}} or \code{\link[sf]{sfc}}
+#' with rounded coordinates.
#'
-#' @return If matrix is \code{FALSE}, a numeric vector of length equal to twice
-#' the number of edges in x, and ordered as
-#' [start of edge 1, end of edge 1, start of edge 2, end of edge 2, ...]. If
-#' matrix is \code{TRUE}, a two-column matrix, with the number of rows equal to
-#' the number of edges in the network. The first column contains the indices of
-#' the start nodes of the edges, the seconds column contains the indices of the
-#' end nodes of the edges.
+#' @seealso \code{\link{round}}
#'
-#' @importFrom igraph E ends
-#' @noRd
-edge_boundary_node_indices = function(x, matrix = FALSE) {
- ends = ends(x, E(x), names = FALSE)
- if (matrix) ends else as.vector(t(ends))
+#' @examples
+#' library(sf, quietly = TRUE)
+#'
+#' p1 = st_sfc(st_point(c(1.123, 1.123)))
+#' p2 = st_sfc(st_point(c(0.789, 0.789)))
+#' p3 = st_sfc(st_point(c(1.123, 0.789)))
+#'
+#' st_round(st_as_sf(c(p1, p2, p2, p3, p1)), digits = 1)
+#'
+#' @importFrom sf st_as_binary st_as_sfc st_geometry st_geometry<-
+#' st_precision<-
+#' @export
+st_round = function(x, digits = 0) {
+ x_geom = st_geometry(x)
+ st_precision(x_geom) = 10^digits
+ x_geom_rounded = st_as_sfc(st_as_binary(x_geom))
+ st_geometry(x) = x_geom_rounded
+ x
}
-#' Get the geometries of the boundary points of edges in an sfnetwork
+#' Convert a sfc object into a sf object.
#'
-#' @param x An object of class \code{\link{sfnetwork}}.
+#' @param x An object of class \code{\link[sf]{sfc}}.
#'
-#' @return An object of class \code{\link[sf]{sfc}} with \code{POINT}
-#' geometries, of length equal to twice the number of edges in x, and ordered
-#' as [start of edge 1, end of edge 1, start of edge 2, end of edge 2, ...].
+#' @param colname The name that should be given to the geometry column.
#'
-#' @details Boundary points differ from boundary nodes in the sense that
-#' boundary points are retrieved by taking the boundary points of the
-#' \code{LINESTRING} geometries of edges, while boundary nodes are retrieved
-#' by querying the nodes table of a network with the `to` and `from` columns
-#' in the edges table. In a valid network structure, boundary nodes should be
-#' equal to boundary points.
+#' @return An object of class \code{\link[sf]{sf}}.
#'
#' @importFrom sf st_as_sf
#' @noRd
-edge_boundary_points = function(x) {
- edges = pull_edge_geom(x)
- linestring_boundary_points(edges)
+sfc_to_sf = function(x, colname = "geometry") {
+ x_sf = st_as_sf(x)
+ names(x_sf) = colname
+ attr(x_sf, "sf_column") = colname
+ x_sf
}
-#' Get the node indices of the boundary points of edges in an sfnetwork
+#' Convert a sfheaders data frame into sfc point geometries
#'
-#' @param x An object of class \code{\link{sfnetwork}}.
+#' @param x_df An object of class \code{\link{data.frame}} as constructed by
+#' the \pkg{sfheaders} package.
#'
-#' @param matrix Should te result be returned as a two-column matrix? Defaults
-#' to \code{FALSE}.
+#' @param x_sf The object of class \code{\link[sf]{sf}} or
+#' \code{\link[sf]{sfc}} from which \code{x_df} was constructed. This is used
+#' to copy the CRS and the precision to the new geometries.
#'
-#' @return If matrix is \code{FALSE}, a numeric vector of length equal to twice
-#' the number of edges in x, and ordered as
-#' [start of edge 1, end of edge 1, start of edge 2, end of edge 2, ...]. If
-#' matrix is \code{TRUE}, a two-column matrix, with the number of rows equal to
-#' the number of edges in the network. The first column contains the node
-#' indices of the start points of the edges, the seconds column contains the
-#' node indices of the end points of the edges.
+#' @param select Should coordinate columns first be selected from the given
+#' data frame? If \code{TRUE}, columns with names "x", "y", "z" and "m" will
+#' first be selected from the data frame. If \code{FALSE}, it is assumed the
+#' data frame only contains (a subset of) these columns in exactly that order.
+#' Defaults to \code{TRUE}.
#'
-#' @importFrom igraph ecount
-#' @importFrom sf st_equals
+#' @return An object of class \code{\link[sf]{sfc}} with \code{POINT}
+#' geometries.
+#'
+#' @importFrom sf st_crs st_crs<- st_precision st_precision<-
+#' @importFrom sfheaders sfc_point
#' @noRd
-edge_boundary_point_indices = function(x, matrix = FALSE) {
- nodes = pull_node_geom(x)
- edges = edges_as_sf(x)
- idxs_lst = st_equals(linestring_boundary_points(edges), nodes)
- idxs_vct = do.call("c", idxs_lst)
- # In most networks the location of a node will be unique.
- # However, this is not a requirement.
- # There may be cases where multiple nodes share the same geometry.
- # Then some more processing is needed to find the correct indices.
- if (length(idxs_vct) != ecount(x) * 2) {
- n = length(idxs_lst)
- from = idxs_lst[seq(1, n - 1, 2)]
- to = idxs_lst[seq(2, n, 2)]
- p_idxs = mapply(c, from, to, SIMPLIFY = FALSE)
- n_idxs = mapply(c, edges$from, edges$to, SIMPLIFY = FALSE)
- find_indices = function(a, b) {
- idxs = a[a %in% b]
- if (length(idxs) > 2) b else idxs
- }
- idxs_lst = mapply(find_indices, p_idxs, n_idxs, SIMPLIFY = FALSE)
- idxs_vct = do.call("c", idxs_lst)
- }
- if (matrix) t(matrix(idxs_vct, nrow = 2)) else idxs_vct
+df_to_points = function(x_df, x_sf, select = TRUE) {
+ if (select) x_df = x_df[, names(x_df) %in% c("x", "y", "z", "m")]
+ pts = sfc_point(x_df)
+ st_crs(pts) = st_crs(x_sf)
+ st_precision(pts) = st_precision(x_sf)
+ pts
}
-#' Make edges spatially explicit
+#' Convert a sfheaders data frame into sfc linestring geometries
#'
-#' @param x An object of class \code{\link{sfnetwork}}.
+#' @param x_df An object of class \code{\link{data.frame}} as constructed by
+#' the \pkg{sfheaders} package.
#'
-#' @return An object of class \code{\link{sfnetwork}} with spatially explicit
-#' edges.
+#' @param x_sf The object of class \code{\link[sf]{sf}} or
+#' \code{\link[sf]{sfc}} from which \code{x_df} was constructed. This is used
+#' to copy the CRS and the precision to the new geometries.
#'
-#' @importFrom rlang !! :=
-#' @importFrom sf st_geometry
-#' @importFrom tidygraph mutate
+#' @param id_col The name of the column in \code{x_df} that identifies which
+#' row belongs to which linestring.
+#'
+#' @param select Should coordinate columns first be selected from the given
+#' data frame? If \code{TRUE}, columns with names "x", "y", "z" and "m" will
+#' first be selected from the data frame, alongside the specified index column.
+#' If \code{FALSE}, it is assumed that the data frame besides the specified
+#' index columns only contains (a subset of) these coordinate columns in
+#' exactly that order. Defaults to \code{TRUE}.
+#'
+#' @return An object of class \code{\link[sf]{sfc}} with \code{LINESTRING}
+#' geometries.
+#'
+#' @importFrom sf st_crs st_crs<- st_precision st_precision<-
+#' @importFrom sfheaders sfc_linestring
#' @noRd
-explicitize_edges = function(x) {
- if (has_explicit_edges(x)) {
- x
- } else {
- # Extract the node geometries from the network.
- nodes = pull_node_geom(x)
- # Get the indices of the boundary nodes of each edge.
- # Returns a matrix with source ids in column 1 and target ids in column 2.
- ids = edge_boundary_node_indices(x, matrix = TRUE)
- # Get the boundary node geometries of each edge.
- from = nodes[ids[, 1]]
- to = nodes[ids[, 2]]
- # Draw linestring geometries between the boundary nodes of each edge.
- mutate_edge_geom(x, draw_lines(from, to))
- }
+df_to_lines = function(x_df, x_sf, id_col = "linestring_id", select = TRUE) {
+ if (select) x_df = x_df[, names(x_df) %in% c("x", "y", "z", "m", id_col)]
+ lns = sfc_linestring(x_df, linestring_id = id_col)
+ st_crs(lns) = st_crs(x_sf)
+ st_precision(lns) = st_precision(x_sf)
+ lns
}
-#' Get the nearest nodes to given features
+#' Convert a sfheaders data frame into a vector of coordinate strings
+#'
+#' @param x An object of class \code{\link{data.frame}} as constructed by
+#' the \pkg{sfheaders} package.
#'
-#' @param x An object of class \code{\link{sfnetwork}}.
+#' @param precision A fixed precision scale factor specifying the precision to
+#' used when rounding the coordinates. For more information on fixed precision
+#' scale factors see \code{\link[sf]{st_as_binary}}. When the precision scale
+#' factor is 0 or \code{NULL}, sfnetworks defaults to 12 decimal places.
#'
-#' @param y Spatial features as object of class \code{\link[sf]{sf}} or
-#' \code{\link[sf]{sfc}}.
+#' @param select Should coordinate columns first be selected from the given
+#' data frame? If \code{TRUE}, columns with names "x", "y", "z" and "m" will
+#' first be selected from the data frame. If \code{FALSE}, it is assumed that
+#' the data frame only contains (a subset of) these coordinate columns in
+#' exactly that order. Defaults to \code{TRUE}.
#'
-#' @return An object of class \code{\link[sf]{sf}} containing \code{POINT}
-#' geometry. The number of rows will be equal to the amount of features in
-#' \code{y}.
+#' @return A character vector with each element being the concatenated
+#' coordinate values of a row in \code{x}.
#'
-#' @importFrom sf st_geometry st_nearest_feature
#' @noRd
-get_nearest_node = function(x, y) {
- nodes = nodes_as_sf(x)
- nodes[st_nearest_feature(st_geometry(y), nodes), ]
+df_to_coords = function(x, precision = NULL, select = TRUE) {
+ if (select) x = x[, names(x) %in% c("x", "y", "z", "m")]
+ coords = lapply(x, round, digits = precision_digits(precision))
+ do.call(paste, coords)
}
-#' Get the index of the nearest nodes to given features
+#' Get the boundary points of linestring geometries
#'
-#' @param x An object of class \code{\link{sfnetwork}}.
+#' @param x An object of class \code{\link[sf]{sf}} or \code{\link[sf]{sfc}}
+#' with \code{LINESTRING} geometries.
+#'
+#' @param return_df Should a data frame with one column per coordinate be
+#' returned instead of a \code{\link[sf]{sfc}} object? Defaults to
+#' \code{FALSE}.
#'
-#' @param y Spatial features as object of class \code{\link[sf]{sf}} or
-#' \code{\link[sf]{sfc}}.
+#' @return An object of class \code{\link[sf]{sfc}} with \code{POINT}
+#' geometries, of length equal to twice the number of lines in x, and ordered
+#' as [start of line 1, end of line 1, start of line 2, end of line 2, ...].
+#' If \code{return_df = TRUE}, a data frame with one column per coordinate is
+#' returned instead, with number of rows equal to twice the number of lines in
+#' x.
#'
-#' @return An vector integers. The length of the vector will be equal to the
-#' amount of features in \code{y}.
+#' @details With boundary points we mean the points at the start and end of
+#' a linestring.
#'
-#' @importFrom sf st_geometry st_nearest_feature
+#' @importFrom sf st_geometry
+#' @importFrom sfheaders sfc_to_df
#' @noRd
-get_nearest_node_index = function(x, y) {
- st_nearest_feature(st_geometry(y), nodes_as_sf(x))
+linestring_boundary_points = function(x, return_df = FALSE) {
+ coords = sfc_to_df(st_geometry(x))
+ is_start = !duplicated(coords[["linestring_id"]])
+ is_end = !duplicated(coords[["linestring_id"]], fromLast = TRUE)
+ is_bound = is_start | is_end
+ bounds = coords[is_bound, names(coords) %in% c("x", "y", "z", "m")]
+ if (return_df) return (bounds)
+ df_to_points(bounds, x, select = FALSE)
}
-#' Make edges spatially implicit
+#' Get the start points of linestring geometries
#'
-#' @param x An object of class \code{\link{sfnetwork}}.
-
-#' @return An object of class \code{\link{sfnetwork}} with spatially implicit
-#' edges.
+#' @param x An object of class \code{\link[sf]{sf}} or \code{\link[sf]{sfc}}
+#' with \code{LINESTRING} geometries.
+#'
+#' @param return_df Should a data frame with one column per coordinate be
+#' returned instead of a \code{\link[sf]{sfc}} object? Defaults to
+#' \code{FALSE}.
+#'
+#' @return An object of class \code{\link[sf]{sfc}} with \code{POINT}
+#' geometries, of length equal to the number of lines in x. If
+#' \code{return_df = TRUE}, a data frame with one column per coordinate is
+#' returned instead, with number of rows equal to the number of lines in x.
#'
+#' @importFrom sf st_geometry
+#' @importFrom sfheaders sfc_to_df
#' @noRd
-implicitize_edges = function(x) {
- if (has_explicit_edges(x)) {
- drop_edge_geom(x)
- } else {
- x
- }
+linestring_start_points = function(x, return_df = FALSE) {
+ coords = sfc_to_df(st_geometry(x))
+ is_start = !duplicated(coords[["linestring_id"]])
+ starts = coords[is_start, names(coords) %in% c("x", "y", "z", "m")]
+ if (return_df) return (starts)
+ df_to_points(starts, x, select = FALSE)
}
-#' Get the boundary points of linestring geometries
+#' Get the end points of linestring geometries
#'
#' @param x An object of class \code{\link[sf]{sf}} or \code{\link[sf]{sfc}}
#' with \code{LINESTRING} geometries.
#'
-#' @return An object of class \code{\link[sf]{sfc}} with \code{POINT}
-#' geometries, of length equal to twice the number of lines in x, and ordered
-#' as [start of line 1, end of line 1, start of line 2, end of line 2, ...].
+#' @param return_df Should a data frame with one column per coordinate be
+#' returned instead of a \code{\link[sf]{sfc}} object? Defaults to
+#' \code{FALSE}.
#'
-#' @details With boundary points we mean the points at the start and end of
-#' a linestring.
+#' @return An object of class \code{\link[sf]{sfc}} with \code{POINT}
+#' geometries, of length equal to the number of lines in x. If
+#' \code{return_df = TRUE}, a data frame with one column per coordinate is
+#' returned instead, with number of rows equal to the number of lines in x.
#'
-#' @importFrom sf st_crs st_crs<- st_geometry st_precision st_precision<-
-#' @importFrom sfheaders sfc_point sfc_to_df
+#' @importFrom sf st_geometry
+#' @importFrom sfheaders sfc_to_df
#' @noRd
-linestring_boundary_points = function(x) {
- # Extract coordinates.
+linestring_end_points = function(x ,return_df = FALSE) {
coords = sfc_to_df(st_geometry(x))
- # Find row-indices of the first and last coordinate pair of each linestring.
- # These are the boundary points.
- first_pair = !duplicated(coords[["sfg_id"]])
- last_pair = !duplicated(coords[["sfg_id"]], fromLast = TRUE)
- idxs = first_pair | last_pair
- # Extract boundary point coordinates.
- pairs = coords[idxs, names(coords) %in% c("x", "y", "z", "m")]
- # Rebuild sf structure.
- points = sfc_point(pairs)
- st_crs(points) = st_crs(x)
- st_precision(points) = st_precision(x)
- points
+ is_end = !duplicated(coords[["linestring_id"]], fromLast = TRUE)
+ ends = coords[is_end, names(coords) %in% c("x", "y", "z", "m")]
+ if (return_df) return (ends)
+ df_to_points(ends, x, select = FALSE)
}
#' Get the segments of linestring geometries
@@ -369,34 +359,30 @@ linestring_boundary_points = function(x) {
#' @details With a line segment we mean a linestring geometry that has no
#' interior points.
#'
-#' @importFrom sf st_crs st_crs<- st_geometry st_precision st_precision<-
-#' @importFrom sfheaders sfc_linestring sfc_to_df
+#' @importFrom sf st_geometry
+#' @importFrom sfheaders sfc_to_df
#' @noRd
-linestring_segments = function(x) {
+linestring_segments = function(x, return_df = FALSE) {
# Decompose lines into the points that shape them.
- pts = sfc_to_df(st_geometry(x))
+ line_points = sfc_to_df(st_geometry(x))
# Define which of the points are a startpoint of a line.
# Define which of the points are an endpoint of a line.
- is_startpoint = !duplicated(pts[["linestring_id"]])
- is_endpoint = !duplicated(pts[["linestring_id"]], fromLast = TRUE)
- # Extract the coordinates from the points.
- coords = pts[names(pts) %in% c("x", "y", "z", "m")]
+ is_start = !duplicated(line_points[["linestring_id"]])
+ is_end = !duplicated(line_points[["linestring_id"]], fromLast = TRUE)
# Extract coordinates of the point that are a startpoint of a segment.
# Extract coordinates of the point that are an endpoint of a segment.
- src_coords = coords[!is_endpoint, ]
- trg_coords = coords[!is_startpoint, ]
- src_coords$segment_id = seq_len(nrow(src_coords))
- trg_coords$segment_id = seq_len(nrow(trg_coords))
+ segment_starts = line_points[!is_end, ]
+ segment_ends = line_points[!is_start, ]
+ segment_starts$segment_id = seq_len(nrow(segment_starts))
+ segment_ends$segment_id = seq_len(nrow(segment_ends))
# Construct the segments.
- segment_pts = rbind(src_coords, trg_coords)
- segment_pts = segment_pts[order(segment_pts$segment_id), ]
- segments = sfc_linestring(segment_pts, linestring_id = "segment_id")
- st_crs(segments) = st_crs(x)
- st_precision(segments) = st_precision(x)
- segments
+ segment_points = rbind(segment_starts, segment_ends)
+ segment_points = segment_points[order(segment_points$segment_id), ]
+ if (return_df) return (segment_points)
+ df_to_lines(segment_points, x, id_col = "segment_id")
}
-#' Cast multilinestrings to single linestrings.
+#' Forcefully cast multilinestrings to single linestrings.
#'
#' @param x An object of class \code{\link[sf]{sf}} or \code{\link[sf]{sfc}}
#' with \code{MULTILINESTRING} geometries or a combination of
@@ -408,49 +394,160 @@ linestring_segments = function(x) {
#' @details This may create invalid linestrings according to the simple feature
#' standard, e.g. linestrings may cross themselves.
#'
-#' @importFrom sf st_crs st_crs<- st_geometry st_precision st_precision<-
-#' @importFrom sfheaders sfc_linestring sfc_to_df
+#' @importFrom sf st_geometry
+#' @importFrom sfheaders sfc_to_df
#' @noRd
-multilinestrings_to_linestrings = function(x) {
+force_multilinestrings_to_linestrings = function(x) {
# Decompose lines into the points that shape them.
pts = sfc_to_df(st_geometry(x))
# Add a linestring ID to each of these points.
# Points of a multilinestring should all have the same ID.
is_in_multi = !is.na(pts$multilinestring_id)
pts$linestring_id[is_in_multi] = pts$multilinestring_id[is_in_multi]
- # Select only coordinate and ID columns.
- pts = pts[, names(pts) %in% c("x", "y", "z", "m", "linestring_id")]
# (Re)create linestring geometries.
- lines = sfc_linestring(pts, linestring_id = "linestring_id")
- st_crs(lines) = st_crs(x)
- st_precision(lines) = st_precision(x)
- lines
+ df_to_lines(pts, x, id_col = "linestring_id")
}
-#' Determine duplicated geometries
+#' Draw lines between two sets of points, row-wise
#'
-#' @param x An object of class \code{\link[sf]{sf}} or \code{\link[sf]{sfc}}.
+#' @param x An object of class \code{\link[sf]{sfc}} with \code{POINT}
+#' geometries, representing the points where lines need to start at.
#'
-#' @return A logical vector of the same length as \code{x}.
+#' @param y An object of class \code{\link[sf]{sfc}} with \code{POINT}
+#' geometries, representing the points where lines need to end at.
#'
-#' @importFrom sf st_equals
+#' @return An object of class \code{\link[sf]{sfc}} with \code{LINESTRING}
+#' geometries.
+#'
+#' @details Lines are drawn row-wise. That is, between the first point in x
+#' and the first point in y, the second point in x and the second point in y,
+#' et cetera.
+#'
+#' @importFrom sf st_crs st_crs<- st_precision st_precision<-
+#' @importFrom sfheaders sfc_linestring sfc_to_df
#' @noRd
-st_duplicated = function(x) {
- dup = rep(FALSE, length(x))
- dup[unique(do.call("c", lapply(st_equals(x), `[`, - 1)))] = TRUE
- dup
+draw_lines = function(x, y) {
+ all_points = rbind(sfc_to_df(x), sfc_to_df(y))
+ all_points = all_points[order(all_points$point_id), ]
+ df_to_lines(all_points, x, id_col = "point_id")
}
-#' Geometry matching
+#' Merge multiple linestring geometries into one linestring
#'
-#' @param x An object of class \code{\link[sf]{sf}} or \code{\link[sf]{sfc}}.
+#' @param x An object of class \code{\link[sf]{sfc}} with \code{LINESTRING}
+#' geometries.
#'
-#' @return A numeric vector giving for each feature in x the row number of the
-#' first feature in x that has equal coordinates.
+#' @details If linestrings share endpoints they will be connected and form a
+#' single linestring. If there are multiple disconnected components the result
+#' will be a multi-linestring. If \code{x} does not contain any geometries, the
+#' result will be an empty linestring.
#'
-#' @importFrom sf st_equals
+#' @return An object of class \code{\link[sf]{sfc}} with a single
+#' \code{LINESTRING} or \code{MULTILINESTRING} geometry.
+#'
+#' @importFrom sf st_crs st_linestring st_line_merge st_sfc
#' @noRd
-st_match = function(x) {
- idxs = do.call("c", lapply(st_equals(x), `[`, 1))
- match(idxs, unique(idxs))
+merge_lines = function(x) {
+ if (length(x) == 0) {
+ st_sfc(st_linestring(), crs = st_crs(x))
+ } else {
+ st_line_merge(st_combine(x))
+ }
+}
+
+#' Merge two spatial bounding box objects
+#'
+#' @param a An object of class \code{\link[sf:st_bbox]{bbox}}.
+#'
+#' @param b An object of class \code{\link[sf:st_bbox]{bbox}}.
+#'
+#' @note This function assumes that \code{a} and \code{b} have equal coordinate
+#' reference systems.
+#'
+#' @return An object of class \code{\link[sf:st_bbox]{bbox}} containing the
+#' most extreme coordinates of \code{a} and \code{b}.
+#'
+#' @noRd
+merge_bboxes = function(a, b) {
+ ab = a
+ ab["xmin"] = min(a["xmin"], b["xmin"])
+ ab["ymin"] = min(a["ymin"], b["ymin"])
+ ab["xmax"] = max(a["xmax"], b["xmax"])
+ ab["ymax"] = max(a["ymax"], b["ymax"])
+ ab
+}
+
+#' Merge two spatial z range objects
+#'
+#' @param a An object of class \code{\link[sf:st_z_range]{z_range}}.
+#'
+#' @param b An object of class \code{\link[sf:st_z_range]{z_range}}.
+#'
+#' @note This function assumes that \code{a} and \code{b} have equal coordinate
+#' reference systems.
+#'
+#' @return An object of class \code{\link[sf:st_z_range]{z_range}} containing
+#' the most extreme coordinates of \code{a} and \code{b}.
+#'
+#' @noRd
+merge_zranges = function(a, b) {
+ ab = a
+ ab["zmin"] = min(a["zmin"], b["zmin"])
+ ab["zmax"] = max(a["zmax"], b["zmax"])
+ ab
+}
+
+#' Merge two spatial m range objects
+#'
+#' @param a An object of class \code{\link[sf:st_m_range]{m_range}}.
+#'
+#' @param b An object of class \code{\link[sf:st_m_range]{m_range}}.
+#'
+#' @note This function assumes that \code{a} and \code{b} have equal coordinate
+#' reference systems.
+#'
+#' @return An object of class \code{\link[sf:st_m_range]{m_range}} containing
+#' the most extreme coordinates of \code{a} and \code{b}.
+#'
+#' @noRd
+merge_mranges = function(a, b) {
+ ab = a
+ ab["mmin"] = min(a["mmin"], b["mmin"])
+ ab["mmax"] = max(a["mmax"], b["mmax"])
+ ab
+}
+
+#' Infer the number of decimal places from a fixed precision scale factor
+#'
+#' @param x A fixed precision scale factor.
+#'
+#' @details For more information on fixed precision scale factors see
+#' \code{\link[sf]{st_as_binary}}. When the precision scale factor is 0
+#' or not defined, sfnetworks defaults to 12 decimal places.
+#'
+#' @return A numeric value specifying the number of decimal places.
+#'
+#' @importFrom cli cli_abort
+#' @noRd
+precision_digits = function(x) {
+ if (is.null(x) || x == 0) return (12)
+ if (x > 0) return (log(x, 10))
+ cli_abort("Currently sfnetworks does not support negative precision")
+}
+
+#' List-column friendly version of bind_rows
+#'
+#' @param ... Tables to be row-binded.
+#'
+#' @details Behaviour of this function should be similar to rbindlist from the
+#' data.table package.
+#'
+#' @importFrom dplyr across bind_rows mutate
+#' @noRd
+bind_rows_list = function(...) {
+ cols_as_list = function(x) list2DF(lapply(x, function(y) unname(as.list(y))))
+ ins = lapply(list(...), cols_as_list)
+ out = bind_rows(ins)
+ is_listcol = vapply(out, function(x) any(lengths(x) > 1), logical(1))
+ mutate(out, across(which(!is_listcol), unlist))
}
diff --git a/R/validate.R b/R/validate.R
new file mode 100644
index 00000000..b89596e4
--- /dev/null
+++ b/R/validate.R
@@ -0,0 +1,64 @@
+#' Validate the structure of a sfnetwork
+#'
+#' @param x An object of class \code{\link{sfnetwork}}.
+#'
+#' @param message Should messages be printed during validation? Defaults to
+#' \code{TRUE}.
+#'
+#' @return Nothing when the network is valid. Otherwise, an error is thrown.
+#'
+#' @details A valid sfnetwork structure means that all nodes have \code{POINT}
+#' geometries, and - when edges are spatially explicit - all edges have
+#' \code{LINESTRING} geometries, nodes and edges have the same coordinate
+#' reference system and the same coordinate precision, and coordinates of
+#' edge boundaries match coordinates of their corresponding nodes.
+#'
+#' @importFrom cli cli_abort cli_alert cli_alert_success
+#' @export
+validate_network = function(x, message = TRUE) {
+ nodes = pull_node_geom(x)
+ # Check 1: Are all node geometries points?
+ if (message) cli_alert("Checking node geometry types ...")
+ if (! are_points(nodes)) {
+ cli_abort("Not all nodes have geometry type {.cls POINT}")
+ }
+ if (message) cli_alert_success("All nodes have geometry type POINT")
+ if (has_explicit_edges(x)) {
+ edges = pull_edge_geom(x)
+ # Check 2: Are all edge geometries linestrings?
+ if (message) cli_alert("Checking edge geometry types ...")
+ if (! are_linestrings(edges)) {
+ cli_abort("Not all edges have geometry type {.cls LINESTRING}")
+ }
+ if (message) cli_alert_success("All edges have geometry type LINESTRING")
+ # Check 3: Is the CRS of the edges the same as of the nodes?
+ if (message) cli_alert("Checking coordinate reference system equality ...")
+ if (! have_equal_crs(nodes, edges)) {
+ cli_abort("Nodes and edges do not have the same coordinate reference system")
+ }
+ if (message) cli_alert_success("Nodes and edges have the same crs")
+ # Check 4: Is the precision of the edges the same as of the nodes?
+ if (message) cli_alert("Checking coordinate precision equality ...")
+ if (! have_equal_precision(nodes, edges)) {
+ cli_abort("Nodes and edges do not have the same coordinate precision")
+ }
+ if (message) cli_alert_success("Nodes and edges have the same precision")
+ # Check 5: Do the edge boundary points match their corresponding nodes?
+ if (message) cli_alert("Checking if geometries match ...")
+ if (is_directed(x)) {
+ # Start point should equal start node.
+ # End point should equal end node.
+ if (! all(nodes_equal_edge_boundaries(x))) {
+ cli_abort("Node locations do not match edge boundaries")
+ }
+ } else {
+ # Start point should equal either start or end node.
+ # End point should equal either start or end node.
+ if (! all(nodes_in_edge_boundaries(x))) {
+ cli_abort("Node locations do not match edge boundaries")
+ }
+ }
+ if (message) cli_alert_success("Node locations match edge boundaries")
+ }
+ if (message) cli_alert_success("Spatial network structure is valid")
+}
diff --git a/R/weights.R b/R/weights.R
new file mode 100644
index 00000000..39159416
--- /dev/null
+++ b/R/weights.R
@@ -0,0 +1,100 @@
+#' Specify edge weights in a spatial network
+#'
+#' This function is not meant to be called directly, but used inside other
+#' functions that accept the specification of edge weights.
+#'
+#' @param data An object of class \code{\link{sfnetwork}}.
+#'
+#' @param spec The specification that defines how to compute or extract edge
+#' weights defused into a \code{\link[rlang:topic-quosure]{quosure}}. See
+#' Details for the different ways in which edge weights can be specified.
+#'
+#' @details There are multiple ways in which edge weights can be specified in
+#' sfnetworks. The specification can be formatted as follows:
+#'
+#' \itemize{
+#' \item As edge measure function: A
+#' \link[=spatial_edge_measures]{spatial edge measure function} computes a
+#' given measure for each edge, which will then be used as edge weights.
+#' \item As column name: A column in the edges table of the network that
+#' contains the edge weights. Note that tidy evaluation is used and hence the
+#' column name should be unquoted.
+#' \item As a numeric vector: This vector should be of the same length as the
+#' number of edges in the network, specifying for each edge what its weight
+#' is.
+#' \item As dual weights: Dual weights can be specified by the
+#' \code{\link{dual_weights}} function. This allows to use a different set of
+#' weights for shortest paths computation and for reporting the total cost of
+#' those paths. Note that not every routing backend support dual-weighted
+#' routing.
+#' }
+#'
+#' If the weight specification is \code{NULL} or \code{NA}, this means that no
+#' edge weights are used. For shortest path computation, this means that the
+#' shortest path is simply the path with the fewest number of edges.
+#'
+#' @note For backward compatibility it is currently also still possible to
+#' format the specification as a quoted column name, but this may be removed in
+#' future versions.
+#'
+#' Also note that many shortest path algorithms require edge weights to be
+#' positive.
+#'
+#' @return A numeric vector of edge weights.
+#'
+#' @importFrom cli cli_abort
+#' @importFrom rlang eval_tidy expr
+#' @importFrom tidygraph .E .register_graph_context
+#' @export
+evaluate_weight_spec = function(data, spec) {
+ .register_graph_context(data, free = TRUE)
+ weights = eval_tidy(spec, .E())
+ if (inherits(weights, "dual_weights")) {
+ weights = lapply(weights, \(x) evaluate_weight_spec(data, x))
+ class(weights) = c("dual_weights", "list")
+ return (weights)
+ }
+ if (is_single_string(weights)) {
+ # Allow character values for backward compatibility.
+ deprecate_weights_is_string()
+ weights = eval_tidy(expr(.data[[weights]]), .E())
+ }
+ if (is.null(weights)) {
+ # Convert NULL to NA to align with tidygraph instead of igraph.
+ deprecate_weights_is_null()
+ weights = NA
+ }
+ n = length(weights)
+ if (!(n == 1 && is.na(weights)) && n != n_edges(data)) {
+ cli_abort(c(
+ "Failed to evaluate the edge weight specification.",
+ "x" = "The amount of weights does not equal the number of edges."
+ ))
+ }
+ weights
+}
+
+#' Specify dual edge weights
+#'
+#' Dual edge weights are two sets of edge weights, one (the actual weight) to
+#' determine the shortest path, and the other (the reported weight) to report
+#' the cost of that path.
+#'
+#' @param reported The edge weights to be reported. Evaluated by
+#' \code{\link{evaluate_weight_spec}}.
+#'
+#' @param actual The actual edge weights to be used to determine shortest paths.
+#' Evaluated by \code{\link{evaluate_weight_spec}}.
+#'
+#' @details Dual edge weights enable dual-weighted routing. This is supported
+#' by the \code{\link[dodgr]{dodgr}} routing backend.
+#'
+#' @returns An object of class \code{dual_weights}.
+#'
+#' @importFrom rlang enquo
+#' @export
+dual_weights = function(reported, actual) {
+ out = list(reported = enquo(reported), actual = enquo(actual))
+ class(out) = c("dual_weights", "list")
+ out
+}
\ No newline at end of file
diff --git a/README.md b/README.md
index e1f3fc41..e05a7113 100644
--- a/README.md
+++ b/README.md
@@ -4,23 +4,30 @@
[![R build status](https://github.com/luukvdmeer/sfnetworks/workflows/R-CMD-check/badge.svg)](https://github.com/luukvdmeer/sfnetworks/actions)
[![Codecov test coverage](https://codecov.io/gh/luukvdmeer/sfnetworks/branch/master/graph/badge.svg)](https://app.codecov.io/gh/luukvdmeer/sfnetworks)
-[![CRAN](http://www.r-pkg.org/badges/version/sfnetworks)](https://cran.r-project.org/package=sfnetworks)
+[![CRAN](https://www.r-pkg.org/badges/version/sfnetworks)](https://cran.r-project.org/package=sfnetworks)
[![Downloads](https://cranlogs.r-pkg.org/badges/sfnetworks)](https://cran.r-project.org/package=sfnetworks)
+
-`sfnetworks` is an R package for analysis of geospatial networks. It connects the functionalities of the `tidygraph` package for network analysis and the `sf` package for spatial data science.
+{sfnetworks} is an R package for analysis of geospatial networks. It connects and extends the functionalities of the {tidygraph} package for network analysis and the {sf} package for spatial data science.
## Background
Geospatial networks are graphs embedded in geographical space. That means that both the nodes and edges in the graph can be represented as geographic features: the nodes most commonly as points, and the edges as linestrings. They play an important role in many different domains, ranging from transportation planning and logistics to ecology and epidemiology. The structure and characteristics of geospatial networks go beyond standard graph topology, and therefore it is crucial to explicitly take space into account when analyzing them.
-We created `sfnetworks` to facilitate such an integrated workflow. It combines the forces of two popular R packages: [sf](https://r-spatial.github.io/sf/) for spatial data science and [tidygraph](https://tidygraph.data-imaginist.com/index.html) for standard graph analysis. The core of the package is a dedicated data structure for geospatial networks, that can be provided as input to both the graph analytical functions of tidygraph as well as the spatial analytical functions of sf, without the need for conversion. Additionally, we implemented a set of geospatial network specific functions, such as routines for shortest path calculation, network cleaning and topology modification. `sfnetworks` is designed as a general-purpose package suitable for usage across different application domains, and can be seamlessly integrated in [tidyverse](https://www.tidyverse.org/) workflows.
+We created {sfnetworks} to facilitate such an integrated workflow. It combines the forces of two popular R packages: {[sf](https://r-spatial.github.io/sf/)} for spatial data science and {[tidygraph](https://tidygraph.data-imaginist.com/index.html)} for standard graph analysis. The core of the package is a dedicated data structure for geospatial networks, that can be provided as input to both the graph analytical functions of {tidygraph} as well as the spatial analytical functions of {sf}, without the need for conversion. Additionally, we implemented a set of geospatial network specific functions, such as routines for shortest path calculation, network cleaning and topology modification. {sfnetworks} is designed as a general-purpose package suitable for usage across different application domains, and can be seamlessly integrated in [tidyverse](https://www.tidyverse.org/) workflows.
+
+
## Installation
-You can install the latest stable version of `sfnetworks` from [CRAN](https://cran.r-project.org/package=sfnetworks) with:
+You can install the latest stable version of {sfnetworks} from [CRAN](https://cran.r-project.org/package=sfnetworks) with:
-``` r
+```r
install.packages("sfnetworks")
```
@@ -30,26 +37,16 @@ You can install the development version from [GitHub](https://github.com/luukvdm
remotes::install_github("luukvdmeer/sfnetworks")
```
-**Note:** Two important dependencies of `sfnetworks`, the `sf` package for spatial data science and the `igraph` package for network analysis (which is the main "analysis backend" of `tidygraph`), require some low-level software libraries to be installed on your system. Depending on which operating system you use, this can mean that you have to install these system requirements first, before you can install `sfnetworks`. See the installation guides of [sf](https://github.com/r-spatial/sf#installing) and [igraph](https://github.com/igraph/rigraph#installation) for details.
+**Note:** Two important dependencies of {sfnetworks}, the {sf} package for spatial data science and the {igraph} package for network analysis (which is the main "analysis backend" of {tidygraph}), require some low-level software libraries to be installed on your system. Depending on which operating system you use, this can mean that you have to install these system requirements first, before you can install {sfnetworks}. See the installation guides of [sf](https://github.com/r-spatial/sf#installing) and [igraph](https://github.com/igraph/rigraph#installation) for details.
## Usage
-The main goal of `sfnetworks` is to connect the `tidygraph` package for network analysis and the `sf` package for spatial data science. To make the most out of it, it is recommended to make yourself familiar with these two 'parent packages' if you don't know them yet.
-
-- [sf documentation](https://r-spatial.github.io/sf/)
-- [tidygraph documentation](https://tidygraph.data-imaginist.com/)
-
-There are currently five vignettes that guide you through the functionalities of `sfnetworks`:
-
-- [The sfnetwork data structure](https://luukvdmeer.github.io/sfnetworks/articles/sfn01_structure.html)
-- [Network pre-processing and cleaning](https://luukvdmeer.github.io/sfnetworks/articles/sfn02_preprocess_clean.html)
-- [Spatial joins and filters](https://luukvdmeer.github.io/sfnetworks/articles/sfn03_join_filter.html)
-- [Routing](https://luukvdmeer.github.io/sfnetworks/articles/sfn04_routing.html)
-- [Spatial morphers](https://luukvdmeer.github.io/sfnetworks/articles/sfn05_morphers.html)
-
-
+To get started with {sfnetworks}, read the [Introduction to sfnetworks](https://luukvdmeer.github.io/sfnetworks/articles/sfn01_intro.html). Four additional package vignettes guide you in more detail through the functionalities of the package:
-(GIF (c) by [Lore Abad](https://github.com/loreabad6))
+- [Creating and representing spatial networks](https://luukvdmeer.github.io/sfnetworks/articles/sfn02_create_represent.html)
+- [Cleaning spatial networks](https://luukvdmeer.github.io/sfnetworks/articles/sfn03_cleaning.html)
+- [Spatial joins and filters](https://luukvdmeer.github.io/sfnetworks/articles/sfn04_join_filter.html)
+- [Routing on spatial networks](https://luukvdmeer.github.io/sfnetworks/articles/sfn05_routing.html)
## Contribution
diff --git a/_pkgdown.yml b/_pkgdown.yml
index 208792a3..684d8bac 100644
--- a/_pkgdown.yml
+++ b/_pkgdown.yml
@@ -1,62 +1,125 @@
+url: https://luukvdmeer.github.io/sfnetworks/
+
destination: docs
+template:
+ bootstrap: 5
+ light-switch: true
+
reference:
- - title: Construction
+ - title: Construction & conversion
desc: >
- The core of the package is a data structure that can be provided as
- input to both graph analytical functions of `tidygraph` and to spatial
- analytical functions of `sf`, without the need for conversion.
+ At the core of the package lies the `sfnetwork` class to represent
+ spatial networks. There are several ways to construct instances of this
+ class, and to convert them to other classes.
contents:
- sfnetwork
- as_sfnetwork
- - is.sfnetwork
- - title: Extensions
+ - create_from_spatial_lines
+ - create_from_spatial_points
+ - play_spatial
+ - sfnetwork_to_dodgr
+ - nb
+ - as.linnet
+ - title: Analysis
desc: >
- The package extends the existing functionalities of `tidygraph` and `sf`
- with a set of functions specific for spatial network analysis.
+ The package enables an integrated analysis workflow that combines
+ `tidygraph` and `sf`. On top of this, it extends the functionalities of
+ those two packages with a set of analytical functions specific for
+ spatial network analysis.
contents:
- - st_network_paths
+ - st_network_bbox
+ - st_network_blend
- st_network_cost
+ - st_network_faces
+ - st_network_iso
- st_network_join
- - st_network_blend
- - st_network_bbox
+ - st_network_paths
+ - st_network_travel
+ - st_project_on_network
+ - spatial_centrality
+ - spatial_node_types
- spatial_node_predicates
- node_coordinates
- - spatial_edge_predicates
- spatial_edge_measures
+ - spatial_edge_predicates
+ - group_spatial
- spatial_morphers
- - title: Integration with sf
+ - bind_spatial
+ - title: Utilities
desc: >
- The package enables smooth integration with `sf` by implementing several
- sfnetwork methods for sf functions.
+ The package exports different kind of utility and internal functions that
+ are meant to make spatial network analysis smoother, and fitting both
+ into tidyverse and non-tidyverse workflows.
+ - subtitle: Data extraction
+ desc: >
+ These functions are about the extraction of data from a network.
contents:
- - sf
- - sf_attr
- - as_tibble.sfnetwork
- - title: Integration with other packages
+ - data
+ - ids
+ - nearest
+ - nearest_ids
+ - 'n'
+ - subtitle: Edge geometries
desc: >
- The packages implements basic functions to coerce sfnetwork objects into
- other formats.
+ These functions allow to modify edge geometries.
contents:
- - as.linnet
- - s2
- - title: Visualization
+ - make_edges_directed
+ - make_edges_mixed
+ - make_edges_explicit
+ - make_edges_implicit
+ - make_edges_follow_indices
+ - make_edges_valid
+ - subtitle: Network cleaning
desc: >
- The package offers basic plot methods. For more advanced plotting, the
- use of specific plotting and mapping libraries is recommended.
+ These functions are the internal workers behind the spatial morphers that
+ are dedicted to network cleaning. They are exported to make it possible
+ to do network cleaning outside of tidyverse workflows.
contents:
- - plot.sfnetwork
- - autoplot.sfnetwork
- - title: Demo data
+ - contract_nodes
+ - simplify_network
+ - smooth_pseudo_nodes
+ - subdivide_edges
+ - subtitle: Internals
+ desc: >
+ These are internal functions that are not meant to be called directly by
+ users. They are exported such that developers aiming to extend the
+ package can use them.
+ contents:
+ - evaluate_weight_spec
+ - evaluate_node_query
+ - evaluate_edge_query
+ - subtitle: Other
+ desc: >
+ These are other utility functions that do not fit in the categories above.
+ contents:
+ - dual_weights
+ - is_sfnetwork
+ - sf_attr
+ - st_duplicated
+ - st_match
+ - st_round
+ - validate_network
+ - wrap_igraph
+ - title: Data
contents:
- roxel
+ - mozart
+ - title: internal
+ contents:
+ - as_s2_geography
+ - as_tibble
+ - autoplot
+ - plot.sfnetwork
+ - sf_methods
+ - tidygraph_methods
articles:
- title: Vignettes
navbar: ~
contents:
- - sfn01_structure
- - sfn02_preprocess_clean
- - sfn03_join_filter
- - sfn04_routing
- - sfn05_morphers
+ - sfn01_intro
+ - sfn02_create_represent
+ - sfn03_cleaning
+ - sfn04_join_filter
+ - sfn05_routing
diff --git a/data-raw/mozart.R b/data-raw/mozart.R
new file mode 100644
index 00000000..e9a71c31
--- /dev/null
+++ b/data-raw/mozart.R
@@ -0,0 +1,66 @@
+library(osmdata)
+library(sf)
+library(tidyverse)
+
+# obtain salzburg bounding box
+bb = getbb("Salzburg, Austria", format_out = "sf_polygon")
+# get the city boundary and not the province boundary
+bb = bb |>
+ mutate(area = st_area(geometry)) |>
+ filter(area == min(area, na.rm = TRUE))
+
+# query features with the name mozart using osmdata
+mozart_query = opq(bbox = st_bbox(bb)) |>
+ add_osm_feature(key = "name", value = "mozart",
+ value_exact = FALSE, match_case = FALSE) |>
+ osmdata_sf()
+
+# extract relevant POIs from points, polygons and multipolygons
+pts = mozart_query$osm_points |>
+ filter(str_detect(name, "Mozart")) |>
+ filter(name %in% c(
+ "Café Mozart",
+ "Spirit of Mozart",
+ "Mozart-Eine Hommage",
+ "Haus für Mozart",
+ "Mozartkugel",
+ "Altstadthotel Kasererbraeu Mozartkino GmbH", #rename just Mozartkino
+ "Mozartsteg/Imbergstraße",
+ "Mozartsteg/Rudolfskai"
+ )) |>
+ mutate(name = case_when(
+ str_detect(name, "Mozartkino") ~ "Mozartkino",
+ TRUE ~ name
+ )) |>
+ as_tibble() |>
+ st_as_sf()
+pls = mozart_query$osm_polygons |>
+ filter(str_detect(name, "Mozart")) |>
+ filter(!str_detect(name, "Solit")) |>
+ filter(name != "Mozarteum") |>
+ filter(!(`addr:street` %in% c("Schrannengasse", "Paris-Lodron-Straße"))) |>
+ st_centroid() |>
+ as_tibble() |>
+ st_as_sf()
+
+# combine datasets, select relevant attributes and combine into
+# a single attribute called type
+# compute x and y coordinates to order the points
+mozart = bind_rows(pts, pls) |>
+ select(name, amenity, tourism, craft, building,
+ highway, man_made, website) |>
+ mutate(
+ type = coalesce(amenity, tourism, craft, building, highway, man_made),
+ x = st_coordinates(geometry)[,"X"],
+ y = st_coordinates(geometry)[,"Y"]
+ ) |>
+ arrange(y, x) |>
+ select(name, type, website) |>
+ st_set_agr(c(name = "constant", type = "constant", website = "constant")) |>
+ st_transform(3035) |>
+ mutate(across(where(is.character), .fns = function(x){return(`Encoding<-`(x, "UTF-8"))}))
+
+st_crs(mozart)$wkt <- gsub("ü", "\\\u00fc", st_crs(mozart)$wkt)
+
+# save as lazy data
+usethis::use_data(mozart, overwrite = TRUE)
diff --git a/data-raw/roxel.R b/data-raw/roxel.R
new file mode 100644
index 00000000..527a39e8
--- /dev/null
+++ b/data-raw/roxel.R
@@ -0,0 +1,53 @@
+library(osmdata)
+library(sf)
+library(tidyverse)
+library(sfnetworks)
+library(tidygraph)
+
+# set a bounding box to query with OSM
+bb = c(xmin = 7.522594, ymin = 51.941512,
+ xmax = 7.546705, ymax = 51.961194)
+
+# set a polygon to crop the resulting lines
+poly = st_as_sfc(
+ "POLYGON ((7.522624 51.95441, 7.522594 51.95372, 7.522746 51.94778, 7.527507 51.94151, 7.527601 51.94152, 7.5318 51.94213, 7.532369 51.94222, 7.533006 51.9424, 7.540591 51.94474, 7.543329 51.9463, 7.543709 51.94653, 7.544452 51.9471, 7.546705 51.95124, 7.546326 51.95408, 7.544203 51.95952, 7.543794 51.95971, 7.543638 51.95978, 7.527494 51.9612, 7.522632 51.95446, 7.522624 51.95441))",
+ crs = 4326
+)
+
+# query with osmdata
+roxel_query = opq(bbox = bb) |>
+ add_osm_feature(key = "highway") |>
+ osmdata_sf()
+
+# intersect with polygon, transmute to keep only name and
+# highway renamed as type, remove unwanted types
+# and cast multilinestrings to linestrings
+# to pass to sfnetwork
+roxel_lines = roxel_query$osm_lines |>
+ st_intersection(poly) |>
+ transmute(
+ name = name,
+ type = highway
+ ) |>
+ filter(!(type %in% c("construction", "motorway", "bridelway"))) |>
+ st_cast("LINESTRING") |>
+ mutate(across(where(is.character), .fns = function(x){return(`Encoding<-`(x, "UTF-8"))}))
+
+# pre-processing:
+# -> reduce the components
+# -> smooth and subdivide
+# -> extract edges with corresponding attributes
+roxel_clean = as_sfnetwork(roxel_lines) |>
+ # filter(group_components() <= 180) |>
+ convert(to_spatial_smooth) |>
+ convert(to_spatial_subdivision) |>
+ st_as_sf("edges") |>
+ select(name, type) |>
+ st_set_agr(c(name = "constant", type = "constant"))
+
+# reorder the dataset to have more names on the first 10 rows
+set.seed(92612)
+roxel = roxel_clean[sample(1:nrow(roxel_clean)), ]
+
+# save as lazy data
+usethis::use_data(roxel, overwrite = TRUE)
diff --git a/data/mozart.rda b/data/mozart.rda
new file mode 100644
index 00000000..6cb77016
Binary files /dev/null and b/data/mozart.rda differ
diff --git a/data/roxel.rda b/data/roxel.rda
index ad47eba0..f963dca8 100644
Binary files a/data/roxel.rda and b/data/roxel.rda differ
diff --git a/man/as.linnet.Rd b/man/as.linnet.Rd
index a4c61239..b5fcb20e 100644
--- a/man/as.linnet.Rd
+++ b/man/as.linnet.Rd
@@ -20,7 +20,7 @@ A method to convert an object of class \code{\link{sfnetwork}} into
\code{\link[spatstat.linnet]{linnet}} format and enhance the
interoperability between \code{sfnetworks} and \code{spatstat}. Use
this method without the .sfnetwork suffix and after loading the
-\code{spatstat} package.
+\pkg{spatstat} package.
}
\seealso{
\code{\link{as_sfnetwork}} to convert objects of class
diff --git a/man/as_s2_geography.Rd b/man/as_s2_geography.Rd
new file mode 100644
index 00000000..37733bcf
--- /dev/null
+++ b/man/as_s2_geography.Rd
@@ -0,0 +1,26 @@
+% Generated by roxygen2: do not edit by hand
+% Please edit documentation in R/sf.R
+\name{as_s2_geography}
+\alias{as_s2_geography}
+\alias{as_s2_geography.sfnetwork}
+\title{Extract the geometries of a sfnetwork as a S2 geography vector}
+\usage{
+as_s2_geography.sfnetwork(x, focused = TRUE, ...)
+}
+\arguments{
+\item{x}{An object of class \code{\link{sfnetwork}}.}
+
+\item{focused}{Should only features that are in focus be extracted? Defaults
+to \code{TRUE}. See \code{\link[tidygraph]{focus}} for more information on
+focused networks.}
+
+\item{...}{Arguments passed on the corresponding \code{s2} function.}
+}
+\value{
+An object of class \code{\link[s2]{s2_geography}}.
+}
+\description{
+A method to convert an object of class \code{\link{sfnetwork}} into
+\code{\link[s2]{s2_geography}} format. Use this method without the
+.sfnetwork suffix and after loading the \pkg{s2} package.
+}
diff --git a/man/as_sfnetwork.Rd b/man/as_sfnetwork.Rd
index 714e4bff..d445948e 100644
--- a/man/as_sfnetwork.Rd
+++ b/man/as_sfnetwork.Rd
@@ -1,14 +1,14 @@
% Generated by roxygen2: do not edit by hand
-% Please edit documentation in R/sfnetwork.R
+% Please edit documentation in R/create.R
\name{as_sfnetwork}
\alias{as_sfnetwork}
\alias{as_sfnetwork.default}
\alias{as_sfnetwork.sf}
+\alias{as_sfnetwork.sfc}
+\alias{as_sfnetwork.dodgr_streetnet}
\alias{as_sfnetwork.linnet}
\alias{as_sfnetwork.psp}
-\alias{as_sfnetwork.sfc}
\alias{as_sfnetwork.sfNetwork}
-\alias{as_sfnetwork.sfnetwork}
\alias{as_sfnetwork.tbl_graph}
\title{Convert a foreign object to a sfnetwork}
\usage{
@@ -18,70 +18,124 @@ as_sfnetwork(x, ...)
\method{as_sfnetwork}{sf}(x, ...)
+\method{as_sfnetwork}{sfc}(x, ...)
+
+\method{as_sfnetwork}{dodgr_streetnet}(x, ...)
+
\method{as_sfnetwork}{linnet}(x, ...)
\method{as_sfnetwork}{psp}(x, ...)
-\method{as_sfnetwork}{sfc}(x, ...)
-
\method{as_sfnetwork}{sfNetwork}(x, ...)
-\method{as_sfnetwork}{sfnetwork}(x, ...)
-
\method{as_sfnetwork}{tbl_graph}(x, ...)
}
\arguments{
-\item{x}{Object to be converted into an \code{\link{sfnetwork}}.}
+\item{x}{Object to be converted into a \code{\link{sfnetwork}}.}
-\item{...}{Arguments passed on to the \code{\link{sfnetwork}} construction
-function.}
+\item{...}{Additional arguments passed on to other functions.}
}
\value{
An object of class \code{\link{sfnetwork}}.
}
\description{
Convert a given object into an object of class \code{\link{sfnetwork}}.
-If an object can be read by \code{\link[tidygraph]{as_tbl_graph}} and the
-nodes can be read by \code{\link[sf]{st_as_sf}}, it is automatically
-supported.
}
\section{Methods (by class)}{
\itemize{
-\item \code{as_sfnetwork(sf)}: Only sf objects with either exclusively geometries
-of type \code{LINESTRING} or exclusively geometries of type \code{POINT} are
-supported. For lines, is assumed that the given features form the edges.
-Nodes are created at the endpoints of the lines. Endpoints which are shared
-between multiple edges become a single node. For points, it is assumed that
-the given features geometries form the nodes. They will be connected by
-edges sequentially. Hence, point 1 to point 2, point 2 to point 3, etc.
+\item \code{as_sfnetwork(default)}: By default, the provided object is first converted
+into a \code{\link[tidygraph]{tbl_graph}} using
+\code{\link[tidygraph]{as_tbl_graph}}. Further conversion into an
+\code{\link{sfnetwork}} will work as long as the nodes can be converted to
+an \code{\link[sf]{sf}} object through \code{\link[sf]{st_as_sf}}. Arguments
+to \code{\link[sf]{st_as_sf}} can be provided as additional arguments and
+will be forwarded to \code{\link[sf]{st_as_sf}} through the
+\code{\link{sfnetwork}} construction function.
+
+\item \code{as_sfnetwork(sf)}: Convert spatial features of class
+\code{\link[sf]{sf}} directly into a \code{\link{sfnetwork}}.
+Supported geometry types are either \code{LINESTRING} or \code{POINT}. In
+the first case, the lines become the edges in the network, and nodes are
+placed at their boundaries. Additional arguments are forwarded to
+\code{\link{create_from_spatial_lines}}. In the latter case, the points
+become the nodes in the network, and are connected by edges according to a
+specified method. Additional arguments are forwarded to
+\code{\link{create_from_spatial_points}}.
+
+\item \code{as_sfnetwork(sfc)}: Convert spatial geometries of class
+\code{\link[sf]{sfc}} directly into a \code{\link{sfnetwork}}.
+Supported geometry types are either \code{LINESTRING} or \code{POINT}. In
+the first case, the lines become the edges in the network, and nodes are
+placed at their boundaries. Additional arguments are forwarded to
+\code{\link{create_from_spatial_lines}}. In the latter case, the points
+become the nodes in the network, and are connected by edges according to a
+specified method. Additional arguments are forwarded to
+\code{\link{create_from_spatial_points}}.
+
+\item \code{as_sfnetwork(dodgr_streetnet)}: Convert a directed graph of class
+\code{\link[dodgr]{dodgr_streetnet}} directly into a
+\code{\link{sfnetwork}}. Additional arguments are forwarded to
+\code{\link{dodgr_to_sfnetwork}}. This requires the
+\code{\link[dodgr:dodgr-package]{dodgr}} package to be installed.
+
+\item \code{as_sfnetwork(linnet)}: Convert spatial linear networks of class
+\code{\link[spatstat.linnet]{linnet}} directly into a
+\code{\link{sfnetwork}}. Additional arguments are forwarded to
+\code{\link{create_from_spatial_lines}}. This requires the
+\code{\link[spatstat.geom:spatstat.geom-package]{spatstat.geom}} package
+to be installed.
+
+\item \code{as_sfnetwork(psp)}: Convert spatial line segments of class
+\code{\link[spatstat.geom]{psp}} directly into a \code{\link{sfnetwork}}.
+The lines become the edges in the network, and nodes are placed at their
+boundary points. Additional arguments are forwarded to
+\code{\link{create_from_spatial_lines}}.
+
+\item \code{as_sfnetwork(sfNetwork)}: Convert spatial networks of class
+\code{sfNetwork} from the \pkg{stplanr} package directly into a
+\code{\link{sfnetwork}}. This will extract the edges as an
+\code{\link[sf]{sf}} object and re-create the network structure. Additional
+arguments are forwarded to \code{\link{create_from_spatial_lines}}.The
+directness of the original network is preserved unless specified otherwise
+through the \code{directed} argument.
+
+\item \code{as_sfnetwork(tbl_graph)}: Convert graph objects of class
+\code{\link[tidygraph]{tbl_graph}} directly into a \code{\link{sfnetwork}}.
+This will work if at least the nodes can be converted to an
+\code{\link[sf]{sf}} object through \code{\link[sf]{st_as_sf}}. Arguments
+to \code{\link[sf]{st_as_sf}} can be provided as additional arguments and
+will be forwarded to \code{\link[sf]{st_as_sf}} through the
+\code{\link{sfnetwork}} construction function. The directness of the original
+graph is preserved unless specified otherwise through the \code{directed}
+argument.
}}
\examples{
-# From an sf object.
+# From an sf object with LINESTRING geometries.
library(sf, quietly = TRUE)
-# With LINESTRING geometries.
-as_sfnetwork(roxel)
-
oldpar = par(no.readonly = TRUE)
par(mar = c(1,1,1,1), mfrow = c(1,2))
+
+as_sfnetwork(roxel)
+
plot(st_geometry(roxel))
plot(as_sfnetwork(roxel))
-par(oldpar)
-# With POINT geometries.
-p1 = st_point(c(7, 51))
-p2 = st_point(c(7, 52))
-p3 = st_point(c(8, 52))
-points = st_as_sf(st_sfc(p1, p2, p3))
-as_sfnetwork(points)
+# From an sf object with POINT geometries.
+# For more examples see ?create_from_spatial_points.
+as_sfnetwork(mozart)
+
+plot(st_geometry(mozart))
+plot(as_sfnetwork(mozart))
-oldpar = par(no.readonly = TRUE)
-par(mar = c(1,1,1,1), mfrow = c(1,2))
-plot(st_geometry(points))
-plot(as_sfnetwork(points))
par(oldpar)
+# From a dodgr_streetnet object.
+if (require(dodgr, quietly = TRUE) & require(geodist, quietly = TRUE)) {
+ as_sfnetwork(dodgr::weight_streetnet(hampi))
+}
+
# From a linnet object.
if (require(spatstat.geom, quietly = TRUE)) {
as_sfnetwork(simplenet)
@@ -94,4 +148,12 @@ if (require(spatstat.geom, quietly = TRUE)) {
as_sfnetwork(test_psp)
}
+# From a tbl_graph with coordinate columns.
+library(tidygraph, quietly = TRUE)
+
+nodes = data.frame(lat = c(7, 7, 8), lon = c(51, 52, 52))
+edges = data.frame(from = c(1, 1, 3), to = c(2, 3, 2))
+tbl_net = tbl_graph(nodes, edges)
+as_sfnetwork(tbl_net, coords = c("lon", "lat"), crs = 4326)
+
}
diff --git a/man/as_tibble.Rd b/man/as_tibble.Rd
index 2a319972..cb6526da 100644
--- a/man/as_tibble.Rd
+++ b/man/as_tibble.Rd
@@ -5,7 +5,7 @@
\alias{as_tibble.sfnetwork}
\title{Extract the active element of a sfnetwork as spatial tibble}
\usage{
-\method{as_tibble}{sfnetwork}(x, active = NULL, spatial = TRUE, ...)
+\method{as_tibble}{sfnetwork}(x, active = NULL, focused = TRUE, spatial = TRUE, ...)
}
\arguments{
\item{x}{An object of class \code{\link{sfnetwork}}.}
@@ -14,6 +14,10 @@
extracting. If \code{NULL}, it will be set to the current active element of
the given network. Defaults to \code{NULL}.}
+\item{focused}{Should only features that are in focus be extracted? Defaults
+to \code{TRUE}. See \code{\link[tidygraph]{focus}} for more information on
+focused networks.}
+
\item{spatial}{Should the extracted tibble be a 'spatial tibble', i.e. an
object of class \code{c('sf', 'tbl_df')}, if it contains a geometry list
column. Defaults to \code{TRUE}.}
@@ -22,7 +26,9 @@ column. Defaults to \code{TRUE}.}
}
\value{
The active element of the network as an object of class
-\code{\link[tibble]{tibble}}.
+\code{\link[sf]{sf}} if a geometry list column is present and
+\code{spatial = TRUE}, and object of class \code{\link[tibble]{tibble}}
+otherwise.
}
\description{
The sfnetwork method for \code{\link[tibble]{as_tibble}} is conceptually
diff --git a/man/bind_spatial.Rd b/man/bind_spatial.Rd
new file mode 100644
index 00000000..5c6a98d6
--- /dev/null
+++ b/man/bind_spatial.Rd
@@ -0,0 +1,57 @@
+% Generated by roxygen2: do not edit by hand
+% Please edit documentation in R/bind.R
+\name{bind_spatial}
+\alias{bind_spatial}
+\alias{bind_spatial_nodes}
+\alias{bind_spatial_edges}
+\title{Add nodes or edges to a spatial network.}
+\usage{
+bind_spatial_nodes(.data, ...)
+
+bind_spatial_edges(.data, ..., node_key = "name", force = FALSE)
+}
+\arguments{
+\item{.data}{An object of class \code{\link{sfnetwork}}.}
+
+\item{...}{One or more objects of class \code{\link[sf]{sf}} containing the
+nodes or edges to be added.}
+
+\item{node_key}{The name of the column in the nodes table that character
+represented \code{to} and \code{from} columns should be matched against. If
+\code{NA}, the first column is always chosen. This setting has no effect if
+\code{to} and \code{from} are given as integers. Defaults to \code{'name'}.}
+
+\item{force}{Should network validity checks be skipped? Defaults to
+\code{FALSE}, meaning that network validity checks are executed after binding
+edges, making sure that boundary points of edges match their corresponding
+node coordinates.}
+}
+\value{
+An object of class \code{\link{sfnetwork}} with added nodes or
+edges.
+}
+\description{
+These functions are the spatially aware versions of tidygraph's
+\code{\link[tidygraph]{bind_nodes}} and \code{\link[tidygraph]{bind_edges}}
+that allow you to add rows to the nodes or edges tables in a
+\code{\link{sfnetwork}} object. As with \code{\link[dplyr]{bind_rows}}
+columns are matched by name and filled with \code{NA} if the column does not
+exist in some instances.
+}
+\examples{
+library(sf, quietly = TRUE)
+library(dplyr, quietly = TRUE)
+
+net = roxel |>
+ slice(c(1:2)) |>
+ st_transform(3035) |>
+ as_sfnetwork()
+
+pts = roxel |>
+ slice(c(3:4)) |>
+ st_transform(3035) |>
+ st_centroid()
+
+bind_spatial_nodes(net, pts)
+
+}
diff --git a/man/contract_nodes.Rd b/man/contract_nodes.Rd
new file mode 100644
index 00000000..fb8d97c3
--- /dev/null
+++ b/man/contract_nodes.Rd
@@ -0,0 +1,63 @@
+% Generated by roxygen2: do not edit by hand
+% Please edit documentation in R/contract.R
+\name{contract_nodes}
+\alias{contract_nodes}
+\title{Contract groups of nodes in a spatial network}
+\usage{
+contract_nodes(
+ x,
+ groups,
+ simplify = TRUE,
+ compute_centroids = TRUE,
+ reconnect_edges = TRUE,
+ attribute_summary = "ignore",
+ store_original_ids = FALSE,
+ store_original_data = FALSE
+)
+}
+\arguments{
+\item{x}{An object of class \code{\link{sfnetwork}}.}
+
+\item{groups}{A group index for each node in x.}
+
+\item{simplify}{Should the network be simplified after contraction? Defaults
+to \code{TRUE}. This means that multiple edges and loop edges will be
+removed. Multiple edges are introduced by contraction when there are several
+connections between the same groups of nodes. Loop edges are introduced by
+contraction when there are connections within a group. Note however that
+setting this to \code{TRUE} also removes multiple edges and loop edges that
+already existed before contraction.}
+
+\item{compute_centroids}{Should the new geometry of each contracted group of
+nodes be the centroid of all group members? Defaults to \code{TRUE}. If set
+to \code{FALSE}, the geometry of the first node in each group will be used
+instead, which requires considerably less computing time.}
+
+\item{reconnect_edges}{Should the geometries of the edges be updated such
+they match the new node geometries? Defaults to \code{TRUE}. Only set this
+to \code{FALSE} if you know the node geometries did not change, otherwise
+the valid spatial network structure is broken.}
+
+\item{attribute_summary}{How should the attributes of contracted nodes be
+summarized? There are several options, see
+\code{\link[igraph]{igraph-attribute-combination}} for details.}
+
+\item{store_original_ids}{For each group of contracted nodes, should
+the indices of the original nodes be stored as an attribute of the new edge,
+in a column named \code{.tidygraph_node_index}? This is in line with the
+design principles of \code{tidygraph}. Defaults to \code{FALSE}.}
+
+\item{store_original_data}{For each group of contracted nodes, should
+the data of the original nodes be stored as an attribute of the new edge, in
+a column named \code{.orig_data}? This is in line with the design principles
+of \code{tidygraph}. Defaults to \code{FALSE}.}
+}
+\value{
+The contracted network as object of class \code{\link{sfnetwork}}.
+}
+\description{
+Combine groups of nodes into a single node per group. The centroid such a
+group will be used by default as new geometry of the contracted node. If
+edges are spatially explicit, edge geometries are updated accordingly such
+that the valid spatial network structure is preserved.
+}
diff --git a/man/create_from_spatial_lines.Rd b/man/create_from_spatial_lines.Rd
new file mode 100644
index 00000000..65b40c93
--- /dev/null
+++ b/man/create_from_spatial_lines.Rd
@@ -0,0 +1,69 @@
+% Generated by roxygen2: do not edit by hand
+% Please edit documentation in R/create.R
+\name{create_from_spatial_lines}
+\alias{create_from_spatial_lines}
+\title{Create a spatial network from linestring geometries}
+\usage{
+create_from_spatial_lines(
+ x,
+ directed = TRUE,
+ compute_length = FALSE,
+ subdivide = FALSE
+)
+}
+\arguments{
+\item{x}{An object of class \code{\link[sf]{sf}} or \code{\link[sf]{sfc}}
+with \code{LINESTRING} geometries.}
+
+\item{directed}{Should the constructed network be directed? Defaults to
+\code{TRUE}.}
+
+\item{compute_length}{Should the geographic length of the edges be stored in
+a column named \code{length}? Uses \code{\link[sf]{st_length}} to compute
+the length of the edge geometries. If there is already a column named
+\code{length}, it will be overwritten. Please note that the values in this
+column are \strong{not} automatically recognized as edge weights. This needs
+to be specified explicitly when calling a function that uses edge weights.
+Defaults to \code{FALSE}.}
+
+\item{subdivide}{Should the given linestring geometries be subdivided at
+locations where an interior point is equal to an interior or boundary point
+in another feature? This will connect the features at those locations.
+Defaults to \code{FALSE}, meaning that features are only connected at their
+boundaries.}
+}
+\value{
+An object of class \code{\link{sfnetwork}}.
+}
+\description{
+Create a spatial network from linestring geometries
+}
+\details{
+It is assumed that the given linestring geometries form the edges
+in the network. Nodes are created at the line boundaries. Shared boundaries
+between multiple linestrings become the same node.
+}
+\note{
+By default sfnetworks rounds coordinates to 12 decimal places to
+determine spatial equality. You can influence this behavior by explicitly
+setting the precision of the linestrings using
+\code{\link[sf]{st_set_precision}}.
+}
+\examples{
+library(sf, quietly = TRUE)
+
+oldpar = par(no.readonly = TRUE)
+par(mar = c(1,1,1,1), mfrow = c(1,2))
+
+net = as_sfnetwork(roxel)
+net
+
+plot(st_geometry(roxel))
+plot(net)
+
+par(oldpar)
+
+}
+\seealso{
+\code{\link{create_from_spatial_points}}
+}
diff --git a/man/create_from_spatial_points.Rd b/man/create_from_spatial_points.Rd
new file mode 100644
index 00000000..d5967ab7
--- /dev/null
+++ b/man/create_from_spatial_points.Rd
@@ -0,0 +1,162 @@
+% Generated by roxygen2: do not edit by hand
+% Please edit documentation in R/create.R
+\name{create_from_spatial_points}
+\alias{create_from_spatial_points}
+\title{Create a spatial network from point geometries}
+\usage{
+create_from_spatial_points(
+ x,
+ connections = "complete",
+ directed = TRUE,
+ edges_as_lines = TRUE,
+ compute_length = FALSE,
+ k = 1
+)
+}
+\arguments{
+\item{x}{An object of class \code{\link[sf]{sf}} or \code{\link[sf]{sfc}}
+with \code{POINT} geometries.}
+
+\item{connections}{How to connect the given point geometries to each other?
+Can be specified either as an adjacency matrix, or as a character
+describing a specific method to define the connections. See Details.}
+
+\item{directed}{Should the constructed network be directed? Defaults to
+\code{TRUE}.}
+
+\item{edges_as_lines}{Should the created edges be spatially explicit, i.e.
+have \code{LINESTRING} geometries stored in a geometry list column? Defaults
+to \code{TRUE}.}
+
+\item{compute_length}{Should the geographic length of the edges be stored in
+a column named \code{length}? Uses \code{\link[sf]{st_length}} to compute
+the length of the edge geometries when edges are spatially explicit, and
+\code{\link[sf]{st_distance}} to compute the distance between boundary nodes
+when edges are spatially implicit. Please note that the values in this
+column are \strong{not} automatically recognized as edge weights. This needs
+to be specified explicitly when calling a function that uses edge weights.
+Defaults to \code{FALSE}.}
+
+\item{k}{The amount of neighbors to connect to if
+\code{connections = 'knn'}. Defaults to \code{1}, meaning that nodes are
+only connected to their nearest neighbor. Ignored for any other value of the
+\code{connected} argument.}
+}
+\value{
+An object of class \code{\link{sfnetwork}}.
+}
+\description{
+Create a spatial network from point geometries
+}
+\details{
+It is assumed that the given points form the nodes in the network.
+How those nodes are connected by edges depends on the \code{connections}
+argument.
+
+The connections can be specified through an adjacency matrix A, which is an
+n x n matrix with n being the number of nodes, and element Aij holding a
+\code{TRUE} value if there is an edge from node i to node j, and a
+\code{FALSE} value otherwise. In the case of undirected networks, the matrix
+is not tested for symmetry, and an edge will exist between node i and node j
+if either element Aij or element Aji is \code{TRUE}. Non-logical matrices
+are first converted into logical matrices using \code{\link{as.logical}},
+whenever possible.
+
+The provided adjacency matrix may also be sparse. This can be an object of
+one of the sparse matrix classes from the \pkg{Matrix} package, or a
+list-formatted sparse matrix. This is a list with one element per node,
+holding the integer indices of the nodes it is adjacent to. An example are
+\code{\link[sf]{sgbp}} objects. If the values are not integers, they are
+first converted into integers using \code{\link{as.integer}}, whenever
+possible.
+
+Alternatively, the connections can be specified by providing the name of a
+specific method that will create the adjacency matrix internally. Valid
+options are:
+
+\itemize{
+ \item \code{complete}: All nodes are directly connected to each other.
+ \item \code{sequence}: The nodes are sequentially connected to each other,
+ meaning that the first node is connected to the second node, the second
+ node is connected to the third node, et cetera.
+ \item \code{mst}: The nodes are connected by their spatial
+ \href{https://en.wikipedia.org/wiki/Minimum_spanning_tree}{minimum
+ spanning tree}, i.e. the set of edges with the minimum total edge length
+ required to connect all nodes. The tree is always constructed on an
+ undirected network, regardless of the value of the \code{directed}.
+ argument. If \code{directed = TRUE}, each edge is duplicated and reversed
+ to ensure full connectivity of the network. Can also be specified as
+ \code{minimum_spanning_tree}.
+ \item \code{delaunay}: The nodes are connected by their
+ \href{https://en.wikipedia.org/wiki/Delaunay_triangulation}{Delaunay
+ triangulation}.
+ Requires the \href{https://r-spatial.github.io/spdep/index.html}{spdep}
+ package to be installed, and assumes planar coordinates.
+ \item \code{gabriel}: The nodes are connected as a
+ \href{https://en.wikipedia.org/wiki/Gabriel_graph}{Gabriel graph}.
+ Requires the \href{https://r-spatial.github.io/spdep/index.html}{spdep}
+ package to be installed, and assumes planar coordinates.
+ \item \code{rn}: The nodes are connected as a
+ \href{https://en.wikipedia.org/wiki/Relative_neighborhood_graph}{relative
+ neighborhood graph}. Can also be specified as \code{relative_neighborhood}
+ or \code{relative_neighbourhood}.
+ Requires the \href{https://r-spatial.github.io/spdep/index.html}{spdep}
+ package to be installed, and assumes planar coordinates.
+ \item \code{knn}: Each node is connected to its k nearest neighbors, with
+ \code{k} being specified through the \code{k} argument. By default,
+ \code{k = 1}, meaning that the nodes are connected as a
+ \href{https://en.wikipedia.org/wiki/Nearest_neighbor_graph}{nearest
+ neighbor graph}. Can also be specified as \code{nearest_neighbors} or
+ \code{nearest_neighbours}.
+ Requires the \href{https://r-spatial.github.io/spdep/index.html}{spdep}
+ package to be installed.
+}
+}
+\examples{
+library(sf, quietly = TRUE)
+
+oldpar = par(no.readonly = TRUE)
+par(mar = c(1,1,1,1))
+
+pts = st_transform(mozart, 3035)
+
+# Using a custom adjacency matrix
+adj = matrix(c(rep(TRUE, 17), rep(rep(FALSE, 17), 16)), nrow = 17)
+net = as_sfnetwork(pts, connections = adj)
+
+plot(net)
+
+# Using a sparse adjacency matrix from a spatial predicate
+dst = units::set_units(300, "m")
+adj = st_is_within_distance(pts, dist = dst, remove_self = TRUE)
+net = as_sfnetwork(pts, connections = adj, directed = FALSE)
+
+plot(net)
+
+# Using pre-defined methods
+cnet = as_sfnetwork(pts, connections = "complete")
+snet = as_sfnetwork(pts, connections = "sequence")
+mnet = as_sfnetwork(pts, connections = "mst")
+dnet = as_sfnetwork(pts, connections = "delaunay")
+gnet = as_sfnetwork(pts, connections = "gabriel")
+rnet = as_sfnetwork(pts, connections = "rn")
+nnet = as_sfnetwork(pts, connections = "knn")
+knet = as_sfnetwork(pts, connections = "knn", k = 2)
+
+par(mar = c(1,1,1,1), mfrow = c(4,2))
+
+plot(cnet, main = "complete")
+plot(snet, main = "sequence")
+plot(mnet, main = "minimum spanning tree")
+plot(dnet, main = "delaunay triangulation")
+plot(gnet, main = "gabriel graph")
+plot(rnet, main = "relative neighborhood graph")
+plot(nnet, main = "nearest neighbor graph")
+plot(knet, main = "k nearest neighbor graph (k = 2)")
+
+par(oldpar)
+
+}
+\seealso{
+\code{\link{create_from_spatial_lines}}, \code{\link{play_geometric}}
+}
diff --git a/man/data.Rd b/man/data.Rd
new file mode 100644
index 00000000..112ef011
--- /dev/null
+++ b/man/data.Rd
@@ -0,0 +1,38 @@
+% Generated by roxygen2: do not edit by hand
+% Please edit documentation in R/data.R
+\name{data}
+\alias{data}
+\alias{node_data}
+\alias{edge_data}
+\title{Extract the node or edge data from a spatial network}
+\usage{
+node_data(x, focused = TRUE)
+
+edge_data(x, focused = TRUE, require_sf = FALSE)
+}
+\arguments{
+\item{x}{An object of class \code{\link{sfnetwork}}.}
+
+\item{focused}{Should only features that are in focus be extracted? Defaults
+to \code{TRUE}. See \code{\link[tidygraph]{focus}} for more information on
+focused networks.}
+
+\item{require_sf}{Is an \code{\link[sf]{sf}} object required? This will make
+extraction of edge data fail if the edges are spatially implicit. Defaults
+to \code{FALSE}.}
+}
+\value{
+For the nodes, always an object of class \code{\link[sf]{sf}}. For
+the edges, an object of class \code{\link[sf]{sf}} if the edges are
+spatially explicit, and an object of class \code{\link[tibble]{tibble}}
+if the edges are spatially implicity and \code{require_sf = FALSE}.
+}
+\description{
+Extract the node or edge data from a spatial network
+}
+\examples{
+net = as_sfnetwork(roxel[1:10, ])
+node_data(net)
+edge_data(net)
+
+}
diff --git a/man/dual_weights.Rd b/man/dual_weights.Rd
new file mode 100644
index 00000000..064e894a
--- /dev/null
+++ b/man/dual_weights.Rd
@@ -0,0 +1,27 @@
+% Generated by roxygen2: do not edit by hand
+% Please edit documentation in R/weights.R
+\name{dual_weights}
+\alias{dual_weights}
+\title{Specify dual edge weights}
+\usage{
+dual_weights(reported, actual)
+}
+\arguments{
+\item{reported}{The edge weights to be reported. Evaluated by
+\code{\link{evaluate_weight_spec}}.}
+
+\item{actual}{The actual edge weights to be used to determine shortest paths.
+Evaluated by \code{\link{evaluate_weight_spec}}.}
+}
+\value{
+An object of class \code{dual_weights}.
+}
+\description{
+Dual edge weights are two sets of edge weights, one (the actual weight) to
+determine the shortest path, and the other (the reported weight) to report
+the cost of that path.
+}
+\details{
+Dual edge weights enable dual-weighted routing. This is supported
+by the \code{\link[dodgr]{dodgr}} routing backend.
+}
diff --git a/man/evaluate_edge_query.Rd b/man/evaluate_edge_query.Rd
new file mode 100644
index 00000000..ee60ca02
--- /dev/null
+++ b/man/evaluate_edge_query.Rd
@@ -0,0 +1,55 @@
+% Generated by roxygen2: do not edit by hand
+% Please edit documentation in R/ids.R
+\name{evaluate_edge_query}
+\alias{evaluate_edge_query}
+\title{Query specific edge indices from a spatial network}
+\usage{
+evaluate_edge_query(data, query)
+}
+\arguments{
+\item{data}{An object of class \code{\link{sfnetwork}}.}
+
+\item{query}{The query that defines for which edges to extract indices,
+defused into a \code{\link[rlang:topic-quosure]{quosure}}. See Details for
+the different ways in which edge queries can be formulated.}
+}
+\value{
+A vector of queried edge indices.
+}
+\description{
+This function is not meant to be called directly, but used inside other
+functions that accept an edge query.
+}
+\details{
+There are multiple ways in which edge indices can be queried in
+sfnetworks. The query can be formatted as follows:
+
+\itemize{
+ \item As spatial features: Spatial features can be given as object of
+ class \code{\link[sf]{sf}} or \code{\link[sf]{sfc}}. The nearest edge to
+ each feature is found by calling \code{\link[sf]{st_nearest_feature}}.
+ \item As edge type query function: A
+ \link[tidygraph:edge_types]{edge type query function} defines for each
+ edge if it is of a given type or not. Nodes that meet the criterium are
+ queried.
+ \item As edge predicate query function: A
+ \link[=spatial_edge_predicates]{edge predicate query function} defines
+ for each edge if a given spatial predicate applies to the spatial relation
+ between that edge and other spatial features. Nodes that meet the
+ criterium are queried.
+ \item As column name: The referenced column is expected to have logical
+ values defining for each edge if it should be queried or not. Note that
+ tidy evaluation is used and hence the column name should be unquoted.
+ \item As integers: Integers are interpreted as edge indices. A edge index
+ corresponds to a row-number in the edges table of the network.
+ \item As characters: Characters are interpreted as edge names. A edge name
+ corresponds to a value in a column named "name" in the the edges table of
+ the network. Note that this column is expected to store unique names
+ without any duplicated values.
+ \item As logicals: Logicals should define for each edge if it should be
+ queried or not.
+}
+
+Queries that can not be evaluated in any of the ways described above will be
+forcefully converted to integers using \code{\link{as.integer}}.
+}
diff --git a/man/evaluate_node_query.Rd b/man/evaluate_node_query.Rd
new file mode 100644
index 00000000..24d350c6
--- /dev/null
+++ b/man/evaluate_node_query.Rd
@@ -0,0 +1,55 @@
+% Generated by roxygen2: do not edit by hand
+% Please edit documentation in R/ids.R
+\name{evaluate_node_query}
+\alias{evaluate_node_query}
+\title{Query specific node indices from a spatial network}
+\usage{
+evaluate_node_query(data, query)
+}
+\arguments{
+\item{data}{An object of class \code{\link{sfnetwork}}.}
+
+\item{query}{The query that defines for which nodes to extract indices,
+defused into a \code{\link[rlang:topic-quosure]{quosure}}. See Details for
+the different ways in which node queries can be formulated.}
+}
+\value{
+A vector of queried node indices.
+}
+\description{
+This function is not meant to be called directly, but used inside other
+functions that accept a node query.
+}
+\details{
+There are multiple ways in which node indices can be queried in
+sfnetworks. The query can be formatted as follows:
+
+\itemize{
+ \item As spatial features: Spatial features can be given as object of
+ class \code{\link[sf]{sf}} or \code{\link[sf]{sfc}}. The nearest node to
+ each feature is found by calling \code{\link[sf]{st_nearest_feature}}.
+ \item As node type query function: A
+ \link[tidygraph:node_types]{node type query function} defines for each
+ node if it is of a given type or not. Nodes that meet the criterium are
+ queried.
+ \item As node predicate query function: A
+ \link[=spatial_node_predicates]{node predicate query function} defines
+ for each node if a given spatial predicate applies to the spatial relation
+ between that node and other spatial features. Nodes that meet the
+ criterium are queried.
+ \item As column name: The referenced column is expected to have logical
+ values defining for each node if it should be queried or not. Note that
+ tidy evaluation is used and hence the column name should be unquoted.
+ \item As integers: Integers are interpreted as node indices. A node index
+ corresponds to a row-number in the nodes table of the network.
+ \item As characters: Characters are interpreted as node names. A node name
+ corresponds to a value in a column named "name" in the the nodes table of
+ the network. Note that this column is expected to store unique names
+ without any duplicated values.
+ \item As logicals: Logicals should define for each node if it should be
+ queried or not.
+}
+
+Queries that can not be evaluated in any of the ways described above will be
+forcefully converted to integers using \code{\link{as.integer}}.
+}
diff --git a/man/evaluate_weight_spec.Rd b/man/evaluate_weight_spec.Rd
new file mode 100644
index 00000000..9e83051a
--- /dev/null
+++ b/man/evaluate_weight_spec.Rd
@@ -0,0 +1,55 @@
+% Generated by roxygen2: do not edit by hand
+% Please edit documentation in R/weights.R
+\name{evaluate_weight_spec}
+\alias{evaluate_weight_spec}
+\title{Specify edge weights in a spatial network}
+\usage{
+evaluate_weight_spec(data, spec)
+}
+\arguments{
+\item{data}{An object of class \code{\link{sfnetwork}}.}
+
+\item{spec}{The specification that defines how to compute or extract edge
+weights defused into a \code{\link[rlang:topic-quosure]{quosure}}. See
+Details for the different ways in which edge weights can be specified.}
+}
+\value{
+A numeric vector of edge weights.
+}
+\description{
+This function is not meant to be called directly, but used inside other
+functions that accept the specification of edge weights.
+}
+\details{
+There are multiple ways in which edge weights can be specified in
+sfnetworks. The specification can be formatted as follows:
+
+\itemize{
+ \item As edge measure function: A
+ \link[=spatial_edge_measures]{spatial edge measure function} computes a
+ given measure for each edge, which will then be used as edge weights.
+ \item As column name: A column in the edges table of the network that
+ contains the edge weights. Note that tidy evaluation is used and hence the
+ column name should be unquoted.
+ \item As a numeric vector: This vector should be of the same length as the
+ number of edges in the network, specifying for each edge what its weight
+ is.
+ \item As dual weights: Dual weights can be specified by the
+ \code{\link{dual_weights}} function. This allows to use a different set of
+ weights for shortest paths computation and for reporting the total cost of
+ those paths. Note that not every routing backend support dual-weighted
+ routing.
+}
+
+If the weight specification is \code{NULL} or \code{NA}, this means that no
+edge weights are used. For shortest path computation, this means that the
+shortest path is simply the path with the fewest number of edges.
+}
+\note{
+For backward compatibility it is currently also still possible to
+format the specification as a quoted column name, but this may be removed in
+future versions.
+
+Also note that many shortest path algorithms require edge weights to be
+positive.
+}
diff --git a/man/figures-raw/graphs.drawio b/man/figures-raw/graphs.drawio
new file mode 100644
index 00000000..00807674
--- /dev/null
+++ b/man/figures-raw/graphs.drawio
@@ -0,0 +1,908 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/man/figures/hexlogo.R b/man/figures-raw/hexlogo.R
similarity index 100%
rename from man/figures/hexlogo.R
rename to man/figures-raw/hexlogo.R
diff --git a/man/group_spatial.Rd b/man/group_spatial.Rd
new file mode 100644
index 00000000..9a8f71d7
--- /dev/null
+++ b/man/group_spatial.Rd
@@ -0,0 +1,59 @@
+% Generated by roxygen2: do not edit by hand
+% Please edit documentation in R/group.R
+\name{group_spatial}
+\alias{group_spatial}
+\alias{group_spatial_dbscan}
+\title{Group nodes based on spatial distance}
+\usage{
+group_spatial_dbscan(epsilon, min_pts = 1, use_network_distance = TRUE, ...)
+}
+\arguments{
+\item{epsilon}{The value of the epsilon parameter for the DBSCAN clustering
+algorithm, defining the radius of the neighborhood of a node.}
+
+\item{min_pts}{The value of the minPts parameter for the DBSCAN clustering
+algorithm, defining the minimum number of points in the neighborhood to be
+considered a core point.}
+
+\item{use_network_distance}{Should the distance between nodes be computed as
+the distance over the network (using \code{\link{st_network_distance}}?
+Defaults to \code{TRUE}. If set to \code{FALSE}, the straight-line distance
+(using \code{\link[sf]{st_distance}}) is computed instead.}
+
+\item{...}{Additional arguments passed on to the clustering algorithm.}
+}
+\value{
+A numeric vector with the membership for each node in the network.
+The enumeration happens in order based on group size progressing from the
+largest to the smallest group.
+}
+\description{
+These functions forms a spatial extension to the
+\code{\link[tidygraph:group_graph]{grouping}} functions in tidygraph,
+allowing to detect communities with spatial clustering algorithms.
+}
+\details{
+Just as with all grouping functions in tidygraph, spatial grouping
+functions are meant to be called inside tidygraph verbs such as
+\code{\link[tidygraph]{mutate}} or \code{\link[tidygraph]{filter}}, where
+the network that is currently being worked on is known and thus not needed
+as an argument to the function. If you want to use an algorithm outside of
+the tidygraph framework you can use \code{\link[tidygraph]{with_graph}} to
+set the context temporarily while the algorithm is being evaluated.
+}
+\section{Functions}{
+\itemize{
+\item \code{group_spatial_dbscan()}: Uses density-based spatial clustering as
+implemented in the \code{\link[dbscan]{dbscan}} function of the dbscan
+package. This requires the dbscan package to be installed. Each node marked
+as noise will form its own cluster.
+
+}}
+\examples{
+library(tidygraph, quietly = TRUE)
+
+play_geometric(10, 0.5) |>
+ activate(nodes) |>
+ mutate(group = group_spatial_dbscan(0.25))
+
+}
diff --git a/man/ids.Rd b/man/ids.Rd
new file mode 100644
index 00000000..82b52386
--- /dev/null
+++ b/man/ids.Rd
@@ -0,0 +1,35 @@
+% Generated by roxygen2: do not edit by hand
+% Please edit documentation in R/ids.R
+\name{ids}
+\alias{ids}
+\alias{node_ids}
+\alias{edge_ids}
+\title{Extract all node or edge indices from a spatial network}
+\usage{
+node_ids(x, focused = TRUE)
+
+edge_ids(x, focused = TRUE)
+}
+\arguments{
+\item{x}{An object of class \code{\link{sfnetwork}}.}
+
+\item{focused}{Should only the indices of features that are in focus be
+extracted? Defaults to \code{TRUE}. See \code{\link[tidygraph]{focus}} for
+more information on focused networks.}
+}
+\value{
+A vector of integers.
+}
+\description{
+Extract all node or edge indices from a spatial network
+}
+\details{
+The indices in these objects are always integers that correspond to
+rownumbers in respectively the nodes or edges table.
+}
+\examples{
+net = as_sfnetwork(roxel[1:10, ])
+node_ids(net)
+edge_ids(net)
+
+}
diff --git a/man/is.sfnetwork.Rd b/man/is_sfnetwork.Rd
similarity index 74%
rename from man/is.sfnetwork.Rd
rename to man/is_sfnetwork.Rd
index ee2d6d57..6e219217 100644
--- a/man/is.sfnetwork.Rd
+++ b/man/is_sfnetwork.Rd
@@ -1,9 +1,12 @@
% Generated by roxygen2: do not edit by hand
-% Please edit documentation in R/sfnetwork.R
-\name{is.sfnetwork}
+% Please edit documentation in R/checks.R
+\name{is_sfnetwork}
+\alias{is_sfnetwork}
\alias{is.sfnetwork}
\title{Check if an object is a sfnetwork}
\usage{
+is_sfnetwork(x)
+
is.sfnetwork(x)
}
\arguments{
@@ -20,7 +23,7 @@ Check if an object is a sfnetwork
library(tidygraph, quietly = TRUE, warn.conflicts = FALSE)
net = as_sfnetwork(roxel)
-is.sfnetwork(net)
-is.sfnetwork(as_tbl_graph(net))
+is_sfnetwork(net)
+is_sfnetwork(as_tbl_graph(net))
}
diff --git a/man/make_edges_directed.Rd b/man/make_edges_directed.Rd
new file mode 100644
index 00000000..2ff38b8c
--- /dev/null
+++ b/man/make_edges_directed.Rd
@@ -0,0 +1,31 @@
+% Generated by roxygen2: do not edit by hand
+% Please edit documentation in R/edge.R
+\name{make_edges_directed}
+\alias{make_edges_directed}
+\title{Convert undirected edges into directed edges based on their geometries}
+\usage{
+make_edges_directed(x)
+}
+\arguments{
+\item{x}{An object of class \code{\link{sfnetwork}}.}
+}
+\value{
+A directed network as object of class \code{\link{sfnetwork}}.
+}
+\description{
+This function converts an undirected network to a directed network following
+the direction given by the linestring geometries of the edges.
+}
+\details{
+In undirected spatial networks it is required that the boundary of
+edge geometries contain their incident node geometries. However, it is not
+required that their start point equals their specified *from* node and their
+end point their specified *to* node. Instead, it may be vice versa. This is
+because for undirected networks *from* and *to* indices are always swopped
+if the *to* index is lower than the *from* index. Therefore, the direction
+given by the *from* and *to* indices does not necessarily match the
+direction given by the edge geometries.
+}
+\note{
+If the network is already directed it is returned unmodified.
+}
diff --git a/man/make_edges_explicit.Rd b/man/make_edges_explicit.Rd
new file mode 100644
index 00000000..7c416749
--- /dev/null
+++ b/man/make_edges_explicit.Rd
@@ -0,0 +1,28 @@
+% Generated by roxygen2: do not edit by hand
+% Please edit documentation in R/edge.R
+\name{make_edges_explicit}
+\alias{make_edges_explicit}
+\title{Construct edge geometries for spatially implicit networks}
+\usage{
+make_edges_explicit(x, ...)
+}
+\arguments{
+\item{x}{An object of class \code{\link{sfnetwork}}.}
+
+\item{...}{Arguments forwarded to \code{\link[sf]{st_as_sf}} to directly
+convert the edges table into a sf object. If no arguments are given, the
+edges are made explicit by simply drawing straight lines between the start
+and end node of each edge.}
+}
+\value{
+An object of class \code{\link{sfnetwork}} with spatially explicit
+edges.
+}
+\description{
+This function turns spatially implicit networks into spatially explicit
+networks by adding a geometry column to the edge data.
+}
+\note{
+If the network is already spatially explicit it is returned
+unmodified.
+}
diff --git a/man/make_edges_follow_indices.Rd b/man/make_edges_follow_indices.Rd
new file mode 100644
index 00000000..647f2611
--- /dev/null
+++ b/man/make_edges_follow_indices.Rd
@@ -0,0 +1,33 @@
+% Generated by roxygen2: do not edit by hand
+% Please edit documentation in R/edge.R
+\name{make_edges_follow_indices}
+\alias{make_edges_follow_indices}
+\title{Match the direction of edge geometries to their specified incident nodes}
+\usage{
+make_edges_follow_indices(x)
+}
+\arguments{
+\item{x}{An object of class \code{\link{sfnetwork}}.}
+}
+\value{
+An object of class \code{\link{sfnetwork}} with updated edge
+geometries.
+}
+\description{
+This function updates edge geometries in undirected networks such that they
+are guaranteed to start at their specified *from* node and end at their
+specified *to* node.
+}
+\details{
+In undirected spatial networks it is required that the boundary of
+edge geometries contain their incident node geometries. However, it is not
+required that their start point equals their specified *from* node and their
+end point their specified *to* node. Instead, it may be vice versa. This is
+because for undirected networks *from* and *to* indices are always swopped
+if the *to* index is lower than the *from* index.
+
+This function reverses edge geometries if they start at the *to* node and
+end at the *from* node, such that in the resulting network it is guaranteed
+that edge boundary points exactly match their incident node geometries. In
+directed networks, there will be no change.
+}
diff --git a/man/make_edges_implicit.Rd b/man/make_edges_implicit.Rd
new file mode 100644
index 00000000..f203e85b
--- /dev/null
+++ b/man/make_edges_implicit.Rd
@@ -0,0 +1,23 @@
+% Generated by roxygen2: do not edit by hand
+% Please edit documentation in R/edge.R
+\name{make_edges_implicit}
+\alias{make_edges_implicit}
+\title{Drop edge geometries of spatially explicit networks}
+\usage{
+make_edges_implicit(x)
+}
+\arguments{
+\item{x}{An object of class \code{\link{sfnetwork}}.}
+}
+\value{
+An object of class \code{\link{sfnetwork}} with spatially implicit
+edges.
+}
+\description{
+This function turns spatially explicit networks into spatially implicit
+networks by dropping the geometry column of the edge data.
+}
+\note{
+If the network is already spatially implicit it is returned
+unmodified.
+}
diff --git a/man/make_edges_mixed.Rd b/man/make_edges_mixed.Rd
new file mode 100644
index 00000000..da78c0ce
--- /dev/null
+++ b/man/make_edges_mixed.Rd
@@ -0,0 +1,23 @@
+% Generated by roxygen2: do not edit by hand
+% Please edit documentation in R/edge.R
+\name{make_edges_mixed}
+\alias{make_edges_mixed}
+\title{Make some edges directed and some undirected}
+\usage{
+make_edges_mixed(x, directed)
+}
+\arguments{
+\item{x}{An object of class \code{\link{sfnetwork}}.}
+
+\item{directed}{An integer vector of edge indices specifying those edges
+that should be directed.}
+}
+\value{
+A mixed network as object of class \code{\link{sfnetwork}}.
+}
+\description{
+This function creates a mixed network, meaning that some edges are directed,
+and some are undirected. In practice this is implemented as a directed
+network in which those edges that are meant to be undirected are duplicated
+and reversed.
+}
diff --git a/man/make_edges_valid.Rd b/man/make_edges_valid.Rd
new file mode 100644
index 00000000..05973450
--- /dev/null
+++ b/man/make_edges_valid.Rd
@@ -0,0 +1,41 @@
+% Generated by roxygen2: do not edit by hand
+% Please edit documentation in R/edge.R
+\name{make_edges_valid}
+\alias{make_edges_valid}
+\title{Match edge geometries to their incident node locations}
+\usage{
+make_edges_valid(x, preserve_geometries = FALSE)
+}
+\arguments{
+\item{x}{An object of class \code{\link{sfnetwork}}.}
+
+\item{preserve_geometries}{Should the edge geometries remain unmodified?
+Defaults to \code{FALSE}. See Details.}
+}
+\value{
+An object of class \code{\link{sfnetwork}} with corrected edge
+geometries.
+}
+\description{
+This function makes invalid edges valid by modifying either edge or node
+geometries such that the boundary points of edge linestring geometries
+always match the point geometries of the nodes that are specified as their
+incident nodes by the *from* and *to* columns.
+}
+\details{
+If geometries should be preserved, edges are made valid by adding
+edge boundary points that do not equal their corresponding node geometry as
+new nodes to the network, and updating the *from* and *to* indices to match
+this newly added nodes. If \code{FALSE}, edges are made valid by modifying
+their geometries, i.e. edge boundary points that do not equal their
+corresponding node geometry are replaced by that node geometry.
+}
+\note{
+This function works only if the edge geometries are meant to start at
+their specified *from* node and end at their specified *to* node. In
+undirected networks this is not necessarily the case, since edge geometries
+are allowed to start at their specified *to* node and end at their specified
+*from* node. Therefore, in undirected networks those edges first have to be
+reversed before running this function. Use
+\code{\link{make_edges_follow_indices}} for this.
+}
diff --git a/man/mozart.Rd b/man/mozart.Rd
new file mode 100644
index 00000000..7eda1416
--- /dev/null
+++ b/man/mozart.Rd
@@ -0,0 +1,31 @@
+% Generated by roxygen2: do not edit by hand
+% Please edit documentation in R/mozart.R
+\docType{data}
+\name{mozart}
+\alias{mozart}
+\title{Point locations for places about W. A. Mozart in Salzburg, Austria}
+\format{
+An object of class \code{\link[sf]{sf}} with \code{LINESTRING}
+geometries, containing 17 features and four columns:
+\describe{
+ \item{name}{the name of the point location}
+ \item{type}{the type of location, e.g. museum, artwork, cinema, etc.}
+ \item{website}{the website URL for more information
+ about the place, if available}
+ \item{geometry}{the geometry list column}
+}
+}
+\source{
+\url{https://www.openstreetmap.org}
+}
+\usage{
+mozart
+}
+\description{
+A dataset containing point locations (museums, sculptures, squares,
+universities, etc.) of places named after Wolfgang Amadeus Mozart
+in the city of Salzburg, Austria.
+The data are taken from OpenStreetMap.
+See `data-raw/mozart.R` for code on its creation.
+}
+\keyword{datasets}
diff --git a/man/n.Rd b/man/n.Rd
new file mode 100644
index 00000000..179ff00d
--- /dev/null
+++ b/man/n.Rd
@@ -0,0 +1,32 @@
+% Generated by roxygen2: do not edit by hand
+% Please edit documentation in R/data.R
+\name{n}
+\alias{n}
+\alias{n_nodes}
+\alias{n_edges}
+\title{Count the number of nodes or edges in a network}
+\usage{
+n_nodes(x, focused = FALSE)
+
+n_edges(x, focused = FALSE)
+}
+\arguments{
+\item{x}{An object of class \code{\link{sfnetwork}}, or any other network
+object inheriting from \code{\link[igraph]{igraph}}.}
+
+\item{focused}{Should only features that are in focus be counted? Defaults
+to \code{FALSE}. See \code{\link[tidygraph]{focus}} for more information on
+focused networks.}
+}
+\value{
+An integer.
+}
+\description{
+Count the number of nodes or edges in a network
+}
+\examples{
+net = as_sfnetwork(roxel)
+n_nodes(net)
+n_edges(net)
+
+}
diff --git a/man/nb.Rd b/man/nb.Rd
new file mode 100644
index 00000000..8f5dff17
--- /dev/null
+++ b/man/nb.Rd
@@ -0,0 +1,64 @@
+% Generated by roxygen2: do not edit by hand
+% Please edit documentation in R/nb.R
+\name{nb}
+\alias{nb}
+\alias{nb_to_sfnetwork}
+\alias{sfnetwork_to_nb}
+\title{Conversion between neighbor lists and sfnetworks}
+\usage{
+nb_to_sfnetwork(
+ x,
+ nodes,
+ directed = TRUE,
+ edges_as_lines = TRUE,
+ compute_length = FALSE,
+ force = FALSE
+)
+
+sfnetwork_to_nb(x, direction = "out")
+}
+\arguments{
+\item{x}{For the conversion to sfnetwork: a neighbor list, which is a list
+adjacent to. For the conversion from sfnetwork: an object of class
+\code{\link{sfnetwork}}.}
+
+\item{nodes}{The nodes themselves as an object of class \code{\link[sf]{sf}}
+or \code{\link[sf]{sfc}} with \code{POINT} geometries.}
+
+\item{directed}{Should the constructed network be directed? Defaults to
+\code{TRUE}.}
+
+\item{edges_as_lines}{Should the created edges be spatially explicit, i.e.
+have \code{LINESTRING} geometries stored in a geometry list column? Defaults
+to \code{TRUE}.}
+
+\item{compute_length}{Should the geographic length of the edges be stored in
+a column named \code{length}? Defaults to \code{FALSE}.}
+
+\item{force}{Should validity checks be skipped? Defaults to \code{FALSE},
+meaning that network validity checks are executed when constructing the
+network. These checks make sure that the provided neighbor list has a valid
+structure, i.e. that its length is equal to the number of provided nodes and
+that its values are all integers referring to one of the nodes.}
+
+\item{direction}{The direction that defines if two nodes are neighbors.
+Defaults to \code{'out'}, meaning that the direction given by the network is
+followed and node j is only a neighbor of node i if there exists an edge
+i->j. May be set to \code{'in'}, meaning that the opposite direction is
+followed and node j is only a neighbor of node i if there exists an edge
+j->i. May also be set to \code{'all'}, meaning that the network is
+considered to be undirected. This argument is ignored for undirected
+networks.}
+}
+\value{
+For the conversion to sfnetwork: An object of class
+\code{\link{sfnetwork}}. For the conversion from sfnetwork: a neighbor list,
+which is a list with one element per node that holds the integer indices of
+the nodes it is adjacent to.
+}
+\description{
+Neighbor lists are sparse adjacency matrices in list format that specify for
+each node to which other nodes it is adjacent. They occur for example in the
+\pkg{sf} package as \code{\link[sf]{sgbp}} objects, and are also
+frequently used in the \pkg{spdep} package.
+}
diff --git a/man/nearest.Rd b/man/nearest.Rd
new file mode 100644
index 00000000..7b67a47c
--- /dev/null
+++ b/man/nearest.Rd
@@ -0,0 +1,58 @@
+% Generated by roxygen2: do not edit by hand
+% Please edit documentation in R/nearest.R
+\name{nearest}
+\alias{nearest}
+\alias{nearest_nodes}
+\alias{nearest_edges}
+\title{Extract the nearest nodes or edges to given spatial features}
+\usage{
+nearest_nodes(x, y, focused = TRUE)
+
+nearest_edges(x, y, focused = TRUE)
+}
+\arguments{
+\item{x}{An object of class \code{\link{sfnetwork}}.}
+
+\item{y}{Spatial features as object of class \code{\link[sf]{sf}} or
+\code{\link[sf]{sfc}}.}
+
+\item{focused}{Should only features that are in focus be extracted? Defaults
+to \code{TRUE}. See \code{\link[tidygraph]{focus}} for more information on
+focused networks.}
+}
+\value{
+An object of class \code{\link[sf]{sf}} with each row containing
+the nearest node or edge to the corresponding spatial features in \code{y}.
+}
+\description{
+Extract the nearest nodes or edges to given spatial features
+}
+\details{
+To determine the nearest node or edge to each feature in \code{y}
+the function \code{\link[sf]{st_nearest_feature}} is used. When extracting
+nearest edges, spatially explicit edges are required, i.e. the edges table
+should have a geometry column.
+}
+\examples{
+library(sf, quietly = TRUE)
+
+net = as_sfnetwork(roxel)
+pts = st_sample(st_bbox(roxel), 6)
+
+nodes = nearest_nodes(net, pts)
+edges = nearest_edges(net, pts)
+
+oldpar = par(no.readonly = TRUE)
+par(mar = c(1,1,1,1), mfrow = c(1,2))
+
+plot(net, main = "Nearest nodes")
+plot(pts, cex = 2, col = "red", pch = 20, add = TRUE)
+plot(st_geometry(nodes), cex = 2, col = "orange", pch = 20, add = TRUE)
+
+plot(net, main = "Nearest edges")
+plot(pts, cex = 2, col = "red", pch = 20, add = TRUE)
+plot(st_geometry(edges), lwd = 2, col = "orange", pch = 20, add = TRUE)
+
+par(oldpar)
+
+}
diff --git a/man/nearest_ids.Rd b/man/nearest_ids.Rd
new file mode 100644
index 00000000..46548865
--- /dev/null
+++ b/man/nearest_ids.Rd
@@ -0,0 +1,45 @@
+% Generated by roxygen2: do not edit by hand
+% Please edit documentation in R/nearest.R
+\name{nearest_ids}
+\alias{nearest_ids}
+\alias{nearest_node_ids}
+\alias{nearest_edge_ids}
+\title{Extract the indices of nearest nodes or edges to given spatial features}
+\usage{
+nearest_node_ids(x, y, focused = TRUE)
+
+nearest_edge_ids(x, y, focused = TRUE)
+}
+\arguments{
+\item{x}{An object of class \code{\link{sfnetwork}}.}
+
+\item{y}{Spatial features as object of class \code{\link[sf]{sf}} or
+\code{\link[sf]{sfc}}.}
+
+\item{focused}{Should only the indices of features that are in focus be
+extracted? Defaults to \code{TRUE}. See \code{\link[tidygraph]{focus}} for
+more information on focused networks.}
+}
+\value{
+An integer vector with each element containing the index of the
+nearest node or edge to the corresponding spatial features in \code{y}.
+}
+\description{
+Extract the indices of nearest nodes or edges to given spatial features
+}
+\details{
+To determine the nearest node or edge to each feature in \code{y}
+the function \code{\link[sf]{st_nearest_feature}} is used. When extracting
+nearest edges, spatially explicit edges are required, i.e. the edges table
+should have a geometry column.
+}
+\examples{
+library(sf, quietly = TRUE)
+
+net = as_sfnetwork(roxel)
+pts = st_sample(st_bbox(roxel), 6)
+
+nearest_node_ids(net, pts)
+nearest_edge_ids(net, pts)
+
+}
diff --git a/man/node_coordinates.Rd b/man/node_coordinates.Rd
index 9a2e915f..c800564c 100644
--- a/man/node_coordinates.Rd
+++ b/man/node_coordinates.Rd
@@ -45,8 +45,8 @@ library(tidygraph, quietly = TRUE)
net = as_sfnetwork(roxel)
# Use query function in a filter call.
-filtered = net \%>\%
- activate("nodes") \%>\%
+filtered = net |>
+ activate(nodes) |>
filter(node_X() > 7.54)
oldpar = par(no.readonly = TRUE)
@@ -56,8 +56,12 @@ plot(filtered, col = "red", add = TRUE)
par(oldpar)
# Use query function in a mutate call.
-net \%>\%
- activate("nodes") \%>\%
+net |>
+ activate(nodes) |>
mutate(X = node_X(), Y = node_Y())
+# Use query function directly.
+X = with_graph(net, node_X())
+head(X)
+
}
diff --git a/man/play_spatial.Rd b/man/play_spatial.Rd
new file mode 100644
index 00000000..fffbfc87
--- /dev/null
+++ b/man/play_spatial.Rd
@@ -0,0 +1,81 @@
+% Generated by roxygen2: do not edit by hand
+% Please edit documentation in R/create.R
+\name{play_spatial}
+\alias{play_spatial}
+\alias{play_geometric}
+\title{Create random spatial networks}
+\usage{
+play_geometric(
+ n,
+ radius,
+ bounds = NULL,
+ edges_as_lines = TRUE,
+ compute_length = FALSE,
+ ...
+)
+}
+\arguments{
+\item{n}{The number of nodes to be sampled.}
+
+\item{radius}{The radius within which nodes will be connected by an edge.}
+
+\item{bounds}{The spatial features within which the nodes should be sampled
+as object of class \code{\link[sf]{sf}}, \code{\link[sf]{sfc}},
+\code{\link[sf:st]{sfg}} or \code{\link[sf:st_bbox]{bbox}}. If set to
+\code{NULL}, nodes will be sampled within a unit square.}
+
+\item{edges_as_lines}{Should the created edges be spatially explicit, i.e.
+have \code{LINESTRING} geometries stored in a geometry list column? Defaults
+to \code{TRUE}.}
+
+\item{compute_length}{Should the geographic length of the edges be stored in
+a column named \code{length}? Uses \code{\link[sf]{st_length}} to compute
+the length of the edge geometries when edges are spatially explicit, and
+\code{\link[sf]{st_distance}} to compute the distance between boundary nodes
+when edges are spatially implicit. Please note that the values in this
+column are \strong{not} automatically recognized as edge weights. This needs
+to be specified explicitly when calling a function that uses edge weights.
+Defaults to \code{FALSE}.}
+
+\item{...}{Additional arguments passed on to \code{\link[sf]{st_sample}}.
+Ignored if \code{bounds = NULL}.}
+}
+\description{
+Random spatial networks are created by randomly sampling nodes within a
+given area, and connecting them by edges according to a specified method.
+}
+\section{Functions}{
+\itemize{
+\item \code{play_geometric()}: Creates a random geometric graph. Two nodes will be
+connected by an edge if the distance between them is within the given radius.
+If nodes are sampled on a unit square (i.e. when \code{bounds = NULL}) this
+radius is unitless. If bounds are given as a spatial feature, the radius is
+assumed to be in meters for geographic coordinates, and in the units of the
+coordinate reference system for projected coordinates. Alternatively, units
+can also be specified explicitly by providing a \code{\link[units]{units}}
+object.
+
+}}
+\examples{
+library(sf, quietly = TRUE)
+
+oldpar = par(no.readonly = TRUE)
+par(mar = c(1,1,1,1))
+
+# Sample 10 nodes on a unit square
+# Connect nodes by an edge if they are within 0.25 distance from each other
+net = play_geometric(10, 0.25)
+net
+
+plot(net)
+
+# Sample 10 nodes within a spatial bounding box
+# Connect nodes by an edge if they are within 1 km from each other
+net = play_geometric(10, units::set_units(1, "km"), bounds = st_bbox(roxel))
+net
+
+plot(net)
+
+par(oldpar)
+
+}
diff --git a/man/plot.sfnetwork.Rd b/man/plot.sfnetwork.Rd
index b814dd3f..cad12722 100644
--- a/man/plot.sfnetwork.Rd
+++ b/man/plot.sfnetwork.Rd
@@ -2,9 +2,9 @@
% Please edit documentation in R/plot.R
\name{plot.sfnetwork}
\alias{plot.sfnetwork}
-\title{Plot sfnetwork geometries}
+\title{Plot the geometries of a sfnetwork}
\usage{
-\method{plot}{sfnetwork}(x, draw_lines = TRUE, ...)
+\method{plot}{sfnetwork}(x, draw_lines = TRUE, node_args = list(), edge_args = list(), ...)
}
\arguments{
\item{x}{Object of class \code{\link{sfnetwork}}.}
@@ -13,41 +13,60 @@
straight lines be drawn between connected nodes? Defaults to \code{TRUE}.
Ignored when the edges of the network are spatially explicit.}
-\item{...}{Arguments passed on to \code{\link[sf:plot]{plot.sf}}}
+\item{node_args}{A named list of arguments that will be passed on to
+\code{\link[sf:plot]{plot.sf}} only for plotting the nodes.}
+
+\item{edge_args}{A named list of arguments that will be passed on to
+\code{\link[sf:plot]{plot.sf}} only for plotting the edges.}
+
+\item{...}{Arguments passed on to \code{\link[sf:plot]{plot.sf}} that will
+apply to the plot as a whole.}
}
\value{
-This is a plot method and therefore has no visible return value.
+Invisible.
}
\description{
Plot the geometries of an object of class \code{\link{sfnetwork}}.
}
\details{
-This is a basic plotting functionality. For more advanced plotting,
-it is recommended to extract the nodes and edges from the network, and plot
-them separately with one of the many available spatial plotting functions
-as can be found in \code{sf}, \code{tmap}, \code{ggplot2}, \code{ggspatial},
-and others.
+Arguments passed to \code{...} will be used both for plotting the
+nodes and for plotting the edges. Edges are always plotted first. Arguments
+specified in \code{node_args} and \code{edge_args} should not be specified
+in \code{...} as well, this will result in an error.
}
\examples{
+library(sf, quietly = TRUE)
+
oldpar = par(no.readonly = TRUE)
par(mar = c(1,1,1,1), mfrow = c(1,1))
net = as_sfnetwork(roxel)
plot(net)
-# When lines are spatially implicit.
+# When edges are spatially implicit.
+# By default straight lines will be drawn between connected nodes.
par(mar = c(1,1,1,1), mfrow = c(1,2))
-net = as_sfnetwork(roxel, edges_as_lines = FALSE)
-plot(net)
-plot(net, draw_lines = FALSE)
+inet = st_drop_geometry(activate(net, "edges"))
+plot(inet)
+plot(inet, draw_lines = FALSE)
-# Changing default settings.
+# Changing plot settings.
par(mar = c(1,1,1,1), mfrow = c(1,1))
-plot(net, col = 'blue', pch = 18, lwd = 1, cex = 2)
+plot(net, main = "My network", col = "blue", pch = 18, lwd = 1, cex = 2)
+
+# Changing plot settings for nodes and edges separately.
+plot(net, node_args = list(col = "red"), edge_args = list(col = "blue"))
# Add grid and axis
par(mar = c(2.5,2.5,1,1))
plot(net, graticule = TRUE, axes = TRUE)
+# Plot two networks on top of each other.
+par(mar = c(1,1,1,1), mfrow = c(1,1))
+neta = as_sfnetwork(roxel[1:10, ])
+netb = as_sfnetwork(roxel[50:60, ])
+plot(neta)
+plot(netb, node_args = list(col = "orange"), add = TRUE)
+
par(oldpar)
}
diff --git a/man/reexports.Rd b/man/reexports.Rd
index 0aeda644..325455d9 100644
--- a/man/reexports.Rd
+++ b/man/reexports.Rd
@@ -5,6 +5,12 @@
\alias{reexports}
\alias{activate}
\alias{active}
+\alias{morph}
+\alias{unmorph}
+\alias{convert}
+\alias{crystallize}
+\alias{crystallise}
+\alias{with_graph}
\alias{\%>\%}
\title{Objects exported from other packages}
\keyword{internal}
@@ -13,6 +19,6 @@ These objects are imported from other packages. Follow the links
below to see their documentation.
\describe{
- \item{tidygraph}{\code{\link[tidygraph:reexports]{\%>\%}}, \code{\link[tidygraph]{activate}}, \code{\link[tidygraph:activate]{active}}}
+ \item{tidygraph}{\code{\link[tidygraph:reexports]{\%>\%}}, \code{\link[tidygraph]{activate}}, \code{\link[tidygraph:activate]{active}}, \code{\link[tidygraph:morph]{convert}}, \code{\link[tidygraph:morph]{crystallise}}, \code{\link[tidygraph:morph]{crystallize}}, \code{\link[tidygraph]{morph}}, \code{\link[tidygraph:morph]{unmorph}}, \code{\link[tidygraph]{with_graph}}}
}}
diff --git a/man/roxel.Rd b/man/roxel.Rd
index 2e9ad8f0..36536e1b 100644
--- a/man/roxel.Rd
+++ b/man/roxel.Rd
@@ -6,7 +6,7 @@
\title{Road network of Münster Roxel}
\format{
An object of class \code{\link[sf]{sf}} with \code{LINESTRING}
-geometries, containing 851 features and three columns:
+geometries, containing 1215 features and three columns:
\describe{
\item{name}{the name of the road, if it exists}
\item{type}{the type of the road, e.g. cycleway}
@@ -22,7 +22,7 @@ roxel
\description{
A dataset containing the road network (roads, bikelanes, footpaths, etc.) of
Roxel, a neighborhood in the city of Münster, Germany. The data are taken
-from OpenStreetMap, querying by key = 'highway'. The topology is cleaned with
-the v.clean tool in GRASS GIS.
+from OpenStreetMap, querying by key = 'highway'.
+See `data-raw/roxel.R` for code on its creation.
}
\keyword{datasets}
diff --git a/man/s2.Rd b/man/s2.Rd
deleted file mode 100644
index fc1d712c..00000000
--- a/man/s2.Rd
+++ /dev/null
@@ -1,17 +0,0 @@
-% Generated by roxygen2: do not edit by hand
-% Please edit documentation in R/s2.R
-\name{s2}
-\alias{s2}
-\alias{as_s2_geography.sfnetwork}
-\title{s2 methods for sfnetworks}
-\usage{
-as_s2_geography.sfnetwork(x, ...)
-}
-\arguments{
-\item{x}{An object of class \code{\link{sfnetwork}}.}
-
-\item{...}{Arguments passed on the corresponding \code{s2} function.}
-}
-\description{
-s2 methods for sfnetworks
-}
diff --git a/man/sf_attr.Rd b/man/sf_attr.Rd
index 9fe68731..7b666166 100644
--- a/man/sf_attr.Rd
+++ b/man/sf_attr.Rd
@@ -9,24 +9,20 @@ sf_attr(x, name, active = NULL)
\arguments{
\item{x}{An object of class \code{\link{sfnetwork}}.}
-\item{name}{Name of the attribute to query. Either \code{'sf_column'} or
-\code{'agr'}.}
+\item{name}{Name of the attribute to query. Either \code{'sf_column'} to
+extract the name of the geometry list column, or \code{'agr'} to extract the
+specification of attribute-geometry relationships.}
\item{active}{Which network element (i.e. nodes or edges) to activate before
extracting. If \code{NULL}, it will be set to the current active element of
the given network. Defaults to \code{NULL}.}
}
\value{
-The value of the attribute matched, or \code{NULL} if no exact
-match is found.
+The value of the queried attribute.
}
\description{
Query sf attributes from the active element of a sfnetwork
}
-\details{
-sf attributes include \code{sf_column} (the name of the sf column)
-and \code{agr} (the attribute-geometry-relationships).
-}
\examples{
net = as_sfnetwork(roxel)
sf_attr(net, "agr", active = "edges")
diff --git a/man/sf.Rd b/man/sf_methods.Rd
similarity index 52%
rename from man/sf.Rd
rename to man/sf_methods.Rd
index dc060d87..4645a5b7 100644
--- a/man/sf.Rd
+++ b/man/sf_methods.Rd
@@ -1,9 +1,8 @@
% Generated by roxygen2: do not edit by hand
% Please edit documentation in R/sf.R
-\name{sf}
-\alias{sf}
+\name{sf_methods}
+\alias{sf_methods}
\alias{st_as_sf.sfnetwork}
-\alias{st_as_s2.sfnetwork}
\alias{st_geometry.sfnetwork}
\alias{st_geometry<-.sfnetwork}
\alias{st_drop_geometry.sfnetwork}
@@ -11,6 +10,7 @@
\alias{st_coordinates.sfnetwork}
\alias{st_is.sfnetwork}
\alias{st_is_valid.sfnetwork}
+\alias{st_as_s2.sfnetwork}
\alias{st_crs.sfnetwork}
\alias{st_crs<-.sfnetwork}
\alias{st_precision.sfnetwork}
@@ -25,6 +25,7 @@
\alias{st_agr.sfnetwork}
\alias{st_agr<-.sfnetwork}
\alias{st_reverse.sfnetwork}
+\alias{st_segmentize.sfnetwork}
\alias{st_simplify.sfnetwork}
\alias{st_join.sfnetwork}
\alias{st_join.morphed_sfnetwork}
@@ -42,11 +43,9 @@
\alias{st_area.sfnetwork}
\title{sf methods for sfnetworks}
\usage{
-\method{st_as_sf}{sfnetwork}(x, active = NULL, ...)
-
-\method{st_as_s2}{sfnetwork}(x, active = NULL, ...)
+\method{st_as_sf}{sfnetwork}(x, active = NULL, focused = TRUE, ...)
-\method{st_geometry}{sfnetwork}(obj, active = NULL, ...)
+\method{st_geometry}{sfnetwork}(obj, active = NULL, focused = TRUE, ...)
\method{st_geometry}{sfnetwork}(x) <- value
@@ -60,6 +59,8 @@
\method{st_is_valid}{sfnetwork}(x, ...)
+\method{st_as_s2}{sfnetwork}(x, active = NULL, focused = TRUE, ...)
+
\method{st_crs}{sfnetwork}(x, ...)
\method{st_crs}{sfnetwork}(x) <- value
@@ -88,9 +89,11 @@
\method{st_reverse}{sfnetwork}(x, ...)
+\method{st_segmentize}{sfnetwork}(x, ...)
+
\method{st_simplify}{sfnetwork}(x, ...)
-\method{st_join}{sfnetwork}(x, y, ...)
+\method{st_join}{sfnetwork}(x, y, ..., ignore_multiple = TRUE)
\method{st_join}{morphed_sfnetwork}(x, y, ...)
@@ -125,6 +128,10 @@
extracting. If \code{NULL}, it will be set to the current active element of
the given network. Defaults to \code{NULL}.}
+\item{focused}{Should only features that are in focus be extracted? Defaults
+to \code{TRUE}. See \code{\link[tidygraph]{focus}} for more information on
+focused networks.}
+
\item{...}{Arguments passed on the corresponding \code{sf} function.}
\item{obj}{An object of class \code{\link{sfnetwork}}.}
@@ -139,43 +146,108 @@ corresponding sf function for details.}
it using \code{\link[sf]{st_as_sf}}. In some cases, it can also be an object
of \code{\link[sf:st]{sfg}} or \code{\link[sf:st_bbox]{bbox}}. Always look
at the documentation of the corresponding \code{sf} function for details.}
+
+\item{ignore_multiple}{When performing a spatial join with the nodes
+table, and there are multiple matches for a single node, only the first one
+of them is joined into the network. But what should happen with the others?
+If this argument is set to \code{TRUE}, they will be ignored. If this
+argument is set to \code{FALSE}, they will be added as isolated nodes to the
+returned network. Nodes at equal locations can then be merged using the
+spatial morpher \code{\link{to_spatial_unique}}. Defaults to \code{TRUE}.}
}
\value{
-The \code{sfnetwork} method for \code{\link[sf]{st_as_sf}} returns
-the active element of the network as object of class \code{\link[sf]{sf}}.
-The \code{sfnetwork} and \code{morphed_sfnetwork} methods for
-\code{\link[sf]{st_join}}, \code{\link[sf]{st_filter}},
-\code{\link[sf]{st_intersection}}, \code{\link[sf]{st_difference}},
-\code{\link[sf]{st_crop}} and the setter functions
- return an object of class \code{\link{sfnetwork}}
-and \code{morphed_sfnetwork} respectively. All other
-methods return the same type of objects as their corresponding sf function.
-See the \code{\link[sf]{sf}} documentation for details.
+The methods for \code{\link[sf]{st_join}},
+\code{\link[sf]{st_filter}}, \code{\link[sf]{st_intersection}},
+\code{\link[sf]{st_difference}} and \code{\link[sf]{st_crop}}, as well as
+the methods for all setter functions and the geometric unary operations
+preserve the class of the object it is applied to, i.e. either a
+\code{\link{sfnetwork}} object or its morphed equivalent. When dropping node
+geometries, an object of class \code{\link[tidygraph]{tbl_graph}} is
+returned. All other methods return the same type of objects as their
+corresponding sf function. See the \code{\link[sf]{sf}} documentation for
+details.
}
\description{
\code{\link[sf]{sf}} methods for \code{\link{sfnetwork}} objects.
}
\details{
-See the \code{\link[sf]{sf}} documentation.
+See the \code{\link[sf]{sf}} documentation. The following methods
+have a special behavior:
+
+\itemize{
+ \item \code{st_geometry<-}: The geometry setter requires the replacement
+ geometries to have the same CRS as the network. Node replacements should
+ all be points, while edge replacements should all be linestrings. When
+ replacing node geometries, the boundaries of the edge geometries are
+ replaced as well to preserve the valid spatial network structure. When
+ replacing edge geometries, new edge boundaries that do not match the
+ location of their specified incident node are added as new nodes to the
+ network.
+ \item \code{st_transform}: No matter if applied to the nodes or edge
+ table, this method will update the coordinates of both tables. The same
+ holds for all other methods that update the way in which the coordinates
+ are encoded without changing their actual location, such as
+ \code{st_precision}, \code{st_normalize}, \code{st_zm}, and others.
+ \item \code{st_join}: When applied to the nodes table and multiple matches
+ exist for the same node, only the first match is joined. A warning will be
+ given in this case. If \code{ignore_multiple = FALSE}, multiple mathces
+ are instead added as isolated nodes to the returned network.
+ \item \code{st_intersection}, \code{st_difference} and \code{st_crop}:
+ These methods clip edge geometries when applied to the edges table. To
+ preserve a valid spatial network structure, clipped edge boundaries are
+ added as new nodes to the network.
+ \item \code{st_reverse}: When reversing edge geometries in a directed
+ network, the indices in the from and to columns will be swapped as well.
+ \item \code{st_segmentize}: When segmentizing edge geometries, the edge
+ boundaries are forced to remain the same such that the valid spatial
+ network structure is preserved. This may lead to slightly inaccurate
+ results.
+}
+
+Geometric unary operations are only supported on \code{\link{sfnetwork}}
+objects if they do not change the geometry type nor the spatial location
+of the original features, since that would break the valid spatial network
+structure. When applying the unsupported operations, first extract the
+element of interest (nodes or edges) using \code{\link[sf]{st_as_sf}}.
}
\examples{
library(sf, quietly = TRUE)
+oldpar = par(no.readonly = TRUE)
+par(mar = c(1,1,1,1), mfrow = c(1,2))
+
net = as_sfnetwork(roxel)
-# Extract the active network element.
+# Extract the active network element as sf object.
st_as_sf(net)
-# Extract any network element.
+# Extract any network element as sf object.
st_as_sf(net, "edges")
-# Get geometry of the active network element.
+# Get the geometry of the active network element.
st_geometry(net)
-# Get geometry of any network element.
+# Get the geometry of any network element.
st_geometry(net, "edges")
-# Get bbox of the active network element.
+# Replace the geometry of the nodes.
+# This will automatically update edge geometries to match the new nodes.
+newnet = net
+newnds = rep(st_centroid(st_combine(st_geometry(net))), n_nodes(net))
+st_geometry(newnet) = newnds
+
+plot(net)
+plot(newnet)
+
+# Drop the geometries of the edges.
+# This returns an sfnetwork with spatially implicit edges.
+st_drop_geometry(activate(net, "edges"))
+
+# Drop the geometries of the nodes.
+# This returns a tbl_graph.
+st_drop_geometry(net)
+
+# Get the bounding box of the active network element.
st_bbox(net)
# Get CRS of the network.
@@ -195,31 +267,31 @@ codes$post_code = as.character(seq(1000, 1000 + nrow(codes) * 10 - 10, 10))
joined = st_join(net, codes, join = st_intersects)
joined
-oldpar = par(no.readonly = TRUE)
-par(mar = c(1,1,1,1), mfrow = c(1,2))
plot(net, col = "grey")
plot(codes, col = NA, border = "red", lty = 4, lwd = 4, add = TRUE)
text(st_coordinates(st_centroid(st_geometry(codes))), codes$post_code)
+
plot(st_geometry(joined, "edges"))
plot(st_as_sf(joined, "nodes"), pch = 20, add = TRUE)
par(oldpar)
+
# Spatial filter applied to the active network element.
p1 = st_point(c(4151358, 3208045))
p2 = st_point(c(4151340, 3207520))
p3 = st_point(c(4151756, 3207506))
p4 = st_point(c(4151774, 3208031))
-poly = st_multipoint(c(p1, p2, p3, p4)) \%>\%
- st_cast('POLYGON') \%>\%
- st_sfc(crs = 3035) \%>\%
+poly = st_multipoint(c(p1, p2, p3, p4)) |>
+ st_cast('POLYGON') |>
+ st_sfc(crs = 3035) |>
st_as_sf()
filtered = st_filter(net, poly, .pred = st_intersects)
-oldpar = par(no.readonly = TRUE)
-par(mar = c(1,1,1,1), mfrow = c(1,2))
plot(net, col = "grey")
plot(poly, border = "red", lty = 4, lwd = 4, add = TRUE)
plot(filtered)
+
par(oldpar)
+
}
diff --git a/man/sfnetwork.Rd b/man/sfnetwork.Rd
index 39cd8fd5..83e67956 100644
--- a/man/sfnetwork.Rd
+++ b/man/sfnetwork.Rd
@@ -1,5 +1,5 @@
% Generated by roxygen2: do not edit by hand
-% Please edit documentation in R/sfnetwork.R
+% Please edit documentation in R/create.R
\name{sfnetwork}
\alias{sfnetwork}
\title{Create a sfnetwork}
@@ -10,7 +10,8 @@ sfnetwork(
directed = TRUE,
node_key = "name",
edges_as_lines = NULL,
- length_as_weight = FALSE,
+ compute_length = FALSE,
+ length_as_weight = deprecated(),
force = FALSE,
message = TRUE,
...
@@ -26,9 +27,9 @@ of type \code{POINT}.}
\code{\link[sf]{sf}}, with all features having an associated geometry of
type \code{LINESTRING}. It may also be a regular \code{\link{data.frame}} or
\code{\link[tibble]{tbl_df}} object. In any case, the nodes at the ends of
-each edge must either be encoded in a \code{to} and \code{from} column, as
+each edge must be referenced in a \code{to} and \code{from} column, as
integers or characters. Integers should refer to the position of a node in
-the nodes table, while characters should refer to the name of a node encoded
+the nodes table, while characters should refer to the name of a node stored
in the column referred to in the \code{node_key} argument. Setting edges to
\code{NULL} will create a network without edges.}
@@ -46,12 +47,17 @@ represented \code{to} and \code{from} columns should be matched against. If
\code{TRUE} when the edges are given as an object of class
\code{\link[sf]{sf}}, and \code{FALSE} otherwise. Defaults to \code{NULL}.}
-\item{length_as_weight}{Should the length of the edges be stored in a column
-named \code{weight}? If set to \code{TRUE}, this will calculate the length
-of the linestring geometry of the edge in the case of spatially explicit
-edges, and the straight-line distance between the source and target node in
-the case of spatially implicit edges. If there is already a column named
-\code{weight}, it will be overwritten. Defaults to \code{FALSE}.}
+\item{compute_length}{Should the geographic length of the edges be stored in
+a column named \code{length}? Uses \code{\link[sf]{st_length}} to compute
+the length of the edge geometries when edges are spatially explicit, and
+\code{\link[sf]{st_distance}} to compute the distance between boundary nodes
+when edges are spatially implicit. If there is already a column named
+\code{length}, it will be overwritten. Please note that the values in this
+column are \strong{not} automatically recognized as edge weights. This needs
+to be specified explicitly when calling a function that uses edge weights.
+Defaults to \code{FALSE}.}
+
+\item{length_as_weight}{Deprecated, use \code{compute_length} instead.}
\item{force}{Should network validity checks be skipped? Defaults to
\code{FALSE}, meaning that network validity checks are executed when
@@ -84,7 +90,6 @@ edges embedded in geographical space, and offers smooth integration with
\examples{
library(sf, quietly = TRUE)
-## Create sfnetwork from sf objects
p1 = st_point(c(7, 51))
p2 = st_point(c(7, 52))
p3 = st_point(c(8, 52))
@@ -112,12 +117,7 @@ sfnetwork(nodes, edges, node_key = "name")
# Spatially implicit edges.
sfnetwork(nodes, edges, edges_as_lines = FALSE)
-# Store edge lenghts in a weight column.
-sfnetwork(nodes, edges, length_as_weight = TRUE)
-
-# Adjust the number of features printed by active and inactive components
-oldoptions = options(sfn_max_print_active = 1, sfn_max_print_inactive = 2)
-sfnetwork(nodes, edges)
-options(oldoptions)
+# Store edge lenghts in a column named 'length'.
+sfnetwork(nodes, edges, compute_length = TRUE)
}
diff --git a/man/sfnetwork_to_dodgr.Rd b/man/sfnetwork_to_dodgr.Rd
new file mode 100644
index 00000000..120bdeeb
--- /dev/null
+++ b/man/sfnetwork_to_dodgr.Rd
@@ -0,0 +1,44 @@
+% Generated by roxygen2: do not edit by hand
+% Please edit documentation in R/dodgr.R
+\name{sfnetwork_to_dodgr}
+\alias{sfnetwork_to_dodgr}
+\alias{dodgr_to_sfnetwork}
+\title{Conversion between dodgr streetnets and sfnetworks}
+\usage{
+dodgr_to_sfnetwork(x, edges_as_lines = TRUE)
+
+sfnetwork_to_dodgr(x, weights = edge_length(), time = FALSE)
+}
+\arguments{
+\item{x}{For the conversion to sfnetwork: an object of class
+\code{\link[dodgr]{dodgr_streetnet}}. For the conversion from sfnetwork: an
+object of class \code{\link{sfnetwork}}.}
+
+\item{edges_as_lines}{Should the created edges be spatially explicit, i.e.
+have \code{LINESTRING} geometries stored in a geometry list column? Defaults
+to \code{TRUE}.}
+
+\item{weights}{The edge weights to be stored in the dodgr streetnet.
+Evaluated by \code{\link{evaluate_weight_spec}}. The default is
+\code{\link{edge_length}}, which computes the geographic lengths of the
+edges. Dual-weights can be provided through \code{\link{dual_weights}}.}
+
+\item{time}{Are the provided weights time values? If \code{TRUE}, they will
+be stored in a column named 'time' rather than 'd'. Defaults to \code{FALSE}.}
+}
+\value{
+For the conversion to sfnetwork: An object of class
+\code{\link{sfnetwork}}. For the conversion from sfnetwork: an object of
+class \code{\link[dodgr]{dodgr_streetnet}}.
+}
+\description{
+The \code{\link[dodgr:dodgr-package]{dodgr}} package is designed for routing
+on directed graphs, and is known for its fast computations of cost matrices,
+shortest paths, and more. In sfnetwork, dodgr can be chosen as a routing
+backend.
+}
+\note{
+The \code{\link[dodgr:dodgr-package]{dodgr}} package is designed for
+directed graphs. If the provided \code{\link{sfnetwork}} object is
+undirected, it is made directed by duplicating and reversing each edge.
+}
diff --git a/man/simplify_network.Rd b/man/simplify_network.Rd
new file mode 100644
index 00000000..356f7a40
--- /dev/null
+++ b/man/simplify_network.Rd
@@ -0,0 +1,51 @@
+% Generated by roxygen2: do not edit by hand
+% Please edit documentation in R/simplify.R
+\name{simplify_network}
+\alias{simplify_network}
+\title{Simplify a spatial network}
+\usage{
+simplify_network(
+ x,
+ remove_multiple = TRUE,
+ remove_loops = TRUE,
+ attribute_summary = "first",
+ store_original_ids = FALSE,
+ store_original_data = FALSE
+)
+}
+\arguments{
+\item{x}{An object of class \code{\link{sfnetwork}}.}
+
+\item{remove_multiple}{Should multiple edges be merged into one. Defaults
+to \code{TRUE}.}
+
+\item{remove_loops}{Should loop edges be removed. Defaults to \code{TRUE}.}
+
+\item{attribute_summary}{How should the attributes of merged multiple
+edges be summarized? There are several options, see
+\code{\link[igraph]{igraph-attribute-combination}} for details.}
+
+\item{store_original_ids}{For each group of merged multiple edges, should
+the indices of the original edges be stored as an attribute of the new edge,
+in a column named \code{.tidygraph_edge_index}? This is in line with the
+design principles of \code{tidygraph}. Defaults to \code{FALSE}.}
+
+\item{store_original_data}{For each group of merged multiple edges, should
+the data of the original edges be stored as an attribute of the new edge, in
+a column named \code{.orig_data}? This is in line with the design principles
+of \code{tidygraph}. Defaults to \code{FALSE}.}
+}
+\value{
+The simple network as object of class \code{\link{sfnetwork}}.
+}
+\description{
+Construct a simple version of the network. A simple network is defined as a
+network without loop edges and multiple edges. A loop edge is an edge that
+starts and ends at the same node. Multiple edges are different edges between
+the same node pair.
+}
+\note{
+When merging groups of multiple edges into a single edge, the geometry
+of the first edge in each group is preserved. The order of the edges can be
+influenced by calling \code{\link[dplyr]{arrange}} before simplifying.
+}
diff --git a/man/smooth_pseudo_nodes.Rd b/man/smooth_pseudo_nodes.Rd
new file mode 100644
index 00000000..ae10eda7
--- /dev/null
+++ b/man/smooth_pseudo_nodes.Rd
@@ -0,0 +1,54 @@
+% Generated by roxygen2: do not edit by hand
+% Please edit documentation in R/smooth.R
+\name{smooth_pseudo_nodes}
+\alias{smooth_pseudo_nodes}
+\title{Smooth pseudo nodes}
+\usage{
+smooth_pseudo_nodes(
+ x,
+ protect = NULL,
+ require_equal = NULL,
+ attribute_summary = "ignore",
+ store_original_ids = FALSE,
+ store_original_data = FALSE
+)
+}
+\arguments{
+\item{x}{An object of class \code{\link{sfnetwork}}.}
+
+\item{protect}{An integer vector of edge indices specifying which nodes
+should be protected from being removed. Defaults to \code{NULL}, meaning
+that none of the nodes is protected.}
+
+\item{require_equal}{A character vector of edge column names specifying
+which attributes of the incident edges of a pseudo node should be equal in
+order for the pseudo node to be removed? Defaults to \code{NULL}, meaning
+that attribute equality is not considered for pseudo node removal.}
+
+\item{attribute_summary}{How should the attributes of concatenated edges
+be summarized? There are several options, see
+\code{\link[igraph]{igraph-attribute-combination}} for details.}
+
+\item{store_original_ids}{For each concatenated edge, should the indices of
+the original edges be stored as an attribute of the new edge, in a column
+named \code{.tidygraph_edge_index}? This is in line with the design
+principles of \code{tidygraph}. Defaults to \code{FALSE}.}
+
+\item{store_original_data}{For each concatenated edge, should the data of
+the original edges be stored as an attribute of the new edge, in a column
+named \code{.orig_data}? This is in line with the design principles of
+\code{tidygraph}. Defaults to \code{FALSE}.}
+}
+\value{
+The smoothed network as object of class \code{\link{sfnetwork}}.
+}
+\description{
+Construct a smoothed version of the network by iteratively removing pseudo
+nodes, while preserving the connectivity of the network. In the case of
+directed networks, pseudo nodes are those nodes that have only one incoming
+and one outgoing edge. In undirected networks, pseudo nodes are those nodes
+that have two incident edges. Equality of attribute values among the two
+edges can be defined as an additional requirement by setting the
+\code{require_equal} parameter. Connectivity of the network is preserved by
+concatenating the incident edges of each removed pseudo node.
+}
diff --git a/man/spatial_centrality.Rd b/man/spatial_centrality.Rd
new file mode 100644
index 00000000..d52d1484
--- /dev/null
+++ b/man/spatial_centrality.Rd
@@ -0,0 +1,49 @@
+% Generated by roxygen2: do not edit by hand
+% Please edit documentation in R/centrality.R
+\name{spatial_centrality}
+\alias{spatial_centrality}
+\alias{centrality_straightness}
+\title{Compute spatial centrality measures}
+\usage{
+centrality_straightness(...)
+}
+\arguments{
+\item{...}{Additional arguments passed on to other functions.}
+}
+\value{
+A numeric vector of the same length as the number of nodes in the
+network.
+}
+\description{
+These functions are a collection of centrality measures that are specific
+for spatial networks, and form a spatial extension to
+\code{\link[tidygraph:centrality]{centrality measures}} in tidygraph.
+}
+\details{
+Just as with all centrality functions in tidygraph, these functions
+are meant to be called inside tidygraph verbs such as
+\code{\link[tidygraph]{mutate}} or \code{\link[tidygraph]{filter}}, where
+the network that is currently being worked on is known and thus not needed
+as an argument to the function. If you want to use an algorithm outside of
+the tidygraph framework you can use \code{\link[tidygraph]{with_graph}} to
+set the context temporarily while the algorithm is being evaluated.
+}
+\section{Functions}{
+\itemize{
+\item \code{centrality_straightness()}: The straightness centrality of node i is the
+average ratio of Euclidean distance and network distance between node i and
+all other nodes in the network. \code{...} is forwarded to
+\code{\link{st_network_distance}} to compute the network distance matrix.
+Euclidean distances are computed using \code{\link[sf]{st_distance}}.
+
+}}
+\examples{
+library(tidygraph, quietly = TRUE)
+
+net = as_sfnetwork(roxel, directed = FALSE)
+
+net |>
+ activate(nodes) |>
+ mutate(sc = centrality_straightness())
+
+}
diff --git a/man/spatial_edge_measures.Rd b/man/spatial_edge_measures.Rd
index ee8b00da..af1d2058 100644
--- a/man/spatial_edge_measures.Rd
+++ b/man/spatial_edge_measures.Rd
@@ -6,6 +6,7 @@
\alias{edge_circuity}
\alias{edge_length}
\alias{edge_displacement}
+\alias{edge_segment_count}
\title{Query spatial edge measures}
\usage{
edge_azimuth(degrees = FALSE)
@@ -15,6 +16,8 @@ edge_circuity(Inf_as_NaN = FALSE)
edge_length()
edge_displacement()
+
+edge_segment_count()
}
\arguments{
\item{degrees}{Should the angle be returned in degrees instead of radians?
@@ -28,9 +31,7 @@ A numeric vector of the same length as the number of edges in the
graph.
}
\description{
-These functions are a collection of specific spatial edge measures, that
-form a spatial extension to edge measures in
-\code{\link[tidygraph:tidygraph-package]{tidygraph}}.
+These functions are a collection of edge measures in spatial networks.
}
\details{
Just as with all query functions in tidygraph, spatial edge
@@ -54,11 +55,17 @@ nodes, as described in Giacomin &
Levinson, 2015. DOI: 10.1068/b130131p.
\item \code{edge_length()}: The length of an edge linestring geometry
-as calculated by \code{\link[sf]{st_length}}.
+as calculated by \code{\link[sf]{st_length}}. If edges are spatially
+implicit, the straight-line distance between its boundary nodes is computed
+instead, using \code{\link[sf]{st_distance}}.
\item \code{edge_displacement()}: The straight-line distance between the two
boundary nodes of an edge, as calculated by \code{\link[sf]{st_distance}}.
+\item \code{edge_segment_count()}: The number of segments contained in the
+linestring geometry of an edge. Segments are those parts of a linestring
+geometry that do not contain any interior points.
+
}}
\examples{
library(sf, quietly = TRUE)
@@ -66,24 +73,28 @@ library(tidygraph, quietly = TRUE)
net = as_sfnetwork(roxel)
-net \%>\%
- activate("edges") \%>\%
+net |>
+ activate(edges) |>
mutate(azimuth = edge_azimuth())
-net \%>\%
- activate("edges") \%>\%
+net |>
+ activate(edges) |>
mutate(azimuth = edge_azimuth(degrees = TRUE))
-net \%>\%
- activate("edges") \%>\%
+net |>
+ activate(edges) |>
mutate(circuity = edge_circuity())
-net \%>\%
- activate("edges") \%>\%
+net |>
+ activate(edges) |>
mutate(length = edge_length())
-net \%>\%
- activate("edges") \%>\%
+net |>
+ activate(edges) |>
mutate(displacement = edge_displacement())
+net |>
+ activate(edges) |>
+ mutate(n_segs = edge_segment_count())
+
}
diff --git a/man/spatial_edge_predicates.Rd b/man/spatial_edge_predicates.Rd
index c1619b01..444ddffa 100644
--- a/man/spatial_edge_predicates.Rd
+++ b/man/spatial_edge_predicates.Rd
@@ -14,6 +14,7 @@
\alias{edge_covers}
\alias{edge_is_covered_by}
\alias{edge_is_within_distance}
+\alias{edge_is_nearest}
\title{Query edges with spatial predicates}
\usage{
edge_intersects(y, ...)
@@ -39,13 +40,16 @@ edge_covers(y, ...)
edge_is_covered_by(y, ...)
edge_is_within_distance(y, ...)
+
+edge_is_nearest(y)
}
\arguments{
\item{y}{The geospatial features to test the edges against, either as an
object of class \code{\link[sf]{sf}} or \code{\link[sf]{sfc}}.}
\item{...}{Arguments passed on to the corresponding spatial predicate
-function of sf. See \code{\link[sf]{geos_binary_pred}}.}
+function of sf. See \code{\link[sf]{geos_binary_pred}}. The argument
+\code{sparse} should not be set.}
}
\value{
A logical vector of the same length as the number of edges in the
@@ -56,20 +60,23 @@ These functions allow to interpret spatial relations between edges and
other geospatial features directly inside \code{\link[tidygraph]{filter}}
and \code{\link[tidygraph]{mutate}} calls. All functions return a logical
vector of the same length as the number of edges in the network. Element i
-in that vector is \code{TRUE} whenever \code{any(predicate(x[i], y[j]))} is
-\code{TRUE}. Hence, in the case of using \code{edge_intersects}, element i
-in the returned vector is \code{TRUE} when edge i intersects with any of
-the features given in y.
+in that vector is \code{TRUE} whenever the chosen spatial predicate applies
+to the spatial relation between the i-th edge and any of the features in
+\code{y}.
}
\details{
See \code{\link[sf]{geos_binary_pred}} for details on each spatial
-predicate. Just as with all query functions in tidygraph, these functions
-are meant to be called inside tidygraph verbs such as
-\code{\link[tidygraph]{mutate}} or \code{\link[tidygraph]{filter}}, where
-the network that is currently being worked on is known and thus not needed
-as an argument to the function. If you want to use an algorithm outside of
-the tidygraph framework you can use \code{\link[tidygraph]{with_graph}} to
-set the context temporarily while the algorithm is being evaluated.
+predicate. The function \code{edge_is_nearest} instead wraps around
+\code{\link[sf]{st_nearest_feature}} and returns \code{TRUE} for element i
+if the i-th edge is the nearest edge to any of the features in \code{y}.
+
+Just as with all query functions in tidygraph, these functions are meant to
+be called inside tidygraph verbs such as \code{\link[tidygraph]{mutate}} or
+\code{\link[tidygraph]{filter}}, where the network that is currently being
+worked on is known and thus not needed as an argument to the function. If
+you want to use an algorithm outside of the tidygraph framework you can use
+\code{\link[tidygraph]{with_graph}} to set the context temporarily while the
+algorithm is being evaluated.
}
\note{
Note that \code{edge_is_within_distance} is a wrapper around the
@@ -81,7 +88,7 @@ library(sf, quietly = TRUE)
library(tidygraph, quietly = TRUE)
# Create a network.
-net = as_sfnetwork(roxel) \%>\%
+net = as_sfnetwork(roxel) |>
st_transform(3035)
# Create a geometry to test against.
@@ -90,13 +97,13 @@ p2 = st_point(c(4151340, 3207520))
p3 = st_point(c(4151756, 3207506))
p4 = st_point(c(4151774, 3208031))
-poly = st_multipoint(c(p1, p2, p3, p4)) \%>\%
- st_cast('POLYGON') \%>\%
+poly = st_multipoint(c(p1, p2, p3, p4)) |>
+ st_cast('POLYGON') |>
st_sfc(crs = 3035)
# Use predicate query function in a filter call.
-intersects = net \%>\%
- activate(edges) \%>\%
+intersects = net |>
+ activate(edges) |>
filter(edge_intersects(poly))
oldpar = par(no.readonly = TRUE)
@@ -106,9 +113,13 @@ plot(st_geometry(intersects, "edges"), col = "red", lwd = 2, add = TRUE)
par(oldpar)
# Use predicate query function in a mutate call.
-net \%>\%
- activate(edges) \%>\%
- mutate(disjoint = edge_is_disjoint(poly)) \%>\%
+net |>
+ activate(edges) |>
+ mutate(disjoint = edge_is_disjoint(poly)) |>
select(disjoint)
+# Use predicate query function directly.
+intersects = with_graph(net, edge_intersects(poly))
+head(intersects)
+
}
diff --git a/man/spatial_morphers.Rd b/man/spatial_morphers.Rd
index 2ad55187..fa947441 100644
--- a/man/spatial_morphers.Rd
+++ b/man/spatial_morphers.Rd
@@ -5,20 +5,26 @@
\alias{to_spatial_contracted}
\alias{to_spatial_directed}
\alias{to_spatial_explicit}
+\alias{to_spatial_implicit}
+\alias{to_spatial_mixed}
\alias{to_spatial_neighborhood}
+\alias{to_spatial_reversed}
\alias{to_spatial_shortest_paths}
\alias{to_spatial_simple}
\alias{to_spatial_smooth}
\alias{to_spatial_subdivision}
\alias{to_spatial_subset}
\alias{to_spatial_transformed}
-\title{Spatial morphers for sfnetworks}
+\alias{to_spatial_unique}
+\title{Morph spatial networks into a different structure}
\usage{
to_spatial_contracted(
x,
...,
- simplify = FALSE,
- summarise_attributes = "ignore",
+ simplify = TRUE,
+ compute_centroids = TRUE,
+ attribute_summary = "ignore",
+ summarise_attributes = deprecated(),
store_original_data = FALSE
)
@@ -26,7 +32,13 @@ to_spatial_directed(x)
to_spatial_explicit(x, ...)
-to_spatial_neighborhood(x, node, threshold, weights = NULL, from = TRUE, ...)
+to_spatial_implicit(x)
+
+to_spatial_mixed(x, directed)
+
+to_spatial_neighborhood(x, node, threshold, weights = edge_length(), ...)
+
+to_spatial_reversed(x, protect = NULL)
to_spatial_shortest_paths(x, ...)
@@ -34,23 +46,27 @@ to_spatial_simple(
x,
remove_multiple = TRUE,
remove_loops = TRUE,
- summarise_attributes = "first",
+ attribute_summary = "first",
+ summarise_attributes = deprecated(),
store_original_data = FALSE
)
to_spatial_smooth(
x,
protect = NULL,
- summarise_attributes = "ignore",
- require_equal = FALSE,
+ require_equal = NULL,
+ attribute_summary = "ignore",
+ summarise_attributes = deprecated(),
store_original_data = FALSE
)
-to_spatial_subdivision(x)
+to_spatial_subdivision(x, protect = NULL, all = FALSE, merge = TRUE)
to_spatial_subset(x, ..., subset_by = NULL)
to_spatial_transformed(x, ...)
+
+to_spatial_unique(x, attribute_summary = "ignore", store_original_data = FALSE)
}
\arguments{
\item{x}{An object of class \code{\link{sfnetwork}}.}
@@ -58,68 +74,82 @@ to_spatial_transformed(x, ...)
\item{...}{Arguments to be passed on to other functions. See the description
of each morpher for details.}
-\item{simplify}{Should the network be simplified after contraction? This
-means that multiple edges and loop edges will be removed. Multiple edges
-are introduced by contraction when there are several connections between
-the same groups of nodes. Loop edges are introduced by contraction when
-there are connections within a group. Note however that setting this to
-\code{TRUE} also removes multiple edges and loop edges that already
-existed before contraction. Defaults to \code{FALSE}.}
-
-\item{summarise_attributes}{Whenever multiple features (i.e. nodes and/or
-edges) are merged into a single feature during morphing, how should their
-attributes be combined? Several options are possible, see
+\item{simplify}{Should the network be simplified after contraction? Defaults
+to \code{TRUE}. This means that multiple edges and loop edges will be
+removed. Multiple edges are introduced by contraction when there are several
+connections between the same groups of nodes. Loop edges are introduced by
+contraction when there are connections within a group. Note however that
+setting this to \code{TRUE} also removes multiple edges and loop edges that
+already existed before contraction.}
+
+\item{compute_centroids}{Should the new geometry of each contracted group of
+nodes be the centroid of all group members? Defaults to \code{TRUE}. If set
+to \code{FALSE}, the geometry of the first node in each group will be used
+instead, which requires considerably less computing time.}
+
+\item{attribute_summary}{Whenever groups of nodes or edges are merged
+into a single feature during morphing, how should their attributes be
+summarized? There are several options, see
\code{\link[igraph]{igraph-attribute-combination}} for details.}
-\item{store_original_data}{Whenever multiple features (i.e. nodes and/or
-edges) are merged into a single feature during morphing, should the data of
-the original features be stored as an attribute of the new feature, in a
-column named \code{.orig_data}. This is in line with the design principles
-of \code{tidygraph}. Defaults to \code{FALSE}.}
-
-\item{node}{The geospatial point for which the neighborhood will be
-calculated. Can be an integer, referring to the index of the node for which
-the neighborhood will be calculated. Can also be an object of class
-\code{\link[sf]{sf}} or \code{\link[sf]{sfc}}, containing a single feature.
-In that case, this point will be snapped to its nearest node before
-calculating the neighborhood. When multiple indices or features are given,
-only the first one is taken.}
-
-\item{threshold}{The threshold distance to be used. Only nodes within the
-threshold distance from the reference node will be included in the
-neighborhood. Should be a numeric value in the same units as the weight
-values used for distance calculation.}
-
-\item{weights}{The edge weights used to calculate distances on the network.
-Can be a numeric vector giving edge weights, or a column name referring to
-an attribute column in the edges table containing those weights. If set to
-\code{NULL}, the values of a column named \code{weight} in the edges table
-will be used automatically, as long as this column is present. If not, the
-geographic edge lengths will be calculated internally and used as weights.}
-
-\item{from}{Should distances be calculated from the reference node towards
-the other nodes? Defaults to \code{TRUE}. If set to \code{FALSE}, distances
-will be calculated from the other nodes towards the reference node instead.}
+\item{summarise_attributes}{Deprecated, use \code{attribute_summary} instead.}
+
+\item{store_original_data}{Whenever groups of nodes or edges are merged
+into a single feature during morphing, should the data of the original
+features be stored as an attribute of the new feature, in a column named
+\code{.orig_data}. This is in line with the design principles of
+\code{tidygraph}. Defaults to \code{FALSE}.}
+
+\item{directed}{Which edges should be directed? Evaluated by
+\code{\link{evaluate_edge_query}}.}
+
+\item{node}{The node for which the neighborhood will be calculated.
+Evaluated by \code{\link{evaluate_node_query}}. When multiple nodes are
+given, only the first one is used.}
+
+\item{threshold}{The threshold cost to be used. Only nodes reachable within
+this threshold cost from the reference node will be included in the
+neighborhood. Should be a numeric value in the same units as the given edge
+weights. Alternatively, units can be specified explicitly by providing a
+\code{\link[units]{units}} object. Multiple threshold values may be given,
+which will result in mutliple neigborhoods being returned.}
+
+\item{weights}{The edge weights to be used for travel cost computation.
+Evaluated by \code{\link{evaluate_weight_spec}}. The default is
+\code{\link{edge_length}}, which computes the geographic lengths of the
+edges.}
+
+\item{protect}{Nodes or edges to be protected from being changed in
+structure. Evaluated by \code{\link{evaluate_node_query}} in the case of
+nodes and by \code{\link{evaluate_edge_query}} in the case of edges.
+Defaults to \code{NULL}, meaning that no features are protected.}
\item{remove_multiple}{Should multiple edges be merged into one. Defaults
to \code{TRUE}.}
\item{remove_loops}{Should loop edges be removed. Defaults to \code{TRUE}.}
-\item{protect}{Nodes to be protected from being removed, no matter if they
-are a pseudo node or not. Can be given as a numeric vector containing node
-indices or a character vector containing node names. Can also be a set of
-geospatial features as object of class \code{\link[sf]{sf}} or
-\code{\link[sf]{sfc}}. In that case, for each of these features its nearest
-node in the network will be protected. Defaults to \code{NULL}, meaning that
-none of the nodes is protected.}
-
-\item{require_equal}{Should nodes only be removed when the attribute values
-of their incident edges are equal? Defaults to \code{FALSE}. If \code{TRUE},
-only pseudo nodes that have incident edges with equal attribute values are
-removed. May also be given as a vector of attribute names. In that case only
-those attributes are checked for equality. Equality tests are evaluated
-using the \code{==} operator.}
+\item{require_equal}{Which attributes of its incident edges should be equal
+in order for a pseudo node to be removed? Evaluated as a
+\code{\link[dplyr]{dplyr_tidy_select}} argument. Defaults to \code{NULL},
+meaning that attribute equality is not considered for pseudo node removal.}
+
+\item{all}{Should edges be subdivided at all their interior points? If set
+to \code{FALSE}, edges are only subdivided at those interior points that
+share their location with any other interior or boundary point (a node) in
+the edges table. Defaults to \code{FALSE}. By default sfnetworks rounds
+coordinates to 12 decimal places to determine spatial equality. You can
+influence this behavior by explicitly setting the precision of the network
+using \code{\link[sf]{st_set_precision}}.}
+
+\item{merge}{Should multiple subdivision points at the same location be
+merged into a single node, and should subdivision points at the same
+location as an existing node be merged into that node? Defaults to
+\code{TRUE}. If set to \code{FALSE}, each subdivision point is added
+separately as a new node to the network. By default sfnetworks rounds
+coordinates to 12 decimal places to determine spatial equality. You can
+influence this behavior by explicitly setting the precision of the network
+using \code{\link[sf]{st_set_precision}}.}
\item{subset_by}{Whether to create subgraphs based on nodes or edges.}
}
@@ -132,27 +162,26 @@ description of each morpher for details.
\description{
Spatial morphers form spatial add-ons to the set of
\code{\link[tidygraph]{morphers}} provided by \code{tidygraph}. These
-functions are not meant to be called directly. They should either be passed
-into \code{\link[tidygraph]{morph}} to create a temporary alternative
-representation of the input network. Such an alternative representation is a
-list of one or more network objects. Single elements of that list can be
-extracted directly as a new network by passing the morpher to
-\code{\link[tidygraph]{convert}} instead, to make the changes lasting rather
-than temporary. Alternatively, if the morphed state contains multiple
-elements, all of them can be extracted together inside a
-\code{\link[tibble]{tbl_df}} by passing the morpher to
-\code{\link[tidygraph]{crystallise}}.
+functions change the existing structure of the network.
}
\details{
-It also possible to create your own morphers. See the documentation
-of \code{\link[tidygraph]{morph}} for the requirements for custom morphers.
+Morphers are not meant to be called directly. Instead, they should
+be called inside the \code{\link[tidygraph]{morph}} verb to change the
+network structure temporarily. Depending on the chosen morpher, this results
+in a list of one or more network objects. Single elements of that list can
+be extracted directly as a new network by calling the morpher inside the
+\code{\link[tidygraph]{convert}} verb instead, to make the changes lasting
+rather than temporary.
+
+It also possible to create your own morphers. See the documentation of
+\code{\link[tidygraph]{morph}} for the requirements for custom morphers.
}
\section{Functions}{
\itemize{
\item \code{to_spatial_contracted()}: Combine groups of nodes into a single node per
group. \code{...} is forwarded to \code{\link[dplyr]{group_by}} to
-create the groups. The centroid of the group of nodes will be used as
-geometry of the contracted node. If edge are spatially explicit, edge
+create the groups. The centroid of such a group will be used by default as
+geometry of the contracted node. If edges are spatially explicit, edge
geometries are updated accordingly such that the valid spatial network
structure is preserved. Returns a \code{morphed_sfnetwork} containing a
single element of class \code{\link{sfnetwork}}.
@@ -175,30 +204,45 @@ drawn between the source and target node of each edge. Returns a
\code{morphed_sfnetwork} containing a single element of class
\code{\link{sfnetwork}}.
+\item \code{to_spatial_implicit()}: Drop edge geometries from the network. Returns
+a \code{morphed_sfnetwork} containing a single element of class
+\code{\link{sfnetwork}}.
+
+\item \code{to_spatial_mixed()}: Construct a mixed network in which some edges
+are directed, and some are undirected. In practice this is implemented as a
+directed network in which those edges that are meant to be undirected are
+duplicated and reversed. Returns a \code{morphed_sfnetwork} containing a
+single element of class \code{\link{sfnetwork}}.
+
\item \code{to_spatial_neighborhood()}: Limit a network to the spatial neighborhood of
-a specific node. \code{...} is forwarded to
-\code{\link[tidygraph]{node_distance_from}} (if \code{from} is \code{TRUE})
-or \code{\link[tidygraph]{node_distance_to}} (if \code{from} is
-\code{FALSE}). Returns a \code{morphed_sfnetwork} containing a single
-element of class \code{\link{sfnetwork}}.
+a specific node. \code{...} is forwarded to \code{\link{st_network_cost}} to
+compute the travel cost from the specified node to all other nodes in the
+network. Returns a \code{morphed_sfnetwork} that may contain multiple
+elements of class \code{\link{sfnetwork}}, depending on the number of given
+thresholds. When unmorphing only the first instance of both the node and
+edge data will be used, as the the same node and/or edge can be present in
+multiple neighborhoods.
+
+\item \code{to_spatial_reversed()}: Reverse the direction of edges. Returns a
+\code{morphed_sfnetwork} containing a single element of class
+\code{\link{sfnetwork}}.
\item \code{to_spatial_shortest_paths()}: Limit a network to those nodes and edges that
are part of the shortest path between two nodes. \code{...} is evaluated in
-the same manner as \code{\link{st_network_paths}} with
-\code{type = 'shortest'}. Returns a \code{morphed_sfnetwork} that may
-contain multiple elements of class \code{\link{sfnetwork}}, depending on
-the number of requested paths. When unmorphing only the first instance of
-both the node and edge data will be used, as the the same node and/or edge
-can be present in multiple paths.
-
-\item \code{to_spatial_simple()}: Remove loop edges and/or merges multiple edges
-into a single edge. Multiple edges are edges that have the same source and
-target nodes (in directed networks) or edges that are incident to the same
-nodes (in undirected networks). When merging them into a single edge, the
-geometry of the first edge is preserved. The order of the edges can be
-influenced by calling \code{\link[dplyr]{arrange}} before simplifying.
-Returns a \code{morphed_sfnetwork} containing a single element of class
-\code{\link{sfnetwork}}.
+the same manner as \code{\link{st_network_paths}}. Returns a
+\code{morphed_sfnetwork} that may contain multiple elements of class
+\code{\link{sfnetwork}}, depending on the number of requested paths. When
+unmorphing only the first instance of both the node and edge data will be
+used, as the the same node and/or edge can be present in multiple paths.
+
+\item \code{to_spatial_simple()}: Construct a simple version of the network. A
+simple network is defined as a network without loop edges and multiple
+edges. A loop edge is an edge that starts and ends at the same node.
+Multiple edges are different edges between the same node pair. When merging
+them into a single edge, the geometry of the first edge is preserved. The
+order of the edges can be influenced by calling \code{\link[dplyr]{arrange}}
+before simplifying. Returns a \code{morphed_sfnetwork} containing a single
+element of class \code{\link{sfnetwork}}.
\item \code{to_spatial_smooth()}: Construct a smoothed version of the network by
iteratively removing pseudo nodes, while preserving the connectivity of the
@@ -212,16 +256,12 @@ pseudo node. Returns a \code{morphed_sfnetwork} containing a single element
of class \code{\link{sfnetwork}}.
\item \code{to_spatial_subdivision()}: Construct a subdivision of the network by
-subdividing edges at each interior point that is equal to any
-other interior or boundary point in the edges table. Interior points in this
-sense are those points that are included in their linestring geometry
-feature but are not endpoints of it, while boundary points are the endpoints
-of the linestrings. The network is reconstructed after subdivision such that
-edges are connected at the points of subdivision. Returns a
-\code{morphed_sfnetwork} containing a single element of class
-\code{\link{sfnetwork}}. This morpher requires edges to be spatially
-explicit and nodes to be spatially unique (i.e. not more than one node at
-the same spatial location).
+subdividing edges at interior points. Subdividing means that a new node is
+added on an edge, and the edge is split in two at that location. Interior
+points are those points that shape a linestring geometry feature but are not
+endpoints of it. Returns a \code{morphed_sfnetwork} containing a single
+element of class \code{\link{sfnetwork}}. This morpher requires edges to be
+spatially explicit.
\item \code{to_spatial_subset()}: Subset the network by applying a spatial
filter, i.e. a filter on the geometry column based on a spatial predicate.
@@ -236,30 +276,31 @@ evaluated in the same manner as \code{\link[sf]{st_transform}}.
Returns a \code{morphed_sfnetwork} containing a single element of class
\code{\link{sfnetwork}}.
+\item \code{to_spatial_unique()}: Merge nodes with equal geometries into a single
+node. Returns a \code{morphed_sfnetwork} containing a single element of
+class \code{\link{sfnetwork}}. By default sfnetworks rounds coordinates to
+12 decimal places to determine spatial equality. You can influence this
+behavior by explicitly setting the precision of the network using
+\code{\link[sf]{st_set_precision}}.
+
}}
\examples{
library(sf, quietly = TRUE)
library(tidygraph, quietly = TRUE)
-net = as_sfnetwork(roxel, directed = FALSE) \%>\%
+net = as_sfnetwork(roxel, directed = FALSE) |>
st_transform(3035)
# Temporary changes with morph and unmorph.
-net \%>\%
- activate("edges") \%>\%
- mutate(weight = edge_length()) \%>\%
- morph(to_spatial_shortest_paths, from = 1, to = 10) \%>\%
- mutate(in_paths = TRUE) \%>\%
+net |>
+ activate(edges) |>
+ morph(to_spatial_shortest_paths, from = 1, to = 10) |>
+ mutate(in_paths = TRUE) |>
unmorph()
# Lasting changes with convert.
-net \%>\%
- activate("edges") \%>\%
- mutate(weight = edge_length()) \%>\%
+net |>
+ activate(edges) |>
convert(to_spatial_shortest_paths, from = 1, to = 10)
}
-\seealso{
-The vignette on
-\href{https://luukvdmeer.github.io/sfnetworks/articles/sfn05_morphers.html}{spatial morphers}.
-}
diff --git a/man/spatial_node_predicates.Rd b/man/spatial_node_predicates.Rd
index 5241f70f..a30431b5 100644
--- a/man/spatial_node_predicates.Rd
+++ b/man/spatial_node_predicates.Rd
@@ -9,6 +9,7 @@
\alias{node_equals}
\alias{node_is_covered_by}
\alias{node_is_within_distance}
+\alias{node_is_nearest}
\title{Query nodes with spatial predicates}
\usage{
node_intersects(y, ...)
@@ -24,13 +25,16 @@ node_equals(y, ...)
node_is_covered_by(y, ...)
node_is_within_distance(y, ...)
+
+node_is_nearest(y)
}
\arguments{
\item{y}{The geospatial features to test the nodes against, either as an
object of class \code{\link[sf]{sf}} or \code{\link[sf]{sfc}}.}
\item{...}{Arguments passed on to the corresponding spatial predicate
-function of sf. See \code{\link[sf]{geos_binary_pred}}.}
+function of sf. See \code{\link[sf]{geos_binary_pred}}. The argument
+\code{sparse} should not be set.}
}
\value{
A logical vector of the same length as the number of nodes in the
@@ -41,20 +45,23 @@ These functions allow to interpret spatial relations between nodes and
other geospatial features directly inside \code{\link[tidygraph]{filter}}
and \code{\link[tidygraph]{mutate}} calls. All functions return a logical
vector of the same length as the number of nodes in the network. Element i
-in that vector is \code{TRUE} whenever \code{any(predicate(x[i], y[j]))} is
-\code{TRUE}. Hence, in the case of using \code{node_intersects}, element i
-in the returned vector is \code{TRUE} when node i intersects with any of
-the features given in y.
+in that vector is \code{TRUE} whenever the chosen spatial predicate applies
+to the spatial relation between the i-th node and any of the features in
+\code{y}.
}
\details{
See \code{\link[sf]{geos_binary_pred}} for details on each spatial
-predicate. Just as with all query functions in tidygraph, these functions
-are meant to be called inside tidygraph verbs such as
-\code{\link[tidygraph]{mutate}} or \code{\link[tidygraph]{filter}}, where
-the network that is currently being worked on is known and thus not needed
-as an argument to the function. If you want to use an algorithm outside of
-the tidygraph framework you can use \code{\link[tidygraph]{with_graph}} to
-set the context temporarily while the algorithm is being evaluated.
+predicate. The function \code{node_is_nearest} instead wraps around
+\code{\link[sf]{st_nearest_feature}} and returns \code{TRUE} for element i
+if the i-th node is the nearest node to any of the features in \code{y}.
+
+Just as with all query functions in tidygraph, these functions are meant to
+be called inside tidygraph verbs such as \code{\link[tidygraph]{mutate}} or
+\code{\link[tidygraph]{filter}}, where the network that is currently being
+worked on is known and thus not needed as an argument to the function. If
+you want to use an algorithm outside of the tidygraph framework you can use
+\code{\link[tidygraph]{with_graph}} to set the context temporarily while the
+algorithm is being evaluated.
}
\note{
Note that \code{node_is_within_distance} is a wrapper around the
@@ -68,7 +75,7 @@ library(sf, quietly = TRUE)
library(tidygraph, quietly = TRUE)
# Create a network.
-net = as_sfnetwork(roxel) \%>\%
+net = as_sfnetwork(roxel) |>
st_transform(3035)
# Create a geometry to test against.
@@ -77,18 +84,19 @@ p2 = st_point(c(4151340, 3207520))
p3 = st_point(c(4151756, 3207506))
p4 = st_point(c(4151774, 3208031))
-poly = st_multipoint(c(p1, p2, p3, p4)) \%>\%
- st_cast('POLYGON') \%>\%
+poly = st_multipoint(c(p1, p2, p3, p4)) |>
+ st_cast('POLYGON') |>
st_sfc(crs = 3035)
# Use predicate query function in a filter call.
-within = net \%>\%
- activate("nodes") \%>\%
+within = net |>
+ activate(nodes) |>
filter(node_is_within(poly))
-disjoint = net \%>\%
- activate("nodes") \%>\%
+disjoint = net |>
+ activate(nodes) |>
filter(node_is_disjoint(poly))
+
oldpar = par(no.readonly = TRUE)
par(mar = c(1,1,1,1))
plot(net)
@@ -97,9 +105,13 @@ plot(disjoint, col = "blue", add = TRUE)
par(oldpar)
# Use predicate query function in a mutate call.
-net \%>\%
- activate("nodes") \%>\%
- mutate(within = node_is_within(poly)) \%>\%
+net |>
+ activate(nodes) |>
+ mutate(within = node_is_within(poly)) |>
select(within)
+# Use predicate query function directly.
+within = with_graph(net, node_is_within(poly))
+head(within)
+
}
diff --git a/man/spatial_node_types.Rd b/man/spatial_node_types.Rd
new file mode 100644
index 00000000..0fa91b33
--- /dev/null
+++ b/man/spatial_node_types.Rd
@@ -0,0 +1,75 @@
+% Generated by roxygen2: do not edit by hand
+% Please edit documentation in R/node.R
+\name{spatial_node_types}
+\alias{spatial_node_types}
+\alias{node_is_pseudo}
+\alias{node_is_dangling}
+\title{Query spatial node types}
+\usage{
+node_is_pseudo()
+
+node_is_dangling()
+}
+\value{
+A logical vector of the same length as the number of nodes in the
+network, indicating if each node is of the type in question.
+}
+\description{
+These functions are a collection of node type queries that are commonly
+used in spatial network analysis, and form a spatial extension to
+\code{\link[tidygraph:node_types]{node type queries}} in tidygraph.
+}
+\details{
+Just as with all query functions in tidygraph, these functions
+are meant to be called inside tidygraph verbs such as
+\code{\link[tidygraph]{mutate}} or \code{\link[tidygraph]{filter}}, where
+the network that is currently being worked on is known and thus not needed
+as an argument to the function. If you want to use an algorithm outside of
+the tidygraph framework you can use \code{\link[tidygraph]{with_graph}} to
+set the context temporarily while the algorithm is being evaluated.
+}
+\section{Functions}{
+\itemize{
+\item \code{node_is_pseudo()}: Pseudo nodes in directed networks are those
+nodes with only one incoming and one outgoing edge. In undirected networks
+pseudo nodes are those nodes with only two incident edges, i.e. nodes of
+degree 2.
+
+\item \code{node_is_dangling()}: Dangling nodes are nodes with only one
+incident edge, i.e. nodes of degree 1.
+
+}}
+\examples{
+library(sf, quietly = TRUE)
+library(tidygraph, quietly = TRUE)
+
+# Create a network.
+net = as_sfnetwork(mozart, "mst", directed = FALSE)
+
+# Use query function in a filter call.
+pseudos = net |>
+ activate(nodes) |>
+ filter(node_is_pseudo())
+
+danglers = net |>
+ activate(nodes) |>
+ filter(node_is_dangling())
+
+oldpar = par(no.readonly = TRUE)
+par(mar = c(1,1,1,1), mfrow = c(1,2))
+plot(net, main = "Pseudo nodes")
+plot(st_geometry(pseudos), pch = 20, cex = 1.2, col = "orange", add = TRUE)
+plot(net, main = "Dangling nodes")
+plot(st_geometry(danglers), pch = 20, cex = 1.2, col = "orange", add = TRUE)
+par(oldpar)
+
+# Use query function in a mutate call.
+net |>
+ activate(nodes) |>
+ mutate(pseudo = node_is_pseudo(), dangling = node_is_dangling())
+
+# Use query function directly.
+danglers = with_graph(net, node_is_dangling())
+head(danglers)
+
+}
diff --git a/man/st_duplicated.Rd b/man/st_duplicated.Rd
new file mode 100644
index 00000000..b75464dd
--- /dev/null
+++ b/man/st_duplicated.Rd
@@ -0,0 +1,31 @@
+% Generated by roxygen2: do not edit by hand
+% Please edit documentation in R/utils.R
+\name{st_duplicated}
+\alias{st_duplicated}
+\title{Determine duplicated geometries}
+\usage{
+st_duplicated(x)
+}
+\arguments{
+\item{x}{An object of class \code{\link[sf]{sf}} or \code{\link[sf]{sfc}}.}
+}
+\value{
+A logical vector specifying for each feature in \code{x} if its
+geometry is equal to a previous feature in \code{x}.
+}
+\description{
+Determine duplicated geometries
+}
+\examples{
+library(sf, quietly = TRUE)
+
+p1 = st_sfc(st_point(c(1, 1)))
+p2 = st_sfc(st_point(c(0, 0)))
+p3 = st_sfc(st_point(c(1, 0)))
+
+st_duplicated(c(p1, p2, p2, p3, p1))
+
+}
+\seealso{
+\code{\link{duplicated}}
+}
diff --git a/man/st_match.Rd b/man/st_match.Rd
new file mode 100644
index 00000000..0a593194
--- /dev/null
+++ b/man/st_match.Rd
@@ -0,0 +1,31 @@
+% Generated by roxygen2: do not edit by hand
+% Please edit documentation in R/utils.R
+\name{st_match}
+\alias{st_match}
+\title{Geometry matching}
+\usage{
+st_match(x)
+}
+\arguments{
+\item{x}{An object of class \code{\link[sf]{sf}} or \code{\link[sf]{sfc}}.}
+}
+\value{
+A numeric vector giving for each feature in \code{x} the position of
+the first feature in \code{x} that has an equal geometry.
+}
+\description{
+Geometry matching
+}
+\examples{
+library(sf, quietly = TRUE)
+
+p1 = st_sfc(st_point(c(1, 1)))
+p2 = st_sfc(st_point(c(0, 0)))
+p3 = st_sfc(st_point(c(1, 0)))
+
+st_match(c(p1, p2, p2, p3, p1))
+
+}
+\seealso{
+\code{\link{match}}
+}
diff --git a/man/st_network_bbox.Rd b/man/st_network_bbox.Rd
index 392a63e3..a05db36b 100644
--- a/man/st_network_bbox.Rd
+++ b/man/st_network_bbox.Rd
@@ -2,7 +2,7 @@
% Please edit documentation in R/bbox.R
\name{st_network_bbox}
\alias{st_network_bbox}
-\title{Get the bounding box of a spatial network}
+\title{Compute the bounding box of a spatial network}
\usage{
st_network_bbox(x, ...)
}
@@ -16,24 +16,28 @@ The bounding box of the network as an object of class
\code{\link[sf:st_bbox]{bbox}}.
}
\description{
-A spatial network specific bounding box extractor, returning the combined
+A spatial network specific bounding box creator, returning the combined
bounding box of the nodes and edges in the network.
}
\details{
See \code{\link[sf]{st_bbox}} for details.
}
\examples{
-library(sf)
+library(sf, quietly = TRUE)
+
+oldpar = par(no.readonly = TRUE)
+par(mar = c(1,1,1,1), mfrow = c(1,2))
# Create a network.
-node1 = st_point(c(8, 51))
-node2 = st_point(c(7, 51.5))
-node3 = st_point(c(8, 52))
-node4 = st_point(c(9, 51))
-edge1 = st_sfc(st_linestring(c(node1, node2, node3)))
-
-nodes = st_as_sf(c(st_sfc(node1), st_sfc(node3), st_sfc(node4)))
-edges = st_as_sf(edge1)
+n1 = st_point(c(8, 51))
+n2 = st_point(c(7, 51.5))
+n3 = st_point(c(8, 52))
+n4 = st_point(c(9, 51))
+e1 = st_sfc(st_linestring(c(n1, n2, n3)))
+
+nodes = st_as_sf(c(st_sfc(n1), st_sfc(n3), st_sfc(n4)))
+
+edges = st_as_sf(e1)
edges$from = 1
edges$to = 2
@@ -48,13 +52,13 @@ net_bbox = st_network_bbox(net)
net_bbox
# Plot.
-oldpar = par(no.readonly = TRUE)
-par(mar = c(1,1,1,1), mfrow = c(1,2))
plot(net, lwd = 2, cex = 4, main = "Element bounding boxes")
-plot(st_as_sfc(node_bbox), border = "red", lty = 2, lwd = 4, add = TRUE)
-plot(st_as_sfc(edge_bbox), border = "blue", lty = 2, lwd = 4, add = TRUE)
+plot(st_as_sfc(node_bbox), border = "orange", lty = 2, lwd = 4, add = TRUE)
+plot(st_as_sfc(edge_bbox), border = "skyblue", lty = 2, lwd = 4, add = TRUE)
+
plot(net, lwd = 2, cex = 4, main = "Network bounding box")
-plot(st_as_sfc(net_bbox), border = "red", lty = 2, lwd = 4, add = TRUE)
+plot(st_as_sfc(net_bbox), border = "orange", lty = 2, lwd = 4, add = TRUE)
+
par(oldpar)
}
diff --git a/man/st_network_blend.Rd b/man/st_network_blend.Rd
index bce86741..00cec7a3 100644
--- a/man/st_network_blend.Rd
+++ b/man/st_network_blend.Rd
@@ -2,9 +2,9 @@
% Please edit documentation in R/blend.R
\name{st_network_blend}
\alias{st_network_blend}
-\title{Blend geospatial points into a spatial network}
+\title{Blend spatial points into a spatial network}
\usage{
-st_network_blend(x, y, tolerance = Inf)
+st_network_blend(x, y, tolerance = Inf, ignore_duplicates = TRUE)
}
\arguments{
\item{x}{An object of class \code{\link{sfnetwork}}.}
@@ -18,27 +18,37 @@ Should be a non-negative number preferably given as an object of class
\code{\link[units]{units}}. Otherwise, it will be assumed that the unit is
meters. If set to \code{Inf} all features will be blended. Defaults to
\code{Inf}.}
+
+\item{ignore_duplicates}{If there are multiple points in \code{y} that have
+the same projected location, only the first one of them is blended into
+the network. But what should happen with the others? If this argument is set
+to \code{TRUE}, they will be ignored. If this argument is set to
+\code{FALSE}, they will be added as isolated nodes to the returned network.
+Nodes at equal locations can then be merged using the spatial morpher
+\code{\link{to_spatial_unique}}. Defaults to \code{TRUE}.}
}
\value{
The blended network as an object of class \code{\link{sfnetwork}}.
}
\description{
-Blending a point into a network is the combined process of first snapping
-the given point to its nearest point on its nearest edge in the network,
-subsequently splitting that edge at the location of the snapped point, and
-finally adding the snapped point as node to the network. If the location
-of the snapped point is already a node in the network, the attributes of the
-point (if any) will be joined to that node.
+Blending a point into a network is the combined process of first projecting
+the point onto its nearest point on its nearest edge in the network, then
+subdividing that edge at the location of the projected point, and finally
+adding the projected point as node to the network. If the location of the
+projected point is equal an existing node in the network, the attributes of
+the point will be joined to that node, instead of adding a new node.
}
\details{
-There are two important details to be aware of. Firstly: when the
-snap locations of multiple points are equal, only the first of these points
-is blended into the network. By arranging \code{y} before blending you can
-influence which (type of) point is given priority in such cases.
-Secondly: when the snap location of a point intersects with multiple edges,
-it is only blended into the first of these edges. You might want to run the
-\code{\link{to_spatial_subdivision}} morpher after blending, such that
-intersecting but unconnected edges get connected.
+When the projected location of a given point intersects with more
+than one edge, it is only blended into the first of these edges. Edges are
+not connected at blending locations. Use the spatial morpher
+\code{\link{to_spatial_subdivision}} for that.
+
+To determine if a projected point is equal to an existing node, and to
+determine if multiple projected points are equal to each other, sfnetworks
+by default rounds coordinates to 12 decimal places. You can influence this
+behavior by explicitly setting the precision of the network using
+\code{\link[sf]{st_set_precision}}.
}
\note{
Due to internal rounding of rational numbers, it may occur that the
@@ -46,61 +56,56 @@ intersection point between a line and a point is not evaluated as
actually intersecting that line by the designated algorithm. Instead, the
intersection point lies a tiny-bit away from the edge. Therefore, it is
recommended to set the tolerance to a very small number (for example 1e-5)
-even if you only want to blend points that intersect the line.
+even if you only want to blend points that intersect an edge.
}
\examples{
library(sf, quietly = TRUE)
-# Create a network and a set of points to blend.
-n11 = st_point(c(0,0))
-n12 = st_point(c(1,1))
-e1 = st_sfc(st_linestring(c(n11, n12)), crs = 3857)
+oldpar = par(no.readonly = TRUE)
+par(mar = c(1,1,1,1), mfrow = c(1,2))
+
+# Create a spatial network.
+n1 = st_point(c(0, 0))
+n2 = st_point(c(1, 0))
+n3 = st_point(c(2, 0))
-n21 = n12
-n22 = st_point(c(0,2))
-e2 = st_sfc(st_linestring(c(n21, n22)), crs = 3857)
+e1 = st_sfc(st_linestring(c(n1, n2)), crs = 3857)
+e2 = st_sfc(st_linestring(c(n2, n3)), crs = 3857)
-n31 = n22
-n32 = st_point(c(-1,1))
-e3 = st_sfc(st_linestring(c(n31, n32)), crs = 3857)
+net = as_sfnetwork(c(e1, e2))
-net = as_sfnetwork(c(e1,e2,e3))
+# Create spatial points to blend in.
+p1 = st_sfc(st_point(c(0.5, 0.1)))
+p2 = st_sfc(st_point(c(0.5, -0.2)))
+p3 = st_sfc(st_point(c(1, 0.2)))
+p4 = st_sfc(st_point(c(1.75, 0.2)))
+p5 = st_sfc(st_point(c(1.25, 0.1)))
-pts = net \%>\%
- st_bbox() \%>\%
- st_as_sfc() \%>\%
- st_sample(10, type = "random") \%>\%
- st_set_crs(3857) \%>\%
- st_cast('POINT')
+pts = st_sf(foo = letters[1:5], geometry = c(p1, p2, p3, p4, p5), crs = 3857)
-# Blend points into the network.
-# --> By default tolerance is set to Inf
-# --> Meaning that all points get blended
+# Blend all points into the network.
b1 = st_network_blend(net, pts)
b1
-# Blend points with a tolerance.
-tol = units::set_units(0.2, "m")
+plot(net)
+plot(st_geometry(pts), pch = 20, col = "orange", add = TRUE)
+plot(b1)
+plot(st_geometry(pts), pch = 20, col = "orange", add = TRUE)
+
+# Blend points within a tolerance distance.
+tol = units::set_units(0.1, "m")
b2 = st_network_blend(net, pts, tolerance = tol)
b2
-## Plot results.
-# Initial network and points.
-oldpar = par(no.readonly = TRUE)
-par(mar = c(1,1,1,1), mfrow = c(1,3))
-plot(net, cex = 2, main = "Network + set of points")
-plot(pts, cex = 2, col = "red", pch = 20, add = TRUE)
-
-# Blend with no tolerance
-plot(b1, cex = 2, main = "Blend with tolerance = Inf")
-plot(pts, cex = 2, col = "red", pch = 20, add = TRUE)
-
-# Blend with tolerance.
-within = st_is_within_distance(pts, st_geometry(net, "edges"), tol)
-pts_within = pts[lengths(within) > 0]
-plot(b2, cex = 2, main = "Blend with tolerance = 0.2 m")
-plot(pts, cex = 2, col = "grey", pch = 20, add = TRUE)
-plot(pts_within, cex = 2, col = "red", pch = 20, add = TRUE)
+plot(net)
+plot(st_geometry(pts), pch = 20, col = "orange", add = TRUE)
+plot(b2)
+plot(st_geometry(pts), pch = 20, col = "orange", add = TRUE)
+
+# Add points with duplicated projected location as isolated nodes.
+b3 = st_network_blend(net, pts, ignore_duplicates = FALSE)
+b3
+
par(oldpar)
}
diff --git a/man/st_network_cost.Rd b/man/st_network_cost.Rd
index 0b4cc5e9..1f1b11ee 100644
--- a/man/st_network_cost.Rd
+++ b/man/st_network_cost.Rd
@@ -1,52 +1,53 @@
% Generated by roxygen2: do not edit by hand
-% Please edit documentation in R/paths.R
+% Please edit documentation in R/cost.R
\name{st_network_cost}
\alias{st_network_cost}
+\alias{st_network_distance}
\title{Compute a cost matrix of a spatial network}
\usage{
st_network_cost(
x,
- from = igraph::V(x),
- to = igraph::V(x),
- weights = NULL,
+ from = node_ids(x),
+ to = node_ids(x),
+ weights = edge_length(),
direction = "out",
Inf_as_NaN = FALSE,
+ router = getOption("sfn_default_router", "igraph"),
+ use_names = FALSE,
+ ...
+)
+
+st_network_distance(
+ x,
+ from = node_ids(x),
+ to = node_ids(x),
+ direction = "out",
+ Inf_as_NaN = FALSE,
+ router = getOption("sfn_default_router", "igraph"),
+ use_names = FALSE,
...
)
}
\arguments{
\item{x}{An object of class \code{\link{sfnetwork}}.}
-\item{from}{The (set of) geospatial point(s) from which the shortest paths
-will be calculated. Can be an object of class \code{\link[sf]{sf}} or
-\code{\link[sf]{sfc}}.
-Alternatively it can be a numeric vector containing the indices of the nodes
-from which the shortest paths will be calculated, or a character vector
-containing the names of the nodes from which the shortest paths will be
-calculated. By default, all nodes in the network are included.}
-
-\item{to}{The (set of) geospatial point(s) to which the shortest paths will
-be calculated. Can be an object of class \code{\link[sf]{sf}} or
-\code{\link[sf]{sfc}}.
-Alternatively it can be a numeric vector containing the indices of the nodes
-to which the shortest paths will be calculated, or a character vector
-containing the names of the nodes to which the shortest paths will be
-calculated. Duplicated values will be removed before calculating the cost
-matrix. By default, all nodes in the network are included.}
+\item{from}{The nodes where the paths should start. Evaluated by
+\code{\link{evaluate_node_query}}. By default, all nodes in the network are
+included.}
+
+\item{to}{The nodes where the paths should end. Evaluated by
+\code{\link{evaluate_node_query}}. By default, all nodes in the network are
+included.}
\item{weights}{The edge weights to be used in the shortest path calculation.
-Can be a numeric vector giving edge weights, or a column name referring to
-an attribute column in the edges table containing those weights. If set to
-\code{NULL}, the values of a column named \code{weight} in the edges table
-will be used automatically, as long as this column is present. If not, the
-geographic edge lengths will be calculated internally and used as weights.
-If set to \code{NA}, no weights are used, even if the edges have a
-\code{weight} column.}
+Evaluated by \code{\link{evaluate_weight_spec}}. The default is
+\code{\link{edge_length}}, which computes the geographic lengths of the
+edges.}
\item{direction}{The direction of travel. Defaults to \code{'out'}, meaning
-that the direction given by the network is followed and costs are calculated
+that the direction given by the network is followed and costs are computed
from the points given as argument \code{from}. May be set to \code{'in'},
-meaning that the opposite direction is followed an costs are calculated
+meaning that the opposite direction is followed an costs are computed
towards the points given as argument \code{from}. May also be set to
\code{'all'}, meaning that the network is considered to be undirected. This
argument is ignored for undirected networks.}
@@ -54,54 +55,61 @@ argument is ignored for undirected networks.}
\item{Inf_as_NaN}{Should the cost values of unconnected nodes be stored as
\code{NaN} instead of \code{Inf}? Defaults to \code{FALSE}.}
-\item{...}{Arguments passed on to \code{\link[igraph]{distances}}. Argument
-\code{mode} is ignored. Use \code{direction} instead.}
+\item{router}{The routing backend to use for the cost matrix computation.
+Currently supported options are \code{'igraph'} and \code{'dodgr'}. See
+Details.}
+
+\item{use_names}{If a column named \code{name} is present in the nodes
+table, should these names be used as row and column names in the matrix,
+instead of the node indices? Defaults to \code{FALSE}. Ignored when the
+nodes table does not have a column named \code{name}.}
+
+\item{...}{Additional arguments passed on to the underlying function of the
+chosen routing backend. See Details.}
}
\value{
An n times m numeric matrix where n is the length of the \code{from}
argument, and m is the length of the \code{to} argument.
}
\description{
-Wrapper around \code{\link[igraph]{distances}} to calculate costs of
-pairwise shortest paths between points in a spatial network. It allows to
-provide any set of geospatial point as \code{from} and \code{to} arguments.
-If such a geospatial point is not equal to a node in the network, it will
-be snapped to its nearest node before calculating costs.
+Compute total travel costs of shortest paths between nodes in a spatial
+network.
}
\details{
-Spatial features provided to the \code{from} and/or
-\code{to} argument don't necessarily have to be points. Internally, the
-nearest node to each feature is found by calling
-\code{\link[sf]{st_nearest_feature}}, so any feature with a geometry type
-that is accepted by that function can be provided as \code{from} and/or
-\code{to} argument.
-
-When directly providing integer node indices or character node names to the
-\code{from} and/or \code{to} argument, keep the following in mind. A node
-index should correspond to a row-number of the nodes table of the network.
-A node name should correspond to a value of a column in the nodes table
-named \code{name}. This column should contain character values without
-duplicates.
-
-For more details on the wrapped function from \code{\link[igraph]{igraph}}
-see the \code{\link[igraph]{distances}} documentation page.
+The sfnetworks package does not implement its own routing algorithms
+to compute cost matrices. Instead, it relies on "routing backends", i.e.
+other R packages that have implemented such algorithms. Currently two
+different routing backends are supported.
+
+The default is \code{\link[igraph]{igraph}}. This package supports
+many-to-many cost matrix computation with the \code{\link[igraph]{distances}}
+function. The igraph router does not support dual-weighted routing.
+
+The second supported routing backend is \code{\link[dodgr]{dodgr}}. This
+package supports many-to-many cost matrix computation with the
+\code{\link[dodgr]{dodgr_dists}} function. It also supports dual-weighted
+routing. The dodgr package is a conditional dependency of sfnetworks. Using
+the dodgr router requires the dodgr package to be installed.
+
+The default router can be changed by setting the \code{sfn_default_router}
+option.
}
\examples{
library(sf, quietly = TRUE)
library(tidygraph, quietly = TRUE)
-# Create a network with edge lengths as weights.
-# These weights will be used automatically in shortest paths calculation.
-net = as_sfnetwork(roxel, directed = FALSE) \%>\%
- st_transform(3035) \%>\%
- activate("edges") \%>\%
- mutate(weight = edge_length())
+net = as_sfnetwork(roxel, directed = FALSE) |>
+ st_transform(3035)
-# Providing node indices.
+# Compute the network cost matrix between node pairs.
+# Note that geographic edge length is used as edge weights by default.
st_network_cost(net, from = c(495, 121), to = c(495, 121))
-# Providing nodes as spatial points.
-# Points that don't equal a node will be snapped to their nearest node.
+# st_network_distance is a synonym for st_network_cost with default weights.
+st_network_distance(net, from = c(495, 121), to = c(495, 121))
+
+# Compute the network cost matrix between spatial point features.
+# These are snapped to their nearest node before computing costs.
p1 = st_geometry(net, "nodes")[495] + st_sfc(st_point(c(50, -50)))
st_crs(p1) = st_crs(net)
p2 = st_geometry(net, "nodes")[121] + st_sfc(st_point(c(-10, 100)))
@@ -109,11 +117,31 @@ st_crs(p2) = st_crs(net)
st_network_cost(net, from = c(p1, p2), to = c(p1, p2))
-# Using another column for weights.
-net \%>\%
- activate("edges") \%>\%
- mutate(foo = runif(n(), min = 0, max = 1)) \%>\%
- st_network_cost(c(p1, p2), c(p1, p2), weights = "foo")
+# Use a node type query function to specify origins and/or destinations.
+st_network_cost(net, from = 499, to = node_is_connected(499))
+
+# Use a spatial edge measure to specify edge weights.
+# By default edge_length() is used.
+st_network_cost(net, c(p1, p2), c(p1, p2), weights = edge_displacement())
+
+# Use a column in the edges table to specify edge weights.
+# This uses tidy evaluation.
+net |>
+ activate("edges") |>
+ mutate(foo = runif(n(), min = 0, max = 1)) |>
+ st_network_cost(c(p1, p2), c(p1, p2), weights = foo)
+
+# Compute the cost matrix without edge weights.
+# Here the cost is defined by the number of edges, ignoring space.
+st_network_cost(net, c(p1, p2), c(p1, p2), weights = NA)
+
+# Use the dodgr router for dual-weighted routing.
+paths = st_network_cost(net,
+ from = c(p1, p2),
+ to = c(p1, p2),
+ weights = dual_weights(edge_segment_count(), edge_length()),
+ router = "dodgr"
+)
# Not providing any from or to points includes all nodes by default.
with_graph(net, graph_order()) # Our network has 701 nodes.
@@ -122,5 +150,5 @@ dim(cost_matrix)
}
\seealso{
-\code{\link{st_network_paths}}
+\code{\link{st_network_paths}}, \code{\link{st_network_travel}}
}
diff --git a/man/st_network_faces.Rd b/man/st_network_faces.Rd
new file mode 100644
index 00000000..1a068324
--- /dev/null
+++ b/man/st_network_faces.Rd
@@ -0,0 +1,43 @@
+% Generated by roxygen2: do not edit by hand
+% Please edit documentation in R/faces.R
+\name{st_network_faces}
+\alias{st_network_faces}
+\title{Extract the faces of a spatial network}
+\usage{
+st_network_faces(x, boundary = NULL)
+}
+\arguments{
+\item{x}{An object of class \code{\link{sfnetwork}}.}
+
+\item{boundary}{The boundary used for the outer face, as an object of class
+\code{\link[sf]{sf}} or \code{\link[sf]{sfc}} containing a single
+\code{POLYGON} geometry. Note that this boundary should always be larger
+than the bounding box of the network. If \code{NULL} (the default) the
+network bounding box extended by 0.1 times its diameter is used.}
+}
+\value{
+An object of class \code{\link[sf]{sfc}} with \code{POLYGON}
+geometries, in which each feature represents one face of the network.
+}
+\description{
+The faces of a spatial network are the areas bounded by edges, without any
+other edge crossing it. A special face is the outer face, which is the area
+not bounded by any set of edges.
+}
+\examples{
+library(sf, quietly = TRUE)
+
+oldpar = par(no.readonly = TRUE)
+par(mar = c(1,1,1,1))
+
+pts = st_transform(mozart, 3035)
+net = as_sfnetwork(pts, "delaunay")
+
+faces = st_network_faces(net)
+
+plot(faces, col = sf.colors(length(faces), categorical = TRUE))
+plot(net, add = TRUE)
+
+par(oldpar)
+
+}
diff --git a/man/st_network_iso.Rd b/man/st_network_iso.Rd
new file mode 100644
index 00000000..aca80f3b
--- /dev/null
+++ b/man/st_network_iso.Rd
@@ -0,0 +1,105 @@
+% Generated by roxygen2: do not edit by hand
+% Please edit documentation in R/iso.R
+\name{st_network_iso}
+\alias{st_network_iso}
+\title{Compute isolines around nodes in a spatial network}
+\usage{
+st_network_iso(
+ x,
+ node,
+ cost,
+ weights = edge_length(),
+ ...,
+ delineate = TRUE,
+ ratio = 1,
+ allow_holes = FALSE
+)
+}
+\arguments{
+\item{x}{An object of class \code{\link{sfnetwork}}.}
+
+\item{node}{The node around which the isolines will be drawn. Evaluated by
+\code{\link{evaluate_node_query}}. When multiple nodes are given, only the
+first one is used.}
+
+\item{cost}{The constant cost value of the isoline. Should be a numeric
+value in the same units as the given edge weights. Alternatively, units can
+be specified explicitly by providing a \code{\link[units]{units}} object.
+Multiple values may be given, which will result in multiple isolines being
+drawn.}
+
+\item{weights}{The edge weights to be used in the shortest path calculation.
+Evaluated by \code{\link{evaluate_weight_spec}}. The default is
+\code{\link{edge_length}}, which computes the geographic lengths of the
+edges.}
+
+\item{...}{Additional arguments passed on to \code{\link{st_network_cost}}
+to compute the cost matrix from the specified node to all other nodes in the
+network.}
+
+\item{delineate}{Should the nodes inside the isoline be delineated? If
+\code{FALSE}, the nodes inside the isoline are returned as a
+\code{MULTIPOINT} geometry. If \code{TRUE}, the concave hull of that
+geometry is returned instead. Defaults to \code{TRUE}.}
+
+\item{ratio}{The ratio of the concave hull. Defaults to \code{1}, meaning
+that the convex hull is computed. See \code{\link[sf]{st_concave_hull}} for
+details. Ignored if \code{delineate = FALSE}. Setting this to a value
+smaller than 1 requires a GEOS version of at least 3.11.}
+
+\item{allow_holes}{May the concave hull have holes? Defaults to \code{FALSE}.
+Ignored if \code{delineate = FALSE}.}
+}
+\value{
+An object of class \code{\link[sf]{sf}} with one row per requested
+isoline. The object contains the following columns:
+
+\itemize{
+ \item \code{cost}: The constant cost value of the isoline.
+ \item \code{geometry}: If \code{delineate = TRUE}, the concave hull of all
+ nodes that lie inside the isoline. Otherwise, those nodes combined into a
+ single \code{MULTIPOINT} geometry.
+}
+}
+\description{
+Isolines are curves along which a function has a constant value. In spatial
+networks, they are used to delineate areas that are reachable from a given
+node within a given travel cost. If the travel cost is distance, they are
+known as isodistances, while if the travel cost is time, they are known as
+isochrones. This function finds all network nodes that lie inside an isoline
+around a specified node.
+}
+\examples{
+library(sf, quietly = TRUE)
+
+oldpar = par(no.readonly = TRUE)
+par(mar = c(1,1,1,1))
+
+center = st_centroid(st_combine(st_geometry(roxel)))
+
+net = as_sfnetwork(roxel, directed = FALSE)
+
+iso = net |>
+ st_network_iso(node_is_nearest(center), c(1000, 500, 250))
+
+colors = c("#fee6ce90", "#fdae6b90", "#e6550d90")
+
+plot(net)
+plot(st_geometry(iso), col = colors, add = TRUE)
+
+# The level of detail can be increased with the ratio argument.
+# This requires GEOS >= 3.11.
+if (compareVersion(sf_extSoftVersion()[["GEOS"]], "3.11.0") > -1) {
+
+ iso = net |>
+ st_network_iso(node_is_nearest(center), c(1000, 500, 250), ratio = 0.3)
+
+ colors = c("#fee6ce90", "#fdae6b90", "#e6550d90")
+
+ plot(net)
+ plot(st_geometry(iso), col = colors, add = TRUE)
+}
+
+par(oldpar)
+
+}
diff --git a/man/st_network_join.Rd b/man/st_network_join.Rd
index 3f711648..d0d24a6f 100644
--- a/man/st_network_join.Rd
+++ b/man/st_network_join.Rd
@@ -19,36 +19,46 @@ The joined networks as an object of class \code{\link{sfnetwork}}.
}
\description{
A spatial network specific join function which makes a spatial full join on
-the geometries of the nodes data, based on the \code{\link[sf]{st_equals}}
-spatial predicate. Edge data are combined using a
+the geometries of the nodes data. Edge data are combined using a
\code{\link[dplyr]{bind_rows}} semantic, meaning that data are matched by
column name and values are filled with \code{NA} if missing in either of
the networks. The \code{from} and \code{to} columns in the edge data are
updated such that they match the new node indices of the resulting network.
}
+\note{
+By default sfnetworks rounds coordinates to 12 decimal places to
+determine spatial equality. You can influence this behavior by explicitly
+setting the precision of the networks using
+\code{\link[sf]{st_set_precision}}.
+}
\examples{
library(sf, quietly = TRUE)
-node1 = st_point(c(0, 0))
-node2 = st_point(c(1, 0))
-node3 = st_point(c(1,1))
-node4 = st_point(c(0,1))
-edge1 = st_sfc(st_linestring(c(node1, node2)))
-edge2 = st_sfc(st_linestring(c(node2, node3)))
-edge3 = st_sfc(st_linestring(c(node3, node4)))
+oldpar = par(no.readonly = TRUE)
+par(mar = c(1,1,1,1), mfrow = c(1,2))
-net1 = as_sfnetwork(c(edge1, edge2))
-net2 = as_sfnetwork(c(edge2, edge3))
+# Create two networks.
+n1 = st_point(c(0, 0))
+n2 = st_point(c(1, 0))
+n3 = st_point(c(1,1))
+n4 = st_point(c(0,1))
-joined = st_network_join(net1, net2)
-joined
+e1 = st_sfc(st_linestring(c(n1, n2)))
+e2 = st_sfc(st_linestring(c(n2, n3)))
+e3 = st_sfc(st_linestring(c(n3, n4)))
+
+neta = as_sfnetwork(c(e1, e2))
+netb = as_sfnetwork(c(e2, e3))
+
+# Join the networks based on spatial equality of nodes.
+net = st_network_join(neta, netb)
+net
+
+# Plot.
+plot(neta, pch = 15, cex = 2, lwd = 4)
+plot(netb, col = "orange", pch = 18, cex = 2, lty = 3, lwd = 4, add = TRUE)
+plot(net, cex = 2, lwd = 4)
-## Plot results.
-oldpar = par(no.readonly = TRUE)
-par(mar = c(1,1,1,1), mfrow = c(1,2))
-plot(net1, pch = 15, cex = 2, lwd = 4)
-plot(net2, col = "red", pch = 18, cex = 2, lty = 3, lwd = 4, add = TRUE)
-plot(joined, cex = 2, lwd = 4)
par(oldpar)
}
diff --git a/man/st_network_paths.Rd b/man/st_network_paths.Rd
index b2842570..2925335d 100644
--- a/man/st_network_paths.Rd
+++ b/man/st_network_paths.Rd
@@ -2,131 +2,163 @@
% Please edit documentation in R/paths.R
\name{st_network_paths}
\alias{st_network_paths}
-\title{Paths between points in geographical space}
+\title{Find shortest paths between nodes in a spatial network}
\usage{
st_network_paths(
x,
from,
- to = igraph::V(x),
- weights = NULL,
- type = "shortest",
- use_names = TRUE,
+ to = node_ids(x),
+ weights = edge_length(),
+ all = FALSE,
+ k = 1,
+ direction = "out",
+ router = getOption("sfn_default_router", "igraph"),
+ use_names = FALSE,
+ return_cost = TRUE,
+ return_geometry = TRUE,
...
)
}
\arguments{
\item{x}{An object of class \code{\link{sfnetwork}}.}
-\item{from}{The geospatial point from which the paths will be
-calculated. Can be an object an object of class \code{\link[sf]{sf}} or
-\code{\link[sf]{sfc}}, containing a single feature. When multiple features
-are given, only the first one is used.
-Alternatively, it can be an integer, referring to the index of the
-node from which the paths will be calculated, or a character,
-referring to the name of the node from which the paths will be
-calculated.}
-
-\item{to}{The (set of) geospatial point(s) to which the paths will be
-calculated. Can be an object of class \code{\link[sf]{sf}} or
-\code{\link[sf]{sfc}}.
-Alternatively it can be a numeric vector containing the indices of the nodes
-to which the paths will be calculated, or a character vector
-containing the names of the nodes to which the paths will be
-calculated. By default, all nodes in the network are included.}
+\item{from}{The node where the paths should start. Evaluated by
+\code{\link{evaluate_node_query}}.}
+
+\item{to}{The nodes where the paths should end. Evaluated by
+\code{\link{evaluate_node_query}}. By default, all nodes in the network are
+included.}
\item{weights}{The edge weights to be used in the shortest path calculation.
-Can be a numeric vector giving edge weights, or a column name referring to
-an attribute column in the edges table containing those weights. If set to
-\code{NULL}, the values of a column named \code{weight} in the edges table
-will be used automatically, as long as this column is present. If not, the
-geographic edge lengths will be calculated internally and used as weights.
-If set to \code{NA}, no weights are used, even if the edges have a
-\code{weight} column. Ignored when \code{type = 'all_simple'}.}
-
-\item{type}{Character defining which type of path calculation should be
-performed. If set to \code{'shortest'} paths are calculated using
-\code{\link[igraph]{shortest_paths}}, if set to
-\code{'all_shortest'} paths are calculated using
-\code{\link[igraph]{all_shortest_paths}}, if set to
-\code{'all_simple'} paths are calculated using
-\code{\link[igraph]{all_simple_paths}}. Defaults to \code{'shortest'}.}
+Evaluated by \code{\link{evaluate_weight_spec}}. The default is
+\code{\link{edge_length}}, which computes the geographic lengths of the
+edges.}
+
+\item{all}{Should all shortest paths be returned for each pair of nodes? If
+set to \code{FALSE}, only one shortest path is returned for each pair of
+nodes, even if multiple shortest paths exist. Defaults to \code{FALSE}.}
+
+\item{k}{The number of paths to find. Setting this to any integer higher
+than 1 returns not only the shortest path, but also the next k - 1 loopless
+shortest paths, which may be longer than the shortest path. Currently, this
+is only supported for one-to-one routing, meaning that both the from and to
+argument should be of length 1. This argument is ignored if \code{all} is
+set to \code{TRUE}.}
+
+\item{direction}{The direction of travel. Defaults to \code{'out'}, meaning
+that the direction given by the network is followed and paths are found from
+the node given as argument \code{from}. May be set to \code{'in'}, meaning
+that the opposite direction is followed an paths are found towards the node
+given as argument \code{from}. May also be set to \code{'all'}, meaning that
+the network is considered to be undirected. This argument is ignored for
+undirected networks.}
+
+\item{router}{The routing backend to use for the shortest path computation.
+Currently supported options are \code{'igraph'} and \code{'dodgr'}. See
+Details.}
\item{use_names}{If a column named \code{name} is present in the nodes
table, should these names be used to encode the nodes in a path, instead of
-the node indices? Defaults to \code{TRUE}. Ignored when the nodes table does
+the node indices? Defaults to \code{FALSE}. Ignored when the nodes table does
not have a column named \code{name}.}
-\item{...}{Arguments passed on to the corresponding
-\code{\link[igraph:shortest_paths]{igraph}} or
-\code{\link[igraph:all_simple_paths]{igraph}} function. Arguments
-\code{predecessors} and \code{inbound.edges} are ignored.}
+\item{return_cost}{Should the total cost of each path be computed? Defaults
+to \code{TRUE}.}
+
+\item{return_geometry}{Should a linestring geometry be constructed for each
+path? Defaults to \code{TRUE}. The geometries are constructed by calling
+\code{\link[sf]{st_line_merge}} on the linestring geometries of the edges in
+the path. Ignored for networks with spatially implicit edges.}
+
+\item{...}{Additional arguments passed on to the underlying function of the
+chosen routing backend. See Details.}
}
\value{
-An object of class \code{\link[tibble]{tbl_df}} with one row per
-returned path. Depending on the setting of the \code{type} argument,
-columns can be \code{node_paths} (a list column with for each path the
-ordered indices of nodes present in that path) and \code{edge_paths}
-(a list column with for each path the ordered indices of edges present in
-that path). \code{'all_shortest'} and \code{'all_simple'} return only
-\code{node_paths}, while \code{'shortest'} returns both.
+An object of class \code{\link[sf]{sf}} with one row per requested
+path. If \code{return_geometry = FALSE} or edges are spatially implicit, a
+\code{\link[tibble]{tbl_df}} is returned instead. If a requested path could
+not be found, it is included in the output as an empty path.
+
+Depending on the argument settings, the output may include the following
+columns:
+
+\itemize{
+ \item \code{from}: The index of the node at the start of the path.
+ \item \code{to}: The index of the node at the end of the path.
+ \item \code{node_path}: A vector containing the indices of all nodes on
+ the path, in order of visit.
+ \item \code{edge_path}: A vector containing the indices of all edges on
+ the path, in order of visit.
+ \item \code{path_found}: A boolean describing if the requested path exists.
+ \item \code{cost}: The total cost of the path, obtained by summing the
+ weights of all visited edges. Included if \code{return_cost = TRUE}.
+ \item \code{geometry}: The geometry of the path, obtained by merging the
+ geometries of all visited edges. Included if \code{return_geometry = TRUE}
+ and the network has spatially explicit edges.
+}
}
\description{
-Combined wrapper around \code{\link[igraph]{shortest_paths}},
-\code{\link[igraph]{all_shortest_paths}} and
-\code{\link[igraph]{all_simple_paths}} from \code{\link[igraph]{igraph}},
-allowing to provide any geospatial point as \code{from} argument and any
-set of geospatial points as \code{to} argument. If such a geospatial point
-is not equal to a node in the network, it will be snapped to its nearest
-node before calculating the shortest or simple paths.
+Find shortest paths between nodes in a spatial network
}
\details{
-Spatial features provided to the \code{from} and/or
-\code{to} argument don't necessarily have to be points. Internally, the
-nearest node to each feature is found by calling
-\code{\link[sf]{st_nearest_feature}}, so any feature with a geometry type
-that is accepted by that function can be provided as \code{from} and/or
-\code{to} argument.
-
-When directly providing integer node indices or character node names to the
-\code{from} and/or \code{to} argument, keep the following in mind. A node
-index should correspond to a row-number of the nodes table of the network.
-A node name should correspond to a value of a column in the nodes table
-named \code{name}. This column should contain character values without
-duplicates.
-
-For more details on the wrapped functions from \code{\link[igraph]{igraph}}
-see the \code{\link[igraph]{shortest_paths}} or
-\code{\link[igraph]{all_simple_paths}} documentation pages.
+The sfnetworks package does not implement its own routing algorithms
+to find shortest paths. Instead, it relies on "routing backends", i.e. other
+R packages that have implemented such algorithms. Currently two different
+routing backends are supported.
+
+The default is \code{\link[igraph]{igraph}}. This package supports
+one-to-many shortest path calculation with the
+\code{\link[igraph]{shortest_paths}} function. Note that multiple from nodes
+are not supported. If multiple from nodes are given, only the first one is
+taken. The igraph router also supports the computation of all shortest path
+(see the \code{all} argument) through the
+\code{\link[igraph]{all_shortest_paths}} function and of k shortest paths
+(see the \code{k} argument) through the
+\code{\link[igraph]{k_shortest_paths}} function. In the latter case, only
+one-to-one routing is supported, meaning that also only one to node should
+be provided. The igraph router does not support dual-weighted routing.
+
+The second supported routing backend is \code{\link[dodgr]{dodgr}}. This
+package supports many-to-many shortest path calculation with the
+\code{\link[dodgr]{dodgr_paths}} function. It also supports dual-weighted
+routing. The computation of all shortest paths and k shortest paths is
+currently not supported by the dodgr router. The dodgr package is a
+conditional dependency of sfnetworks. Using the dodgr router requires the
+dodgr package to be installed.
+
+The default router can be changed by setting the \code{sfn_default_router}
+option.
}
\examples{
library(sf, quietly = TRUE)
library(tidygraph, quietly = TRUE)
-# Create a network with edge lengths as weights.
-# These weights will be used automatically in shortest paths calculation.
-net = as_sfnetwork(roxel, directed = FALSE) \%>\%
- st_transform(3035) \%>\%
- activate("edges") \%>\%
- mutate(weight = edge_length())
+oldpar = par(no.readonly = TRUE)
+par(mar = c(1,1,1,1))
-# Providing node indices.
+net = as_sfnetwork(roxel, directed = FALSE) |>
+ st_transform(3035)
+
+# Compute the shortest path between two nodes.
+# Note that geographic edge length is used as edge weights by default.
paths = st_network_paths(net, from = 495, to = 121)
paths
-node_path = paths \%>\%
- slice(1) \%>\%
- pull(node_paths) \%>\%
- unlist()
-node_path
+plot(net, col = "grey")
+plot(st_geometry(net)[paths$from], pch = 20, cex = 2, add = TRUE)
+plot(st_geometry(paths), col = "orange", lwd = 3, add = TRUE)
+
+# Compute the shortest paths from one to multiple nodes.
+# This will return a tibble with one row per path.
+paths = st_network_paths(net, from = 495, to = c(121, 131, 141))
+paths
-oldpar = par(no.readonly = TRUE)
-par(mar = c(1,1,1,1))
plot(net, col = "grey")
-plot(slice(activate(net, "nodes"), node_path), col = "red", add = TRUE)
-par(oldpar)
+plot(st_geometry(net)[paths$from], pch = 20, cex = 2, add = TRUE)
+plot(st_geometry(paths), col = "orange", lwd = 3, add = TRUE)
-# Providing nodes as spatial points.
-# Points that don't equal a node will be snapped to their nearest node.
+# Compute the shortest path between two spatial point features.
+# These are snapped to their nearest node before finding the path.
p1 = st_geometry(net, "nodes")[495] + st_sfc(st_point(c(50, -50)))
st_crs(p1) = st_crs(net)
p2 = st_geometry(net, "nodes")[121] + st_sfc(st_point(c(-10, 100)))
@@ -135,38 +167,47 @@ st_crs(p2) = st_crs(net)
paths = st_network_paths(net, from = p1, to = p2)
paths
-node_path = paths \%>\%
- slice(1) \%>\%
- pull(node_paths) \%>\%
- unlist()
-node_path
-
-oldpar = par(no.readonly = TRUE)
-par(mar = c(1,1,1,1))
plot(net, col = "grey")
-plot(c(p1, p2), col = "black", pch = 8, add = TRUE)
-plot(slice(activate(net, "nodes"), node_path), col = "red", add = TRUE)
-par(oldpar)
-
-# Using another column for weights.
-net \%>\%
- activate("edges") \%>\%
- mutate(foo = runif(n(), min = 0, max = 1)) \%>\%
- st_network_paths(p1, p2, weights = "foo")
+plot(c(p1, p2), pch = 20, cex = 2, add = TRUE)
+plot(st_geometry(net)[paths$from], pch = 4, cex = 2, add = TRUE)
+plot(st_geometry(paths), col = "orange", lwd = 3, add = TRUE)
+
+# Use a node type query function to specify destinations.
+st_network_paths(net, 1, node_is_adjacent(1))
+
+# Use a spatial edge measure to specify edge weights.
+# By default edge_length() is used.
+st_network_paths(net, p1, p2, weights = edge_displacement())
+
+# Use a column in the edges table to specify edge weights.
+# This uses tidy evaluation.
+net |>
+ activate("edges") |>
+ mutate(foo = runif(n(), min = 0, max = 1)) |>
+ st_network_paths(p1, p2, weights = foo)
+
+# Compute the shortest paths without edge weights.
+# This is the path with the fewest number of edges, ignoring space.
+st_network_paths(net, p1, p2, weights = NA)
+
+# Use the dodgr router for many-to-many routing.
+paths = st_network_paths(net,
+ from = c(1, 2),
+ to = c(10, 11),
+ router = "dodgr"
+)
-# Obtaining all simple paths between two nodes.
-# Beware, this function can take long when:
-# --> Providing a lot of 'to' nodes.
-# --> The network is large and dense.
-net = as_sfnetwork(roxel, directed = TRUE)
-st_network_paths(net, from = 1, to = 12, type = "all_simple")
+# Use the dodgr router for dual-weighted routing.
+paths = st_network_paths(net,
+ from = c(1, 2),
+ to = c(10, 11),
+ weights = dual_weights(edge_segment_count(), edge_length()),
+ router = "dodgr"
+)
-# Obtaining all shortest paths between two nodes.
-# Not using edge weights.
-# Hence, a shortest path is the paths with the least number of edges.
-st_network_paths(net, from = 5, to = 1, weights = NA, type = "all_shortest")
+par(oldpar)
}
\seealso{
-\code{\link{st_network_cost}}
+\code{\link{st_network_cost}}, \code{\link{st_network_travel}}
}
diff --git a/man/st_network_travel.Rd b/man/st_network_travel.Rd
new file mode 100644
index 00000000..fee600dc
--- /dev/null
+++ b/man/st_network_travel.Rd
@@ -0,0 +1,136 @@
+% Generated by roxygen2: do not edit by hand
+% Please edit documentation in R/travel.R
+\name{st_network_travel}
+\alias{st_network_travel}
+\title{Find the optimal route through a set of nodes in a spatial network}
+\usage{
+st_network_travel(
+ x,
+ nodes,
+ weights = edge_length(),
+ optimizer = "TSP",
+ router = getOption("sfn_default_router", "igraph"),
+ return_paths = TRUE,
+ use_names = FALSE,
+ return_cost = TRUE,
+ return_geometry = TRUE,
+ ...
+)
+}
+\arguments{
+\item{x}{An object of class \code{\link{sfnetwork}}.}
+
+\item{nodes}{Nodes to be visited. Evaluated by
+\code{\link{evaluate_node_query}}.}
+
+\item{weights}{The edge weights to be used in the shortest path calculation.
+Evaluated by \code{\link{evaluate_weight_spec}}. The default is
+\code{\link{edge_length}}, which computes the geographic lengths of the
+edges.}
+
+\item{optimizer}{The optimization backend to use for defining the optimal
+visiting order of the given nodes. Currently the only supported option is
+\code{'TSP'}. See Details.}
+
+\item{router}{The routing backend to use for the cost matrix computation and
+the path computation. Currently supported options are \code{'igraph'} and
+\code{'dodgr'}. See Details.}
+
+\item{return_paths}{After defining the optimal visiting order of nodes,
+should the actual paths connecting those nodes be computed and returned?
+Defaults to \code{TRUE}. If set to \code{FALSE}, a vector of indices in
+visiting order is returned instead, with each index specifying the position
+of the visited node in the \code{from} argument.}
+
+\item{use_names}{If a column named \code{name} is present in the nodes
+table, should these names be used to encode the nodes in the route, instead
+of the node indices? Defaults to \code{FALSE}. Ignored when the nodes table
+does not have a column named \code{name} and if \code{return_paths = FALSE}.}
+
+\item{return_cost}{Should the total cost of each path between two subsequent
+nodes be computed? Defaults to \code{TRUE}.
+Ignored if \code{return_paths = FALSE}.}
+
+\item{return_geometry}{Should a linestring geometry be constructed for each
+path between two subsequent nodes? Defaults to \code{TRUE}. The geometries
+are constructed by calling \code{\link[sf]{st_line_merge}} on the linestring
+geometries of the edges in the path. Ignored if \code{return_paths = FALSE}
+and for networks with spatially implicit edges.}
+
+\item{...}{Additional arguments passed on to the underlying function of the
+chosen optimization backend. See Details.}
+}
+\value{
+An object of class \code{\link[sf]{sf}} with one row per leg of the
+optimal route, containing the path of that leg.
+If \code{return_geometry = FALSE} or edges are spatially implicit, a
+\code{\link[tibble]{tbl_df}} is returned instead. See the documentation of
+\code{\link{st_network_paths}} for details. If \code{return_paths = FALSE},
+a vector of indices in visiting order is returned, with each index
+specifying the position of the visited node in the \code{from} argument.
+}
+\description{
+Solve the travelling salesman problem by finding the shortest route through
+a set of nodes that visits each of those nodes once.
+}
+\details{
+The sfnetworks package does not implement its own route optimization
+algorithms. Instead, it relies on "optimization backends", i.e. other R
+packages that have implemented such algorithms. Currently the only supported
+optimization backend to solve the travelling salesman problem is the
+\code{\link[TSP:TSP-package]{TSP}} package, which provides the
+\code{\link[TSP]{solve_TSP}} function for this task.
+
+An input for most route optimization algorithms is the matrix containing the
+travel costs between the nodes to be visited. This is computed using
+\code{\link{st_network_cost}}. The output of most route optimization
+algorithms is the optimal order in which the given nodes should be visited.
+To compute the actual paths that connect the nodes in that order, the
+\code{\link{st_network_paths}} function is used. Both cost matrix computation
+and shortest paths computation allow to specify a "routing backend", i.e. an
+R package that implements algorithms to solve those tasks. See the
+documentation of the corresponding functions for details.
+}
+\examples{
+library(sf, quietly = TRUE)
+
+oldpar = par(no.readonly = TRUE)
+par(mar = c(1,1,1,1))
+
+net = as_sfnetwork(roxel, directed = FALSE) |>
+ st_transform(3035)
+
+# Compute the optimal route through three nodes.
+# Note that geographic edge length is used as edge weights by default.
+route = st_network_travel(net, c(1, 10, 100))
+route
+
+plot(net, col = "grey")
+plot(st_geometry(net)[route$from], pch = 20, cex = 2, add = TRUE)
+plot(st_geometry(route), col = "orange", lwd = 3, add = TRUE)
+
+# Instead of returning a path we can return a vector of visiting order.
+st_network_travel(net, c(1, 10, 100), return_paths = FALSE)
+
+# Use spatial point features to specify the visiting locations.
+# These are snapped to their nearest node before finding the path.
+p1 = st_geometry(net, "nodes")[1] + st_sfc(st_point(c(50, -50)))
+p2 = st_geometry(net, "nodes")[10] + st_sfc(st_point(c(-10, 100)))
+p3 = st_geometry(net, "nodes")[100] + st_sfc(st_point(c(-10, 100)))
+pts = c(p1, p2, p3)
+st_crs(pts) = st_crs(net)
+
+route = st_network_travel(net, pts)
+route
+
+plot(net, col = "grey")
+plot(pts, pch = 20, cex = 2, add = TRUE)
+plot(st_geometry(net)[route$from], pch = 4, cex = 2, add = TRUE)
+plot(st_geometry(route), col = "orange", lwd = 3, add = TRUE)
+
+par(oldpar)
+
+}
+\seealso{
+\code{\link{st_network_paths}}, \code{\link{st_network_cost}}
+}
diff --git a/man/st_project_on_network.Rd b/man/st_project_on_network.Rd
new file mode 100644
index 00000000..cb7dabb8
--- /dev/null
+++ b/man/st_project_on_network.Rd
@@ -0,0 +1,77 @@
+% Generated by roxygen2: do not edit by hand
+% Please edit documentation in R/project.R
+\name{st_project_on_network}
+\alias{st_project_on_network}
+\title{Project spatial points on a spatial network}
+\usage{
+st_project_on_network(x, network, on = "edges")
+}
+\arguments{
+\item{x}{The spatial features to be projected, either as object of class
+\code{\link[sf]{sf}} or \code{\link[sf]{sfc}}, with \code{POINT} geometries.}
+
+\item{network}{An object of class \code{\link{sfnetwork}}.}
+
+\item{on}{On what component of the network should the points be projected?
+Setting it to \code{'edges'} (the default) will find the nearest point on
+the nearest edge to each point in \code{x}. Setting it to \code{'nodes'}
+will find the nearest node to each point in \code{x}.}
+}
+\value{
+The same object as \code{x} but with its geometries replaced by the
+projections.
+}
+\description{
+Project spatial points on a spatial network
+}
+\details{
+This function uses \code{\link[sf]{st_nearest_feature}} to find
+the nearest edge or node to each feature in \code{x}. When projecting on
+edges, it then finds the nearest point on the nearest edge by calling
+\code{\link[sf]{st_nearest_points}} in a pairwise manner.
+}
+\note{
+Due to internal rounding of rational numbers, even a point projected
+on an edge may not be evaluated as actually intersecting that edge when
+calling \code{\link[sf]{st_intersects}}.
+}
+\examples{
+library(sf, quietly = TRUE)
+
+oldpar = par(no.readonly = TRUE)
+par(mar = c(1,1,1,1))
+
+# Create a spatial network.
+n1 = st_point(c(0, 0))
+n2 = st_point(c(1, 0))
+n3 = st_point(c(2, 0))
+
+e1 = st_sfc(st_linestring(c(n1, n2)), crs = 3857)
+e2 = st_sfc(st_linestring(c(n2, n3)), crs = 3857)
+
+net = as_sfnetwork(c(e1, e2))
+
+# Create spatial points to project in.
+p1 = st_sfc(st_point(c(0.25, 0.1)))
+p2 = st_sfc(st_point(c(1, 0.2)))
+p3 = st_sfc(st_point(c(1.75, 0.15)))
+
+pts = st_sf(foo = letters[1:3], geometry = c(p1, p2, p3), crs = 3857)
+
+# Project points to the edges of the network.
+p1 = st_project_on_network(pts, net)
+
+plot(net)
+plot(st_geometry(pts), pch = 20, col = "orange", add = TRUE)
+plot(st_geometry(p1), pch = 4, col = "orange", add = TRUE)
+
+# Project points to the nodes of the network.
+p2 = st_project_on_network(pts, net, on = "nodes")
+
+plot(net)
+plot(st_geometry(pts), pch = 20, col = "orange", add = TRUE)
+plot(st_geometry(p2), pch = 4, col = "orange", add = TRUE)
+
+par(oldpar)
+
+}
diff --git a/man/st_round.Rd b/man/st_round.Rd
new file mode 100644
index 00000000..8988dde7
--- /dev/null
+++ b/man/st_round.Rd
@@ -0,0 +1,33 @@
+% Generated by roxygen2: do not edit by hand
+% Please edit documentation in R/utils.R
+\name{st_round}
+\alias{st_round}
+\title{Rounding of geometry coordinates}
+\usage{
+st_round(x, digits = 0)
+}
+\arguments{
+\item{x}{An object of class \code{\link[sf]{sf}} or \code{\link[sf]{sfc}}.}
+
+\item{digits}{Integer indicating the number of decimal places to be used.}
+}
+\value{
+An object of class \code{\link[sf]{sf}} or \code{\link[sf]{sfc}}
+with rounded coordinates.
+}
+\description{
+Rounding of geometry coordinates
+}
+\examples{
+library(sf, quietly = TRUE)
+
+p1 = st_sfc(st_point(c(1.123, 1.123)))
+p2 = st_sfc(st_point(c(0.789, 0.789)))
+p3 = st_sfc(st_point(c(1.123, 0.789)))
+
+st_round(st_as_sf(c(p1, p2, p2, p3, p1)), digits = 1)
+
+}
+\seealso{
+\code{\link{round}}
+}
diff --git a/man/subdivide_edges.Rd b/man/subdivide_edges.Rd
new file mode 100644
index 00000000..f6000f42
--- /dev/null
+++ b/man/subdivide_edges.Rd
@@ -0,0 +1,42 @@
+% Generated by roxygen2: do not edit by hand
+% Please edit documentation in R/subdivide.R
+\name{subdivide_edges}
+\alias{subdivide_edges}
+\title{Subdivide edges at interior points}
+\usage{
+subdivide_edges(x, protect = NULL, all = FALSE, merge = TRUE)
+}
+\arguments{
+\item{x}{An object of class \code{\link{sfnetwork}} with spatially explicit
+edges.}
+
+\item{protect}{An integer vector of edge indices specifying which edges
+should be protected from being subdivided. Defaults to \code{NULL}, meaning
+that none of the edges is protected.}
+
+\item{all}{Should edges be subdivided at all their interior points? If set
+to \code{FALSE}, edges are only subdivided at those interior points that
+share their location with any other interior or boundary point (a node) in
+the edges table. Defaults to \code{FALSE}.}
+
+\item{merge}{Should multiple subdivision points at the same location be
+merged into a single node, and should subdivision points at the same
+location as an existing node be merged into that node? Defaults to
+\code{TRUE}. If set to \code{FALSE}, each subdivision point is added
+separately as a new node to the network.}
+}
+\value{
+The subdivision of x as object of class \code{\link{sfnetwork}}.
+}
+\description{
+Construct a subdivision of the network by subdividing edges at interior
+points. Subdividing means that a new node is added on an edge, and the edge
+is split in two at that location. Interior points are those points that
+shape a linestring geometry feature but are not endpoints of it.
+}
+\note{
+By default sfnetworks rounds coordinates to 12 decimal places to
+determine spatial equality. You can influence this behavior by explicitly
+setting the precision of the network using
+\code{\link[sf]{st_set_precision}}.
+}
diff --git a/man/tidygraph_methods.Rd b/man/tidygraph_methods.Rd
new file mode 100644
index 00000000..f02be66f
--- /dev/null
+++ b/man/tidygraph_methods.Rd
@@ -0,0 +1,55 @@
+% Generated by roxygen2: do not edit by hand
+% Please edit documentation in R/tidygraph.R
+\name{tidygraph_methods}
+\alias{tidygraph_methods}
+\alias{as_tbl_graph.sfnetwork}
+\alias{reroute.sfnetwork}
+\alias{morph.sfnetwork}
+\alias{unmorph.morphed_sfnetwork}
+\title{tidygraph methods for sfnetworks}
+\usage{
+\method{as_tbl_graph}{sfnetwork}(x, ...)
+
+\method{reroute}{sfnetwork}(.data, ...)
+
+\method{morph}{sfnetwork}(.data, ...)
+
+\method{unmorph}{morphed_sfnetwork}(.data, ...)
+}
+\arguments{
+\item{x}{An object of class \code{\link{sfnetwork}}.}
+
+\item{...}{Arguments passed on the corresponding \code{tidygraph} function.}
+
+\item{.data}{An object of class \code{\link{sfnetwork}}.}
+}
+\value{
+The method for \code{\link[tidygraph]{as_tbl_graph}} returns an
+object of class \code{\link[tidygraph]{tbl_graph}}. The method for
+\code{\link[tidygraph]{morph}} returns a \code{morphed_sfnetwork} if the
+morphed network is still spatial, and a \code{morphed_tbl_graph} otherwise.
+All other methods return an object of class \code{\link{sfnetwork}}.
+}
+\description{
+Normally tidygraph functions should work out of the box on
+\code{\link{sfnetwork}} objects, but in some cases special treatment is
+needed especially for the geometry column, requiring a specific method.
+}
+\details{
+See the \code{\link[tidygraph]{tidygraph}} documentation. The
+following methods have a special behavior:
+
+\itemize{
+ \item \code{reroute}: To preserve the valid spatial network structure,
+ this method will replace the boundaries of edge geometries by the location
+ of the node those edges are rerouted to or from. Note that when the goal
+ is to reverse edges in a spatial network, reroute will not simply reverse
+ the edge geometries. In that case it is recommended to use the sfnetwork
+ method for \code{\link[sf]{st_reverse}} instead.
+ \item \code{morph}: This method checks if the morphed network still has
+ spatially embedded nodes. In that case a \code{morphed_sfnetwork} is
+ returned. If not, a \code{morphed_tbl_graph} is returned instead.
+ \item \code{unmorph}: This method makes sure the geometry list column is
+ correctly handled during the unmorphing process.
+}
+}
diff --git a/man/validate_network.Rd b/man/validate_network.Rd
new file mode 100644
index 00000000..886e0b77
--- /dev/null
+++ b/man/validate_network.Rd
@@ -0,0 +1,27 @@
+% Generated by roxygen2: do not edit by hand
+% Please edit documentation in R/validate.R
+\name{validate_network}
+\alias{validate_network}
+\title{Validate the structure of a sfnetwork}
+\usage{
+validate_network(x, message = TRUE)
+}
+\arguments{
+\item{x}{An object of class \code{\link{sfnetwork}}.}
+
+\item{message}{Should messages be printed during validation? Defaults to
+\code{TRUE}.}
+}
+\value{
+Nothing when the network is valid. Otherwise, an error is thrown.
+}
+\description{
+Validate the structure of a sfnetwork
+}
+\details{
+A valid sfnetwork structure means that all nodes have \code{POINT}
+geometries, and - when edges are spatially explicit - all edges have
+\code{LINESTRING} geometries, nodes and edges have the same coordinate
+reference system and the same coordinate precision, and coordinates of
+edge boundaries match coordinates of their corresponding nodes.
+}
diff --git a/man/wrap_igraph.Rd b/man/wrap_igraph.Rd
new file mode 100644
index 00000000..f8f78be8
--- /dev/null
+++ b/man/wrap_igraph.Rd
@@ -0,0 +1,56 @@
+% Generated by roxygen2: do not edit by hand
+% Please edit documentation in R/utils.R
+\name{wrap_igraph}
+\alias{wrap_igraph}
+\title{Run an igraph function on an sfnetwork object}
+\usage{
+wrap_igraph(.data, .f, ..., .force = FALSE, .message = TRUE)
+}
+\arguments{
+\item{.data}{An object of class \code{\link{sfnetwork}}.}
+
+\item{.f}{An function from the \code{\link[igraph]{igraph}} package that
+accepts a graph as its first argument, and returns a graph.}
+
+\item{...}{Arguments passed on to \code{.f}.}
+
+\item{.force}{Should network validity checks be skipped? Defaults to
+\code{FALSE}, meaning that network validity checks are executed when
+returning the new network. These checks guarantee a valid spatial network
+structure. For the nodes, this means that they all should have \code{POINT}
+geometries. In the case of spatially explicit edges, it is also checked that
+all edges have \code{LINESTRING} geometries, nodes and edges have the same
+CRS and boundary points of edges match their corresponding node coordinates.
+These checks are important, but also time consuming. If you are already sure
+your input data meet the requirements, the checks are unnecessary and can be
+turned off to improve performance.}
+
+\item{.message}{Should informational messages (those messages that are
+neither warnings nor errors) be printed when constructing the network?
+Defaults to \code{TRUE}.}
+}
+\value{
+An object of class \code{\link{sfnetwork}}.
+}
+\description{
+Since \code{\link{sfnetwork}} objects inherit \code{\link[igraph]{igraph}}
+objects, any igraph function can be called on a sfnetwork. However, if this
+function returns a network, it will be an igraph object rather than a
+sfnetwork object. With \code{\link{wrap_igraph}}, such a function will
+preserve the sfnetwork class, after checking if the network returned by
+igraph still has a valid spatial network structure.
+}
+\examples{
+oldpar = par(no.readonly = TRUE)
+par(mar = c(1,1,1,1), mfrow = c(1,2))
+
+net = as_sfnetwork(mozart, "delaunay", directed = FALSE)
+mst = wrap_igraph(net, igraph::mst, .message = FALSE)
+mst
+
+plot(net)
+plot(mst)
+
+par(oldpar)
+
+}
diff --git a/tests/testthat/test_blend.R b/tests/testthat/test_blend.R
index ef0eb172..25c6bf88 100644
--- a/tests/testthat/test_blend.R
+++ b/tests/testthat/test_blend.R
@@ -1,5 +1,4 @@
library(sf)
-library(igraph)
library(dplyr)
node1 = st_point(c(0, 0))
node2 = st_point(c(1, 0))
@@ -10,24 +9,24 @@ net = as_sfnetwork(edge)
pois = data.frame(
poi_type = c("bakery", "butcher", "market"),
x = c(0, 0.6, 0.3), y = c(0.1, 0.1, 0)
- ) %>%
+ ) |>
st_as_sf(coords = c("x", "y"))
test_that("st_network_blend with tolerance argument too large gives a
warning and does not perform split", {
expect_warning(
blend1 <- st_network_blend(net, pois[1:2, ], tolerance = 0.05),
- "No points were blended"
+ "did not blend any points into the network"
)
- expect_equal(vcount(blend1), 2)
- expect_equal(ecount(blend1), 1)
+ expect_equal(n_nodes(blend1), 2)
+ expect_equal(n_edges(blend1), 1)
})
test_that("st_network_blend splits edges with nodes on and/or close to
the network", {
blend2 <- st_network_blend(net, pois)
- expect_equal(vcount(blend2), 4)
- expect_equal(ecount(blend2), 3)
+ expect_equal(n_nodes(blend2), 4)
+ expect_equal(n_edges(blend2), 3)
})
test_that("st_network_blend sorts nodes in the correct way", {
diff --git a/tests/testthat/test_edges_nodes.R b/tests/testthat/test_edges_nodes.R
index d725834b..28d6390a 100644
--- a/tests/testthat/test_edges_nodes.R
+++ b/tests/testthat/test_edges_nodes.R
@@ -1,6 +1,5 @@
library(sf)
library(dplyr)
-library(igraph)
# toynet
p1 = st_point(c(0, 1))
@@ -32,30 +31,31 @@ point = st_sfc(st_point(c(2, 0)))
net = as_sfnetwork(lines)
## Edge measures
-circuity_with_nan = net %>%
- activate("edges") %>%
- mutate(circuity = edge_circuity(Inf_as_NaN = TRUE)) %>%
+circuity_with_nan = net |>
+ activate("edges") |>
+ mutate(circuity = edge_circuity(Inf_as_NaN = TRUE)) |>
pull(circuity)
-circuity_with_inf = net %>%
- activate("edges") %>%
- mutate(circuity = edge_circuity(Inf_as_NaN = FALSE)) %>%
+circuity_with_inf = net |>
+ activate("edges") |>
+ mutate(circuity = edge_circuity(Inf_as_NaN = FALSE)) |>
pull(circuity)
-length = net %>%
- activate("edges") %>%
- mutate(length = edge_length()) %>%
+length = net |>
+ activate("edges") |>
+ mutate(length = edge_length()) |>
pull(length)
-displacement = net %>%
- activate("edges") %>%
- mutate(disp = edge_displacement()) %>%
+displacement = net |>
+ activate("edges") |>
+ mutate(disp = edge_displacement()) |>
pull(disp)
-implicit_length = lines %>%
- as_sfnetwork(edges_as_lines = F) %>%
- activate("edges") %>%
- mutate(length = edge_length()) %>%
+implicit_length = lines |>
+ as_sfnetwork() |>
+ make_edges_implicit() |>
+ activate("edges") |>
+ mutate(length = edge_length()) |>
pull(length)
test_that("spatial_edge_measures return correct (known) values", {
@@ -87,140 +87,140 @@ test_that("edge_length returns same output as edge_displacement with
## spatial predicates
# Edge predicates
-net = net %>%
+net = net |>
activate("edges")
-edgeint = net %>%
+edgeint = net |>
filter(edge_intersects(square))
-edgecross = net %>%
+edgecross = net |>
filter(edge_crosses(square))
-edgecov = net %>%
+edgecov = net |>
filter(edge_is_covered_by(square))
-edgedisj = net %>%
+edgedisj = net |>
filter(edge_is_disjoint(square))
-edgetouch = net %>%
+edgetouch = net |>
filter(edge_touches(square))
-edgewithin = net %>%
+edgewithin = net |>
filter(edge_is_within(square))
-edgewithindist = net %>%
+edgewithindist = net |>
filter(edge_is_within_distance(point, 1))
test_that("spatial edge predicates return correct edges", {
expect_true(
all(diag(
- st_geometry(st_as_sf(edgeint, "edges")) %>%
+ st_geometry(st_as_sf(edgeint, "edges")) |>
st_equals(c(l2, l3, l4, l5, l6, l7), sparse = FALSE)
))
)
expect_true(
- st_geometry(st_as_sf(edgecross, "edges")) %>%
+ st_geometry(st_as_sf(edgecross, "edges")) |>
st_equals(l2, sparse = FALSE)
)
expect_true(
all(diag(
- st_geometry(st_as_sf(edgecov, "edges")) %>%
+ st_geometry(st_as_sf(edgecov, "edges")) |>
st_equals(c(l3, l5), sparse = FALSE)
))
)
expect_true(
- st_geometry(st_as_sf(edgedisj, "edges")) %>%
+ st_geometry(st_as_sf(edgedisj, "edges")) |>
st_equals(l1, sparse = FALSE)
)
expect_true(
all(diag(
- st_geometry(st_as_sf(edgetouch, "edges")) %>%
+ st_geometry(st_as_sf(edgetouch, "edges")) |>
st_equals(c(l3, l4, l6, l7), sparse = FALSE)
))
)
expect_true(
- st_geometry(st_as_sf(edgewithin, "edges")) %>%
+ st_geometry(st_as_sf(edgewithin, "edges")) |>
st_equals(l5, sparse = FALSE)
)
expect_true(
all(diag(
- st_geometry(st_as_sf(edgewithindist, "edges")) %>%
+ st_geometry(st_as_sf(edgewithindist, "edges")) |>
st_equals(c(l1, l2, l3), sparse = FALSE)
))
)
})
test_that("spatial edge predicates always return the total number of nodes", {
- expect_equal(vcount(edgeint), vcount(net))
- expect_equal(vcount(edgecross), vcount(net))
- expect_equal(vcount(edgecov), vcount(net))
- expect_equal(vcount(edgedisj), vcount(net))
- expect_equal(vcount(edgetouch), vcount(net))
+ expect_equal(n_nodes(edgeint), n_nodes(net))
+ expect_equal(n_nodes(edgecross), n_nodes(net))
+ expect_equal(n_nodes(edgecov), n_nodes(net))
+ expect_equal(n_nodes(edgedisj), n_nodes(net))
+ expect_equal(n_nodes(edgetouch), n_nodes(net))
})
# Node predicates
-net = net %>%
+net = net |>
activate("nodes")
-nodeint = net %>%
+nodeint = net |>
filter(node_intersects(square))
-nodewithin = net %>%
+nodewithin = net |>
filter(node_is_within(square))
-nodecov = net %>%
+nodecov = net |>
filter(node_is_covered_by(square))
-nodedisj = net %>%
+nodedisj = net |>
filter(node_is_disjoint(square))
-nodetouch = net %>%
+nodetouch = net |>
filter(node_touches(square))
-nodewithindist = net %>%
+nodewithindist = net |>
filter(node_is_within_distance(point, 1))
test_that("spatial node predicates return correct nodes and edges", {
expect_true(
all(diag(
- st_geometry(st_as_sf(nodeint, "nodes")) %>%
+ st_geometry(st_as_sf(nodeint, "nodes")) |>
st_equals(st_sfc(p5, p6, p7, p9, p10), sparse = FALSE)
))
)
expect_true(
all(diag(
- st_geometry(st_as_sf(nodeint, "edges")) %>%
+ st_geometry(st_as_sf(nodeint, "edges")) |>
st_equals(c(l3, l5, l7), sparse = FALSE)
))
)
expect_true(
- st_geometry(st_as_sf(nodewithin, "nodes")) %>%
+ st_geometry(st_as_sf(nodewithin, "nodes")) |>
st_equals(p5, sparse = FALSE)
)
expect_true(
all(diag(
- st_geometry(st_as_sf(nodecov, "nodes")) %>%
+ st_geometry(st_as_sf(nodecov, "nodes")) |>
st_equals(st_sfc(p5, p6, p7, p9, p10), sparse = FALSE)
))
)
expect_true(
all(diag(
- st_geometry(st_as_sf(nodecov, "edges")) %>%
+ st_geometry(st_as_sf(nodecov, "edges")) |>
st_equals(c(l3, l5, l7), sparse = FALSE)
))
)
expect_true(
all(diag(
- st_geometry(st_as_sf(nodedisj, "nodes")) %>%
+ st_geometry(st_as_sf(nodedisj, "nodes")) |>
st_equals(st_sfc(p1, p3, p8), sparse = FALSE)
))
)
expect_true(
- st_geometry(st_as_sf(nodedisj, "edges")) %>%
+ st_geometry(st_as_sf(nodedisj, "edges")) |>
st_equals(l1, sparse = FALSE)
)
expect_true(
all(diag(
- st_geometry(st_as_sf(nodetouch, "nodes")) %>%
+ st_geometry(st_as_sf(nodetouch, "nodes")) |>
st_equals(st_sfc(p6, p7, p9, p10), sparse = FALSE)
))
)
expect_true(
all(diag(
- st_geometry(st_as_sf(nodetouch, "edges")) %>%
+ st_geometry(st_as_sf(nodetouch, "edges")) |>
st_equals(c(l3, l5, l7), sparse = FALSE)
))
)
expect_true(
all(diag(
- st_geometry(st_as_sf(nodewithindist, "nodes")) %>%
+ st_geometry(st_as_sf(nodewithindist, "nodes")) |>
st_equals(st_sfc(p3, p7), sparse = FALSE)
))
)
diff --git a/tests/testthat/test_join.R b/tests/testthat/test_join.R
index a31213c6..1d9a1413 100644
--- a/tests/testthat/test_join.R
+++ b/tests/testthat/test_join.R
@@ -1,5 +1,4 @@
library(sf)
-library(igraph)
library(dplyr)
# Create toy network
@@ -29,7 +28,7 @@ test_that("st_join gives a warning when there are multiple node matches", {
ptsdup = rbind(pts, pts)
expect_warning(
st_join(net, ptsdup),
- "Multiple matches were detected from some nodes. "
+ "Multiple matches were detected for some nodes"
)
})
@@ -43,8 +42,8 @@ snapped_rdm = st_sf(foo = letters[1:4], rdm) %>%
test_that("st_join results in the correct attributes and number of nodes and
edges", {
joined = st_join(net, snapped_rdm[1:3, ])
- expect_equal(vcount(joined), vcount(net))
- expect_equal(ecount(joined), ecount(net))
+ expect_equal(n_nodes(joined), n_nodes(net))
+ expect_equal(n_edges(joined), n_edges(net))
expect_setequal(pull(joined, foo), c("c", "a", NA, "b"))
})
@@ -54,8 +53,8 @@ lines = st_sf(bar = letters[1:2], geom = c(e2, e3))
test_that("st_join on the edges results in the correct attributes and number of
nodes and edges", {
joined = st_join(activate(net, "edges"), lines, join = st_equals)
- expect_equal(vcount(joined), vcount(net))
- expect_equal(ecount(joined), ecount(net))
+ expect_equal(n_nodes(joined), n_nodes(net))
+ expect_equal(n_edges(joined), n_edges(net))
expect_setequal(pull(joined, bar), c(NA, "a", "b"))
})
diff --git a/tests/testthat/test_morphers.R b/tests/testthat/test_morphers.R
index ab583308..d3bdf8e4 100644
--- a/tests/testthat/test_morphers.R
+++ b/tests/testthat/test_morphers.R
@@ -29,22 +29,22 @@ l7 = st_sfc(st_linestring(c(p10, p12, p13, p10)))
set.seed(124)
# Points in Roxel bbox
-A = st_sfc(st_point(c(7.5371, 51.9514)), crs = 4326) %>% st_transform(3035)
-B = st_sfc(st_point(c(7.5276, 51.9501)), crs = 4326) %>% st_transform(3035)
+A = st_sfc(st_point(c(7.5371, 51.9514)), crs = 4326) |> st_transform(3035)
+B = st_sfc(st_point(c(7.5276, 51.9501)), crs = 4326) |> st_transform(3035)
rect = st_buffer(A, dist = 300, endCapStyle = "SQUARE")
# Create network from lines
lines = c(l1, l2, l3, l4, l5, l6, l7)
-net_l = as_sfnetwork(lines) %>%
+net_l = as_sfnetwork(lines) |>
mutate(rdm = sample(1:3, 8, replace = T))
-net_i = as_sfnetwork(lines, edges_as_lines = F)
+net_i = as_sfnetwork(lines) |> make_edges_implicit()
# Create directed Roxel network
-net_d = as_sfnetwork(roxel) %>%
+net_d = as_sfnetwork(roxel) |>
st_transform(3035)
# Create undirected Roxel network
-net_u = as_sfnetwork(roxel, directed = FALSE) %>%
+net_u = as_sfnetwork(roxel, directed = FALSE) |>
st_transform(3035)
# Perform spatial contraction
@@ -66,25 +66,22 @@ sube_d = convert(net_d, to_spatial_subset, rect, subset_by = "edges")
# Extract spatial neighborhood
neigf_d = convert(net_d, to_spatial_neighborhood, A,
- from = TRUE, threshold = 500)
+ threshold = 500)
neigt_d = convert(net_d, to_spatial_neighborhood, A,
- from = FALSE, threshold = 500)
+ threshold = 500, direction = "in")
neigf_u = convert(net_u, to_spatial_neighborhood, A,
- from = TRUE, threshold = 500)
+ threshold = 500)
neigt_u = convert(net_u, to_spatial_neighborhood, A,
- from = FALSE, threshold = 500)
+ threshold = 500, direction = "in")
# Extract shortest path
shpt_d = convert(net_d, to_spatial_shortest_paths, B, A)
shpt_u = convert(net_u, to_spatial_shortest_paths, B, A)
# Perform network subdivision
-## Warnings are suppressed on purpose
-suppressWarnings({
- subd_l <- convert(net_l, to_spatial_subdivision)
- subd_d <- convert(net_d, to_spatial_subdivision)
- subd_u <- convert(net_u, to_spatial_subdivision)
-})
+subd_l = convert(net_l, to_spatial_subdivision)
+subd_d = convert(net_d, to_spatial_subdivision)
+subd_u = convert(net_u, to_spatial_subdivision)
# Perform spatial smoothing of pseudo nodes
smoo_l = convert(net_l, to_spatial_smooth)
@@ -98,15 +95,15 @@ simp_u = convert(net_u, to_spatial_simple)
test_that("the created toy network from lines for morpher testing has
the expected number of nodes, edges and components", {
- expect_equal(vcount(net_l), 8)
- expect_equal(ecount(net_l), 7)
+ expect_equal(n_nodes(net_l), 8)
+ expect_equal(n_edges(net_l), 7)
expect_equal(count_components(net_l), 3)
- expect_equal(vcount(net_d), 701)
- expect_equal(ecount(net_d), 851)
- expect_equal(count_components(net_d), 14)
- expect_equal(vcount(net_u), 701)
- expect_equal(ecount(net_u), 851)
- expect_equal(count_components(net_u), 14)
+ expect_equal(n_nodes(net_d), 987)
+ expect_equal(n_edges(net_d), 1215)
+ expect_equal(count_components(net_d), 9)
+ expect_equal(n_nodes(net_u), 987)
+ expect_equal(n_edges(net_u), 1215)
+ expect_equal(count_components(net_u), 9)
})
test_that("to_spatial_directed morphs an undirected sfnetwork into directed
@@ -133,123 +130,123 @@ test_that("to_spatial_explicit morphs an sfnetwork with spatially implicit
test_that("to_spatial_contracted morphs the sfnetwork into a new network
with the expected number of nodes, edges and components", {
- expect_equal(vcount(cont_l), 3)
- expect_equal(ecount(cont_l), 7)
+ expect_equal(n_nodes(cont_l), 3)
+ expect_equal(n_edges(cont_l), 5)
expect_equal(count_components(cont_l), 1)
})
test_that("to_spatial_subset morphs the sfnetwork into a new network
with the expected number of nodes, edges and components", {
- expect_equal(vcount(subn_d), 151)
- expect_equal(ecount(subn_d), 163)
- expect_equal(count_components(subn_d), 10)
- expect_equal(vcount(sube_d), 701)
- expect_equal(ecount(sube_d), 195)
- expect_equal(count_components(sube_d), 529)
+ expect_equal(n_nodes(subn_d), 168)
+ expect_equal(n_edges(subn_d), 183)
+ expect_equal(count_components(subn_d), 4)
+ expect_equal(n_nodes(sube_d), 987)
+ expect_equal(n_edges(sube_d), 215)
+ expect_equal(count_components(sube_d), 792)
})
test_that("to_spatial_neighborhood morphs the sfnetwork into a new network
with the expected number of nodes, edges and components", {
- expect_equal(vcount(neigf_d), 45)
- expect_equal(ecount(neigf_d), 48)
+ expect_equal(n_nodes(neigf_d), 56)
+ expect_equal(n_edges(neigf_d), 61)
expect_equal(count_components(neigf_d), 1)
- expect_equal(vcount(neigt_d), 76)
- expect_equal(ecount(neigt_d), 81)
+ expect_equal(n_nodes(neigt_d), 95)
+ expect_equal(n_edges(neigt_d), 101)
expect_equal(count_components(neigt_d), 1)
- expect_equal(vcount(neigf_u), 179)
- expect_equal(ecount(neigf_u), 205)
+ expect_equal(n_nodes(neigf_u), 238)
+ expect_equal(n_edges(neigf_u), 271)
expect_equal(count_components(neigf_u), 1)
- expect_equal(vcount(neigt_u), 179)
- expect_equal(ecount(neigt_u), 205)
+ expect_equal(n_nodes(neigt_u), 238)
+ expect_equal(n_edges(neigt_u), 271)
expect_equal(count_components(neigt_u), 1)
})
test_that("to_spatial_shortest_paths morphs the sfnetwork into a new network
with the expected number of nodes, edges and components", {
- expect_equal(vcount(shpt_d), 22)
- expect_equal(ecount(shpt_d), 21)
+ expect_equal(n_nodes(shpt_d), 25)
+ expect_equal(n_edges(shpt_d), 24)
expect_equal(count_components(shpt_d), 1)
- expect_equal(vcount(shpt_u), 17)
- expect_equal(ecount(shpt_u), 16)
+ expect_equal(n_nodes(shpt_u), 28)
+ expect_equal(n_edges(shpt_u), 27)
expect_equal(count_components(shpt_u), 1)
})
test_that("to_spatial_subdivision morphs the sfnetwork into a new network
with the expected number of nodes, edges and components", {
- expect_equal(vcount(subd_l), 9)
- expect_equal(ecount(subd_l), 10)
+ expect_equal(n_nodes(subd_l), 9)
+ expect_equal(n_edges(subd_l), 10)
expect_equal(count_components(subd_l), 1)
- expect_equal(vcount(subd_d), 701)
- expect_equal(ecount(subd_d), 876)
- expect_equal(count_components(subd_d), 1)
- expect_equal(vcount(subd_u), 701)
- expect_equal(ecount(subd_u), 876)
- expect_equal(count_components(subd_u), 1)
+ expect_equal(n_nodes(subd_d), 987)
+ expect_equal(n_edges(subd_d), 1215)
+ expect_equal(count_components(subd_d), 9)
+ expect_equal(n_nodes(subd_u), 987)
+ expect_equal(n_edges(subd_u), 1215)
+ expect_equal(count_components(subd_u), 9)
})
test_that("to_spatial_smooth morphs the sfnetwork into a new network
with the expected number of nodes, edges and components", {
- expect_equal(vcount(smoo_l), 7)
- expect_equal(ecount(smoo_l), 6)
+ expect_equal(n_nodes(smoo_l), 7)
+ expect_equal(n_edges(smoo_l), 6)
expect_equal(count_components(smoo_l), 3)
- expect_equal(vcount(smoo_d), 652)
- expect_equal(ecount(smoo_d), 802)
- expect_equal(count_components(smoo_d), 14)
- expect_equal(vcount(smoo_u), 634)
- expect_equal(ecount(smoo_u), 784)
- expect_equal(count_components(smoo_u), 14)
+ expect_equal(n_nodes(smoo_d), 987)
+ expect_equal(n_edges(smoo_d), 1215)
+ expect_equal(count_components(smoo_d), 9)
+ expect_equal(n_nodes(smoo_u), 965)
+ expect_equal(n_edges(smoo_u), 1193)
+ expect_equal(count_components(smoo_u), 9)
})
test_that("to_spatial_simple morphs the sfnetwork into a new network
with the expected number of nodes, edges and components", {
- expect_equal(vcount(simp_l), 8)
- expect_equal(ecount(simp_l), 5)
+ expect_equal(n_nodes(simp_l), 8)
+ expect_equal(n_edges(simp_l), 5)
expect_equal(count_components(simp_l), 3)
- expect_equal(vcount(simp_d), 701)
- expect_equal(ecount(simp_d), 848)
- expect_equal(count_components(simp_d), 14)
- expect_equal(vcount(simp_u), 701)
- expect_equal(ecount(simp_u), 844)
- expect_equal(count_components(simp_u), 14)
+ expect_equal(n_nodes(simp_d), 987)
+ expect_equal(n_edges(simp_d), 1211)
+ expect_equal(count_components(simp_d), 9)
+ expect_equal(n_nodes(simp_u), 987)
+ expect_equal(n_edges(simp_u), 1207)
+ expect_equal(count_components(simp_u), 9)
})
test_that("morphers return same network when there is no morphing
necessary", {
- expect_equal(vcount(smoo_d), vcount(convert(smoo_d, to_spatial_smooth)))
- expect_equal(ecount(smoo_d), ecount(convert(smoo_d, to_spatial_smooth)))
- expect_equal(vcount(smoo_l), vcount(convert(smoo_l, to_spatial_smooth)))
- expect_equal(ecount(smoo_l), ecount(convert(smoo_l, to_spatial_smooth)))
- expect_equal(vcount(smoo_u), vcount(convert(smoo_u, to_spatial_smooth)))
- expect_equal(ecount(smoo_u), ecount(convert(smoo_u, to_spatial_smooth)))
- expect_equal(vcount(simp_d), vcount(convert(simp_d, to_spatial_simple)))
- expect_equal(ecount(simp_d), ecount(convert(simp_d, to_spatial_simple)))
- expect_equal(vcount(simp_l), vcount(convert(simp_l, to_spatial_simple)))
- expect_equal(ecount(simp_l), ecount(convert(simp_l, to_spatial_simple)))
- expect_equal(vcount(simp_u), vcount(convert(simp_u, to_spatial_simple)))
- expect_equal(ecount(simp_u), ecount(convert(simp_u, to_spatial_simple)))
+ expect_equal(n_nodes(smoo_d), n_nodes(convert(smoo_d, to_spatial_smooth)))
+ expect_equal(n_edges(smoo_d), n_edges(convert(smoo_d, to_spatial_smooth)))
+ expect_equal(n_nodes(smoo_l), n_nodes(convert(smoo_l, to_spatial_smooth)))
+ expect_equal(n_edges(smoo_l), n_edges(convert(smoo_l, to_spatial_smooth)))
+ expect_equal(n_nodes(smoo_u), n_nodes(convert(smoo_u, to_spatial_smooth)))
+ expect_equal(n_edges(smoo_u), n_edges(convert(smoo_u, to_spatial_smooth)))
+ expect_equal(n_nodes(simp_d), n_nodes(convert(simp_d, to_spatial_simple)))
+ expect_equal(n_edges(simp_d), n_edges(convert(simp_d, to_spatial_simple)))
+ expect_equal(n_nodes(simp_l), n_nodes(convert(simp_l, to_spatial_simple)))
+ expect_equal(n_edges(simp_l), n_edges(convert(simp_l, to_spatial_simple)))
+ expect_equal(n_nodes(simp_u), n_nodes(convert(simp_u, to_spatial_simple)))
+ expect_equal(n_edges(simp_u), n_edges(convert(simp_u, to_spatial_simple)))
expect_equal(
- vcount(subd_d),
- suppressWarnings(vcount(convert(subd_d, to_spatial_subdivision)))
+ n_nodes(subd_d),
+ suppressWarnings(n_nodes(convert(subd_d, to_spatial_subdivision)))
)
expect_equal(
- ecount(subd_d),
- suppressWarnings(ecount(convert(subd_d, to_spatial_subdivision)))
+ n_edges(subd_d),
+ suppressWarnings(n_edges(convert(subd_d, to_spatial_subdivision)))
)
expect_equal(
- vcount(subd_l),
- suppressWarnings(vcount(convert(subd_l, to_spatial_subdivision)))
+ n_nodes(subd_l),
+ suppressWarnings(n_nodes(convert(subd_l, to_spatial_subdivision)))
)
expect_equal(
- ecount(subd_l),
- suppressWarnings(ecount(convert(subd_l, to_spatial_subdivision)))
+ n_edges(subd_l),
+ suppressWarnings(n_edges(convert(subd_l, to_spatial_subdivision)))
)
expect_equal(
- vcount(subd_u),
- suppressWarnings(vcount(convert(subd_u, to_spatial_subdivision)))
+ n_nodes(subd_u),
+ suppressWarnings(n_nodes(convert(subd_u, to_spatial_subdivision)))
)
expect_equal(
- ecount(subd_u),
- suppressWarnings(ecount(convert(subd_u, to_spatial_subdivision)))
+ n_edges(subd_u),
+ suppressWarnings(n_edges(convert(subd_u, to_spatial_subdivision)))
)
expect_equal(
is_directed(net_d),
@@ -261,26 +258,27 @@ test_that("morphers return same network when there is no morphing
)
})
+mess = "Spatial network structure is valid"
test_that("morphers return a valid sfnetwork", {
- expect_null(sfnetworks:::require_valid_network_structure(dire_u))
- expect_null(sfnetworks:::require_valid_network_structure(expl_i))
- expect_null(sfnetworks:::require_valid_network_structure(tran_u))
- expect_null(sfnetworks:::require_valid_network_structure(cont_l))
- expect_null(sfnetworks:::require_valid_network_structure(subn_d))
- expect_null(sfnetworks:::require_valid_network_structure(sube_d))
- expect_null(sfnetworks:::require_valid_network_structure(neigf_d))
- expect_null(sfnetworks:::require_valid_network_structure(neigt_d))
- expect_null(sfnetworks:::require_valid_network_structure(neigf_u))
- expect_null(sfnetworks:::require_valid_network_structure(neigt_u))
- expect_null(sfnetworks:::require_valid_network_structure(shpt_d))
- expect_null(sfnetworks:::require_valid_network_structure(shpt_u))
- expect_null(sfnetworks:::require_valid_network_structure(subd_l))
- expect_null(sfnetworks:::require_valid_network_structure(subd_d))
- expect_null(sfnetworks:::require_valid_network_structure(subd_u))
- expect_null(sfnetworks:::require_valid_network_structure(smoo_l))
- expect_null(sfnetworks:::require_valid_network_structure(smoo_d))
- expect_null(sfnetworks:::require_valid_network_structure(smoo_u))
- expect_null(sfnetworks:::require_valid_network_structure(simp_l))
- expect_null(sfnetworks:::require_valid_network_structure(simp_d))
- expect_null(sfnetworks:::require_valid_network_structure(simp_u))
+ expect_message(validate_network(dire_u), mess)
+ expect_message(validate_network(expl_i), mess)
+ expect_message(validate_network(tran_u), mess)
+ expect_message(validate_network(cont_l), mess)
+ expect_message(validate_network(subn_d), mess)
+ expect_message(validate_network(sube_d), mess)
+ expect_message(validate_network(neigf_d), mess)
+ expect_message(validate_network(neigt_d), mess)
+ expect_message(validate_network(neigf_u), mess)
+ expect_message(validate_network(neigt_u), mess)
+ expect_message(validate_network(shpt_d), mess)
+ expect_message(validate_network(shpt_u), mess)
+ expect_message(validate_network(subd_l), mess)
+ expect_message(validate_network(subd_d), mess)
+ expect_message(validate_network(subd_u), mess)
+ expect_message(validate_network(smoo_l), mess)
+ expect_message(validate_network(smoo_d), mess)
+ expect_message(validate_network(smoo_u), mess)
+ expect_message(validate_network(simp_l), mess)
+ expect_message(validate_network(simp_d), mess)
+ expect_message(validate_network(simp_u), mess)
})
diff --git a/tests/testthat/test_paths.R b/tests/testthat/test_paths.R
index 913bcf4c..0a7d55bb 100644
--- a/tests/testthat/test_paths.R
+++ b/tests/testthat/test_paths.R
@@ -4,27 +4,28 @@ library(igraph)
library(tidygraph)
# Call some data for testing
-net = as_sfnetwork(roxel, directed = FALSE) %>%
+net = as_sfnetwork(roxel, directed = FALSE) |>
st_transform(3035)
-net_dir = as_sfnetwork(roxel, directed = TRUE) %>%
+net_dir = as_sfnetwork(roxel, directed = TRUE) |>
st_transform(3035)
-sub1 = net %>%
- convert(to_spatial_neighborhood, 15, 150) %>%
- activate("edges") %>%
+sub1 = net |>
+ convert(to_spatial_neighborhood, 15, 100) |>
+ activate("edges") |>
st_set_geometry(NULL)
-sub2 = net %>%
+sub2 = net |>
slice(1:5)
# Create random points inside network bbox
-rdm = net %>%
- st_bbox() %>%
- st_as_sfc() %>%
+rdm = net |>
+ st_bbox() |>
+ st_as_sfc() |>
st_sample(4, type = "random")
# Calculate some cost matrices and set non outputs
-sub1_c1 = c(0, 2, 3, 1, 2, 2, 0, 1, 1, 2, 3, 1, 0, 2, 3, 1, 1, 2, 0, 1,
- 2, 2, 3, 1, 0)
+sub1_c1 = c(0, 1, 1, 2, 3, 2, 1, 1, 0, 2, 3, 4, 3, 2, 1, 2, 0, 3, 4, 3, 2,
+ 2, 3, 3, 0, 1, 2, 1, 3, 4, 4, 1, 0, 3, 2, 2, 3, 3, 2, 3, 0, 1,
+ 1, 2, 2, 1, 2, 1, 0)
sub2_c1 = c(0, 1, Inf, Inf, Inf, 1, 0, Inf, Inf, Inf, Inf, Inf, 0, 1, Inf,
Inf, Inf, 1, 0, Inf, Inf, Inf, Inf, Inf, 0)
sub2_c2 = c(0, 1, NaN, NaN, NaN, 1, 0, NaN, NaN, NaN, NaN, NaN, 0, 1, NaN,
@@ -42,10 +43,10 @@ test_that("Only the first from argument
net,
from = from_indices,
to = rdm,
- type = "all_shortest"), "only the first element is used")
- resulting_from_nodes = paths %>%
- rowwise() %>%
- mutate(node_from = first(node_paths)) %>%
+ all = TRUE), "Only the first `from` node is considered")
+ resulting_from_nodes = paths |>
+ rowwise() |>
+ mutate(node_from = first(node_path)) |>
pull(node_from)
expect_setequal(resulting_from_nodes, first(from_indices))
})
@@ -55,44 +56,44 @@ test_that("NA indices for in from and/or to arguments give an error", {
net,
from = as.numeric(NA),
to = c(3, 28, 98)
- ), "NA values present")
+ ), "should not contain NA values")
expect_error(st_network_cost(
net,
from = 2,
to = as.numeric(c(NA, 3, NA))
- ), "NA values present")
+ ), "should not contain NA values")
expect_error(st_network_paths(
net,
from = rdm[1],
to = c(rdm, st_sfc(st_point(), crs = 3035))
- ), "NA values present")
+ ), "should not contain NA values")
})
test_that("st_network_paths weights argument is passed implicitly,
explicitly and automatically", {
# Set weights to a named column
expect_silent(
- nodepaths_exp <- net %>%
- activate("edges") %>%
- mutate(length = edge_length()) %>%
- st_network_paths(8, 3, weights = "length") %>%
- pull(node_paths) %>%
+ nodepaths_exp <- net |>
+ activate("edges") |>
+ mutate(length = edge_length()) |>
+ st_network_paths(8, 3, weights = length) |>
+ pull(node_path) |>
unlist()
)
# Set weights to a column called weight
expect_silent(
- nodepaths_imp <- net %>%
- activate("edges") %>%
- mutate(weight = edge_length()) %>%
- st_network_paths(8, 3) %>%
- pull(node_paths) %>%
+ nodepaths_imp <- net |>
+ activate("edges") |>
+ mutate(weight = edge_length()) |>
+ st_network_paths(8, 3) |>
+ pull(node_path) |>
unlist()
)
# Do not set weight but expect it is computed internally
expect_silent(
- nodepaths_aut <- net %>%
- st_network_paths(8, 3) %>%
- pull(node_paths) %>%
+ nodepaths_aut <- net |>
+ st_network_paths(8, 3) |>
+ pull(node_path) |>
unlist()
)
expect_setequal(
@@ -109,44 +110,45 @@ test_that("st_network_paths weights argument is passed implicitly,
test_that("Unexisting 'weights' column passed to st_network_paths
gives an error", {
expect_error(
- st_network_paths(net, 1, 12, weights = "invented_column"),
+ st_network_paths(net, 1, 12, weights = invented_column),
"not found"
)
})
-test_that("node_paths without set weight is equal or shorter than
- node_paths with set weight", {
- nodepaths_weight <- net %>%
- activate("edges") %>%
- mutate(weight = edge_length()) %>%
- st_network_paths(8, 3) %>%
- pull(node_paths) %>%
+test_that("node_path without set weight is equal or shorter than
+ node_path with set weight", {
+ nodepaths_weight <- net |>
+ activate("edges") |>
+ mutate(weight = edge_length()) |>
+ st_network_paths(8, 3) |>
+ pull(node_path) |>
unlist()
- nodepaths_noweight <- net %>%
- st_network_paths(8, 3, weights = NA) %>%
- pull(node_paths) %>%
+ nodepaths_noweight <- net |>
+ st_network_paths(8, 3, weights = NA) |>
+ pull(node_path) |>
unlist()
expect_true(length(nodepaths_noweight) <= length(nodepaths_weight))
})
-test_that("All simple paths wrapper gives a known number of paths", {
- expect_equal(
- net %>%
- convert(to_spatial_directed) %>%
- st_network_paths(1, 12, type = "all_simple") %>%
- nrow(),
- 6
- )
-})
+## Unsupported, change to k shortest paths? #FIXME
+# test_that("All simple paths wrapper gives a known number of paths", {
+# expect_equal(
+# net |>
+# convert(to_spatial_directed) |>
+# st_network_paths(1, 12, type = "all_simple") |>
+# nrow(),
+# 6
+# )
+# })
# Tests for st_networks_cost()
test_that("st_network_cost outputs matrix with known values", {
expect_setequal(as.vector(cost1.1), sub1_c1)
expect_setequal(as.vector(cost2.1), sub2_c1)
expect_setequal(as.vector(cost2.2), sub2_c2)
- expect_equal(sum(cost1.1, na.rm = T), 36)
+ expect_equal(sum(cost1.1, na.rm = T), 92)
expect_equal(sum(cost2.1, na.rm = T), Inf)
expect_equal(sum(cost2.2, na.rm = T), 4)
})
@@ -179,14 +181,14 @@ test_that("st_network_cost calculates distance matrix
test_that("st_network_cost passes the direction argument to the
mode argument in igraph::distances for directed networks
and ignores it for undirected", {
- dist_out = st_network_cost(net_dir, from = 1, to = 10, direction = "out")
- dist_out2 = st_network_cost(net_dir, from = 1, to = 10)
- dist_in = st_network_cost(net_dir, from = 1, to = 10, direction = "in")
- dist_all = st_network_cost(net_dir, from = 1, to = 10, direction = "all")
- dist_all_undir1 = st_network_cost(net, from = 1, to = 10)
- dist_all_undir2 = st_network_cost(net, from = 1, to = 10, direction = "out")
- dist_all_undir3 = st_network_cost(net, from = 1, to = 10, direction = "in")
- dist_all_undir4 = st_network_cost(net, from = 1, to = 10, direction = "all")
+ dist_out = st_network_cost(net_dir, from = 1, to = 6, direction = "out")
+ dist_out2 = st_network_cost(net_dir, from = 1, to = 6)
+ dist_in = st_network_cost(net_dir, from = 1, to = 6, direction = "in")
+ dist_all = st_network_cost(net_dir, from = 1, to = 6, direction = "all")
+ dist_all_undir1 = st_network_cost(net, from = 1, to = 6)
+ dist_all_undir2 = st_network_cost(net, from = 1, to = 6, direction = "out")
+ dist_all_undir3 = st_network_cost(net, from = 1, to = 6, direction = "in")
+ dist_all_undir4 = st_network_cost(net, from = 1, to = 6, direction = "all")
expect_false(isTRUE(all.equal(dist_out, dist_in)))
expect_false(isTRUE(all.equal(dist_out, dist_all)))
expect_false(isTRUE(all.equal(dist_in, dist_all)))
@@ -200,8 +202,8 @@ test_that("st_network_cost passes the direction argument to the
test_that("st_network_cost handles Inf_as_Nan correctly", {
costmat1 = st_network_cost(net, Inf_as_NaN = FALSE)
costmat2 = st_network_cost(net, Inf_as_NaN = TRUE)
- costmat3 = st_network_cost(net, 1, c(377,378,377), Inf_as_NaN = FALSE)
- costmat4 = st_network_cost(net, 1, c(377,378,377), Inf_as_NaN = TRUE)
+ costmat3 = st_network_cost(net, 1, c(412,378,412), Inf_as_NaN = FALSE)
+ costmat4 = st_network_cost(net, 1, c(412,378,412), Inf_as_NaN = TRUE)
# Test that matrix contains Inf values instead of NaN
expect_gt(length(costmat1[is.infinite(costmat1)]), 0)
expect_equal(length(costmat1[is.nan(costmat1)]), 0)
@@ -222,22 +224,22 @@ test_that("st_network_cost handles Inf_as_Nan correctly", {
length(costmat4[is.nan(costmat4)])
)
})
-
-test_that("... ignores mode argument with a warning", {
- expect_warning(
- st_network_cost(net, from = 1, to = 10, mode = "in"),
- "Argument 'mode' is ignored"
- )
- expect_warning(
- st_network_cost(net_dir, from = 1, to = 10, mode = "out"),
- "Argument 'mode' is ignored"
- )
-})
+# mode argument not supported instead of ignores #FIXME
+# test_that("... ignores mode argument with a warning", {
+# expect_warning(
+# st_network_cost(net, from = 1, to = 10, mode = "in"),
+# "Argument 'mode' is ignored"
+# )
+# expect_warning(
+# st_network_cost(net_dir, from = 1, to = 10, mode = "out"),
+# "Argument 'mode' is ignored"
+# )
+# })
test_that("... is passed correcly onto igraph::distances", {
- expect_silent(cost_dijkstra <- st_network_cost(net_dir, from = 1, to = 10,
+ expect_silent(cost_dijkstra <- st_network_cost(net_dir, from = 1, to = 6,
direction = "out", algorithm = "dijkstra"))
- expect_warning(cost_unweighted <- st_network_cost(net_dir, from = 1, to = 10,
+ expect_warning(cost_unweighted <- st_network_cost(net_dir, from = 1, to = 6,
direction = "out", algorithm = "unweighted"))
expect_false(isTRUE(all.equal(cost_dijkstra, cost_unweighted)))
})
diff --git a/tests/testthat/test_plot.R b/tests/testthat/test_plot.R
index e12e64b4..0d3875f5 100644
--- a/tests/testthat/test_plot.R
+++ b/tests/testthat/test_plot.R
@@ -1,5 +1,5 @@
net_exp = as_sfnetwork(roxel)
-net_imp = as_sfnetwork(roxel, edges_as_lines = FALSE)
+net_imp = as_sfnetwork(roxel) |> make_edges_implicit()
test_that("plot accepts sfnetworks with spatially implicit edges", {
pdf(NULL)
@@ -13,10 +13,4 @@ test_that("autplot returns a ggplot with two layers", {
expect_s3_class(g, "ggplot")
expect_equal(length(g$layers), 2)
})
-test_that("autoplot shows a message when implicit edges are passed", {
- skip_if_not_installed("ggplot2", "3.0.0")
- expect_message(
- ggplot2::autoplot(net_imp),
- "Spatially implicit edges are drawn as lines"
- )
-})
+
diff --git a/tests/testthat/test_sf.R b/tests/testthat/test_sf.R
index 0e2c7dfb..8a045870 100644
--- a/tests/testthat/test_sf.R
+++ b/tests/testthat/test_sf.R
@@ -1,18 +1,20 @@
library(sf)
library(dplyr)
-rect = roxel %>%
- st_union() %>%
- st_transform(3857) %>%
- st_centroid() %>%
- st_buffer(dist = 500, endCapStyle = "SQUARE") %>%
- st_transform(4326) %>%
+rect = roxel |>
+ st_union() |>
+ st_transform(3857) |>
+ st_centroid() |>
+ st_buffer(dist = 500, endCapStyle = "SQUARE") |>
+ st_transform(4326) |>
st_as_sf(foo = "bar")
test_that("sf functions for sfnetworks with spatially implicit edges,
give an error", {
message = "This call requires spatially explicit edges"
- net = as_sfnetwork(roxel, edges_as_lines = F) %>% activate("edges")
+ net = as_sfnetwork(roxel) |>
+ make_edges_implicit() |>
+ activate("edges")
# Geometries
expect_error(st_coordinates(net), message)
expect_error(st_is(net, "LINESTRING"), message)
@@ -38,13 +40,13 @@ test_that("sf functions for sfnetworks with spatially implicit edges,
### crs
test_that("st_set_crs sets the crs for edges and nodes", {
- net = as_sfnetwork(roxel) %>%
+ net = as_sfnetwork(roxel) |>
st_set_crs(NA)
expect_equal(st_crs(activate(net, "nodes")), st_crs(activate(net, "edges")))
})
test_that("st_transform changes crs for edges and nodes", {
- net = as_sfnetwork(roxel) %>%
+ net = as_sfnetwork(roxel) |>
st_transform(3857)
expect_equal(st_crs(activate(net, "nodes")), st_crs(activate(net, "edges")))
})
@@ -52,7 +54,7 @@ test_that("st_transform changes crs for edges and nodes", {
### precision
test_that("st_set_precision sets the precision for edges and nodes", {
- net = as_sfnetwork(roxel) %>%
+ net = as_sfnetwork(roxel) |>
st_set_precision(1)
expect_equal(
st_precision(activate(net, "nodes")),
@@ -67,7 +69,7 @@ test_that("st_crop gives a warning and returns a valid network", {
crop <- st_crop(net, rect),
"assumed to be spatially constant"
)
- expect_null(sfnetworks:::require_valid_network_structure(crop))
+ expect_null(validate_network(crop, message = FALSE))
})
test_that("st_intersection gives a warning and returns a valid network", {
@@ -76,7 +78,7 @@ test_that("st_intersection gives a warning and returns a valid network", {
intersection <- st_intersection(net, rect),
"assumed to be spatially constant"
)
- expect_null(sfnetworks:::require_valid_network_structure(intersection))
+ expect_null(validate_network(intersection, message = FALSE))
})
test_that("st_difference gives a warning and returns a valid network", {
@@ -85,7 +87,7 @@ test_that("st_difference gives a warning and returns a valid network", {
difference <- st_difference(net, rect),
"assumed to be spatially constant"
)
- expect_null(sfnetworks:::require_valid_network_structure(difference))
+ expect_null(validate_network(difference, message = FALSE))
})
### st_reverse
@@ -103,8 +105,8 @@ test_that("st_reverse returns valid networks", {
skip_if_not(current_geos >= required_geos)
reversed_D <- suppressWarnings(st_reverse(activate(dirnet, "edges")))
reversed_U <- st_reverse(activate(undirnet, "edges"))
- expect_null(sfnetworks:::require_valid_network_structure(reversed_D))
- expect_null(sfnetworks:::require_valid_network_structure(reversed_U))
+ expect_null(validate_network(reversed_D, message = FALSE))
+ expect_null(validate_network(reversed_U, message = FALSE))
})
test_that("st_reverse gives a warning when nodes are active, keeping the same
@@ -129,9 +131,8 @@ test_that("st_reverse gives a warning when nodes are active, keeping the same
test_that("st_reverse reverses the order of the to/from columns and the
order of the coordinates for directed networks", {
skip_if_not(current_geos >= required_geos)
- expect_warning(
- reversed <- st_reverse(activate(dirnet, "edges")),
- "st_reverse swaps columns"
+ expect_silent(
+ reversed <- st_reverse(activate(dirnet, "edges"))
)
expect_equal(
st_coordinates(reversed)[1, ],
@@ -178,37 +179,38 @@ test_that("st_reverse reverses the order of the coordinates for
test_that("dropping geometry for activated nodes changes the class to
tbl_graph", {
- net = roxel %>% as_sfnetwork()
- expect_s3_class(net %>% sf::st_drop_geometry(), "tbl_graph")
- expect_s3_class(net %>% sf::st_set_geometry(NULL), "tbl_graph")
+ net = roxel |> as_sfnetwork()
+ expect_s3_class(net |> sf::st_drop_geometry(), "tbl_graph")
+ expect_s3_class(net |> sf::st_set_geometry(NULL), "tbl_graph")
})
test_that("dropping geometry for activated edges remains an sfnetwork", {
- net = roxel %>% as_sfnetwork() %>% activate("edges")
- expect_s3_class(net %>% sf::st_drop_geometry(), "sfnetwork")
- expect_s3_class(net %>% sf::st_set_geometry(NULL), "sfnetwork")
+ net = roxel |> as_sfnetwork() |> activate("edges")
+ expect_s3_class(net |> sf::st_drop_geometry(), "sfnetwork")
+ expect_s3_class(net |> sf::st_set_geometry(NULL), "sfnetwork")
})
test_that("st_set_geometry gives an error when replacing edges geometry
type", {
- net = roxel %>% as_sfnetwork()
+ net = roxel |> as_sfnetwork()
# warnings are suppressed since they relate to the sf package
# warning: st_centroid assumes attributes are constant over geometries of x
centroids = suppressWarnings(sf::st_centroid(roxel))
new_geom = st_geometry(centroids)
- expect_error(activate(net, "edges") %>% sf::st_set_geometry(new_geom))
+ expect_error(activate(net, "edges") |> sf::st_set_geometry(new_geom))
})
test_that("st_set_geometry gives an error when replacing edges geometry CRS", {
- net = roxel %>% as_sfnetwork()
+ net = roxel |> as_sfnetwork()
new_geom = st_geometry(st_transform(roxel, 3035))
- expect_error(activate(net, "edges") %>% sf::st_set_geometry(new_geom))
-})
-
-test_that("st_set_geometry gives an error when replacing edges geometry
- endpoints", {
- skip_if_not(current_geos >= required_geos)
- net = roxel %>% as_sfnetwork()
- new_geom = sf::st_geometry(sf::st_reverse(roxel))
- expect_error(activate(net, "edges") %>% sf::st_set_geometry(new_geom))
-})
+ expect_error(activate(net, "edges") |> sf::st_set_geometry(new_geom))
+})
+# st_set_geometry does not give an error anymore but update the edges,
+# create new test #FIXME
+# test_that("st_set_geometry gives an error when replacing edges geometry
+# endpoints", {
+# skip_if_not(current_geos >= required_geos)
+# net = roxel |> as_sfnetwork()
+# new_geom = sf::st_geometry(sf::st_reverse(roxel))
+# expect_error(activate(net, "edges") |> sf::st_set_geometry(new_geom))
+# })
diff --git a/tests/testthat/test_sfnetworks.R b/tests/testthat/test_sfnetworks.R
index 7457733c..2415711e 100644
--- a/tests/testthat/test_sfnetworks.R
+++ b/tests/testthat/test_sfnetworks.R
@@ -7,25 +7,24 @@ test_that("sfnetwork created from Roxel example dataset has
the expected number of nodes, edges and components
for a directed network construction", {
net = as_sfnetwork(roxel)
- expect_equal(vcount(net), 701)
- expect_equal(ecount(net), 851)
- expect_equal(count_components(net), 14)
-})
-
-test_that("sfnetwork created with spatially implicit edges has no geometry
- column for the edges", {
- net = as_sfnetwork(roxel, edges_as_lines = F)
- expect_null(sf_attr(net, "sf_column", "edges"))
+ expect_equal(n_nodes(net), 987)
+ expect_equal(n_edges(net), 1215)
+ expect_equal(count_components(net), 9)
})
+# irrelevant test since a network created from lines
+# will always have explicit edges #FIXME
+# test_that("sfnetwork created with spatially implicit edges has no geometry
+# column for the edges", {
+# net = as_sfnetwork(roxel, edges_as_lines = F)
+# expect_null(sf_attr(net, "sf_column", "edges"))
+# })
test_that("sfnetwork created from POLYGON-geometry sf gives and error", {
- suppressWarnings({
- rdm = st_sample(st_as_sfc(st_bbox(roxel)), 4, type = "random")
- rdmpoly = st_buffer(rdm, 0.5)
- })
+ rdm = st_sample(st_as_sfc(st_bbox(roxel)), 4, type = "random")
+ rdmpoly = st_buffer(rdm, 0.5)
expect_error(
as_sfnetwork(rdmpoly),
- "Geometries are not all of type LINESTRING, or all of type POINT"
+ "Unsupported geometry types"
)
})
@@ -34,8 +33,8 @@ test_that("Creating an sfnetwork from an sf LINESTRING object with
column_from = rep(letters[1:3], 2)
column_to = c("c", "a", "b", "b", "c", "c")
expect_warning(
- net <- roxel[25:30, ] %>%
- mutate(from = column_from, to = column_to) %>%
+ net <- roxel[25:30, ] |>
+ mutate(from = column_from, to = column_to) |>
as_sfnetwork(),
"Overwriting column"
)
@@ -43,17 +42,18 @@ test_that("Creating an sfnetwork from an sf LINESTRING object with
expect_false(all(pull(activate(net, "edges"), "to") == column_to))
})
-test_that("Creating an sfnetwork from an sf LINESTRING object with
- weight columns, when length_as_weigtht, overwrites it
- with a warning.", {
- set.seed(213)
- column_weight = runif(6)
- expect_warning(
- net <- roxel[25:30, ] %>%
- mutate(weight = column_weight) %>%
- as_sfnetwork(length_as_weight = T),
- "Overwriting column"
- )
- expect_false(all(as.numeric(pull(activate(net, "edges"), "weight"))
- == column_weight))
-})
+# irrelvant test since length_as_weight is deprecated #FIXME
+# test_that("Creating an sfnetwork from an sf LINESTRING object with
+# weight columns, when length_as_weigtht, overwrites it
+# with a warning.", {
+# set.seed(213)
+# column_weight = runif(6)
+# expect_warning(
+# net <- roxel[25:30, ] |>
+# mutate(weight = column_weight) |>
+# as_sfnetwork(length_as_weight = T),
+# "Overwriting column"
+# )
+# expect_false(all(as.numeric(pull(activate(net, "edges"), "weight"))
+# == column_weight))
+# })
diff --git a/tests/testthat/test_tidygraph.R b/tests/testthat/test_tidygraph.R
index e9edbc4d..1b33848e 100644
--- a/tests/testthat/test_tidygraph.R
+++ b/tests/testthat/test_tidygraph.R
@@ -1,20 +1,20 @@
library(tidygraph)
test_that("Morphing works for sfnetwork and tbl_graph objects", {
expect_s3_class(
- roxel %>%
- as_sfnetwork() %>%
+ roxel |>
+ as_sfnetwork() |>
morph(to_components),
"morphed_sfnetwork"
)
expect_s3_class(
- roxel %>%
- as_sfnetwork() %>%
- as_tbl_graph() %>%
+ roxel |>
+ as_sfnetwork() |>
+ as_tbl_graph() |>
morph(to_components),
"morphed_tbl_graph"
)
expect_error(
- roxel %>%
+ roxel |>
morph(to_components),
"no applicable method for 'morph' applied to"
)
diff --git a/vignettes/.gitignore b/vignettes/.gitignore
new file mode 100644
index 00000000..efbd2041
--- /dev/null
+++ b/vignettes/.gitignore
@@ -0,0 +1,2 @@
+*_files
+*.R
\ No newline at end of file
diff --git a/vignettes/figures/data-structure-dark.png b/vignettes/figures/data-structure-dark.png
new file mode 100644
index 00000000..fd9b0cfc
Binary files /dev/null and b/vignettes/figures/data-structure-dark.png differ
diff --git a/vignettes/figures/data-structure.png b/vignettes/figures/data-structure.png
new file mode 100644
index 00000000..3ca330b7
Binary files /dev/null and b/vignettes/figures/data-structure.png differ
diff --git a/vignettes/figures/dependencies-dark.png b/vignettes/figures/dependencies-dark.png
new file mode 100644
index 00000000..3e5821d4
Binary files /dev/null and b/vignettes/figures/dependencies-dark.png differ
diff --git a/vignettes/figures/dependencies.png b/vignettes/figures/dependencies.png
new file mode 100644
index 00000000..67e37cda
Binary files /dev/null and b/vignettes/figures/dependencies.png differ
diff --git a/vignettes/sfn01_intro.qmd b/vignettes/sfn01_intro.qmd
new file mode 100644
index 00000000..a1b1f67b
--- /dev/null
+++ b/vignettes/sfn01_intro.qmd
@@ -0,0 +1,600 @@
+---
+title: "Introduction to sfnetworks"
+date: "`r Sys.Date()`"
+vignette: >
+ %\VignetteIndexEntry{1. Introduction to sfnetworks}
+ %\VignetteEncoding{UTF-8}
+ %\VignetteEngine{quarto::html}
+format:
+ html:
+ toc: true
+knitr:
+ opts_chunk:
+ collapse: true
+ comment: '#>'
+ opts_knit:
+ global.par: true
+---
+
+```{r}
+#| label: setup
+#| include: false
+current_geos = numeric_version(sf::sf_extSoftVersion()["GEOS"])
+required_geos = numeric_version("3.7.0")
+geos37 = current_geos >= required_geos
+```
+
+```{r}
+#| label: plot
+#| echo: false
+#| results: asis
+# plot margins
+oldpar = par(no.readonly = TRUE)
+par(mar = c(0, 0, 0, 0))
+# crayon needs to be explicitly activated in Rmd
+oldoptions = options()
+options(crayon.enabled = TRUE)
+# Hooks needs to be set to deal with outputs
+# thanks to fansi logic
+old_hooks = fansi::set_knit_hooks(
+ knitr::knit_hooks,
+ which = c("output", "message", "error")
+)
+```
+
+The `{sfnetworks}` package is designed to represent and analyze geospatial networks. These are networks that are embedded in geographical space, in which the nodes and edges can be represented by spatial simple features. The nodes most commonly as points, and the edges as linestrings. This vignette provides a basic introduction to the package.
+
+```{r}
+#| message: false
+library(sfnetworks)
+library(sf)
+library(tidygraph)
+library(igraph)
+library(ggraph)
+library(dplyr)
+```
+
+## Rationale
+
+In R there are very good packages for respectively geospatial analysis and standard network analysis:
+
+- The `{sf}` package brings the simple features standard to R and provides an interface to low-level geospatial system libraries such as GDAL, GEOS, and s2, allowing to represent and analyze spatial vector data such as points, lines and polygons.
+- The `{tidygraph}` package provides a tidy interface to the large network analysis library `{igraph}`, which is written in C and also has an R API.
+
+These packages by themselves are great for their purposes. However, `{sf}` does not know about networks, while `{tidygraph}` does not know about space. By combining their forces, `{sfnetworks}` enables integrated workflows connecting network analysis with spatial analysis. In addition, it offers functions that are specific for spatial network analysis, and cannot be found in either of the two "parent packages". For that, it often utilizes additional building blocks from within the R world.
+
+```{r}
+#| echo: false
+knitr::include_graphics("figures/dependencies.png", error = FALSE)
+```
+
+## Representing spatial networks
+
+Spatial networks in `{sfnetworks}` are represented by objects of class `sfnetwork`. These objects inherit the `tbl_graph` class from `{tidygraph}`, which in turn inherit the `igraph` class from `{igraph}`. What this means is that in their core they are designed to store graph structures. However, thanks to the design of `{tidygraph}`, they look like a collection of two flat tables: one for the nodes, and one for the edges. Where in `{tidygraph}` these tables can be treated as tibbles, in `{sfnetworks}` they are sf data frames with a list column containing the geometry of each feature.
+
+In `{sfnetworks}`, edges can either be directed (the default) or undirected. Mixed networks that contain both types of edges, e.g. road networks with oneway streets, can be represented by duplicating and reversing all edges that can be traveled in both ways. Where nodes in spatial networks need to explicitly store geometries, for edges this is not always required. If edges are straight lines, their spatial embedding can also be inferred from the spatial locations of the nodes they connect. In `{sfnetworks}` we refer to these two approaches as *spatially explicit edges* and *spatially implicit edges*, respectively. More details on the representation of spatial networks with the `sfnetwork` class can be found in the vignette [Creating and representing spatial networks](https://luukvdmeer.github.io/sfnetworks/articles/sfn02_create_represent.html#the-sfnetwork-class).
+
+```{r}
+#| echo: false
+knitr::include_graphics("figures/data-structure.png", error = FALSE)
+```
+
+There are many different ways to create a `sfnetwork` object, which are explained in detail in the vignette [Creating and representing spatial networks](https://luukvdmeer.github.io/sfnetworks/articles/sfn02_create_represent.html#creating-sfnetwork-objects). Most commonly you will start with a set of spatial features, stored as an object of class `sf`. If these features are linestrings, they are considered to be the edges of the network, and nodes are created at their endpoints. If multiple linestrings share an endpoint, this becomes a single node in the network, and hence, these edges are adjacent.
+
+```{r}
+net = as_sfnetwork(roxel)
+net
+```
+
+```{r}
+plot(net)
+```
+
+If the features are points, they are considered to be the nodes of the network, and they are connected by edges according to a given adjacency matrix. The adjacency matrix can also be created internally according to a specified method.
+
+```{r}
+net = as_sfnetwork(mozart, connections = "gabriel", directed = FALSE)
+net
+```
+
+```{r}
+plot(net)
+```
+
+## Analyzing spatial networks
+
+Thanks to the design of both `{sf}` and `{tidygraph}`, spatial network analysis with `{sfnetworks}` can be fitted seamlessly into tidy data analysis workflows using the [tidyverse](https://www.tidyverse.org/) family of packages. Since a `sfnetwork` object can be treated as a collection of two tables, rather than one, you just need to specify to which of them you want to apply a function. For this, `{tidygraph}` invented the `activate()` verb, allowing to set either the nodes or the edges as the target of analysis. Having done that, you can apply your favorite tidyverse verb just as you're used to. As you can see below the activation of a network element also changes the order in which they are printed.
+
+```{r}
+net |>
+ activate(nodes) |>
+ mutate(label = letters[1:n()]) |>
+ select(name, label) |>
+ activate(edges) |>
+ filter(sample(c(TRUE, FALSE), n(), replace = TRUE))
+```
+
+Obviously a network is more than just a list of two distinct elements. Nodes and edges are related to each other. Therefore, some operations that are applied to the nodes may also affect the edges, and vice versa. A good example of this is filtering. Whenever nodes are removed from the network, the edges terminating at those nodes will be removed too. This behavior is *not* symmetric: when removing edges, the endpoints of those edges remain, even if they are not an endpoint of any other edge. This is because by definition edges can never exist without nodes on their ends, while nodes can peacefully exist in isolation.
+
+```{r}
+# Filtering nodes also reduces the number of edges.
+net |>
+ activate(nodes) |>
+ filter(type == "artwork")
+```
+
+Another consequence of working with relational data is that not all operations that are defined for single tables are applicable to networks. For example, the common groupby-apply-combine workflows are not supported, since they would break the relational structure. In these cases, however, you can always extract the active element from the network either as a sf object or a tibble, and proceed your analysis on a single table.
+
+```{r}
+net |>
+ activate(nodes) |>
+ st_as_sf() |>
+ group_by(type) |>
+ summarize(n = n())
+```
+
+### Network analysis with tidygraph
+
+To allow performing common network analysis tasks, `{sfnetworks}` builds upon tidygraph. Since `sfnetwork` objects inherit the `tbl_graph` class, all analytical functions from `{tidygraph}` can be directly used. There are a lot of such functions available, most of which are tidy wrappers around functions from the `{igraph}` library. For a complete overview, check the [tidygraph documentation](https://tidygraph.data-imaginist.com/).
+
+It is important to note that in the case of spatial networks it often makes most sense to use geographic length as edge weight. Since `{tidygraph}` does not know about space, it will never set this automatically, so you will have to explicitly specify it every time you call a function that can consider edge weights. Read more about specifying edge weights in the vignette [Routing on spatial networks](https://luukvdmeer.github.io/sfnetworks/articles/sfn02_create_represent.html/#specifying-edge-weights).
+
+```{r}
+net = net |>
+ activate(edges) |>
+ mutate(length = edge_length())
+
+net
+```
+
+#### Measures
+
+A large set of functions in `{tidygraph}` is dedicated to the computation of quantitative **measures** for nodes, edges, or the network as a whole. Most known are centrality measures, which define the importance of a node in the network. The names of all implemented centrality measure functions are formatted as `centrality_*`. Other implemented measure functions have names formatted as `node_*` (for node measures) or `edge_*` (for edge measures) or `graph_*` (for network measures). A special group of measure functions are the logical measures, which return either `TRUE` or `FALSE`, indicating if the node, edge or network is of a specified type.
+
+None of these functions are meant to be called directly, but used inside tidyverse-verbs such as `dplyr::mutate()` or `dplyr::filter()`, where the analyzed network is know and thus not needed as an input to the function. `tidygraph::with_graph()` can be used to evaluate a measure in the context of a specific network, but outside the tidyverse framework.
+
+```{r}
+new_net = net |>
+ activate(edges) |>
+ filter(!edge_is_incident(13)) |>
+ activate(nodes) |>
+ mutate(bc = centrality_betweenness(weights = length))
+
+new_net
+```
+
+```{r}
+ggraph(new_net, "sf") +
+ geom_edge_sf() +
+ geom_node_sf(aes(size = bc)) +
+ theme_void()
+```
+
+```{r}
+with_graph(net, centrality_degree()) |> setNames(NULL)
+```
+
+#### Community detection
+
+Another set of functions in `{tidygraph}` deals with **community detection** in networks. These functions group nodes or edges based on a clustering algorithm. Their names are all formatted as `group_*`, and they will always return a vector of group indices, one for each feature. Most of the implemented algorithms will try to create groups of nodes such that the number of edges within groups is relatively high compared to the number of edges between groups, as in the example below. Do note that these algorithms are generally not designed to account for spatial factors, and they may have limited use when working with spatial networks.
+
+```{r}
+new_net = net |>
+ activate(nodes) |>
+ mutate(group = group_optimal())
+```
+
+```{r}
+ggraph(new_net, "sf") +
+ geom_edge_sf() +
+ geom_node_sf(aes(color = as.factor(group)), size = 4) +
+ scale_colour_discrete("group") +
+ theme_void()
+```
+
+#### Morphers
+
+Finally, `{tidygraph}` has introduced a new set of network analysis functions which they call **morphers**. Morphers change the structure of the input graph, for example by subsetting or splitting it, by combining multiple features into one, or by converting nodes to edges and vice versa. If this different struture is only needed for a few computations, it can be set temporarily by providing the morphers to subsequently the `tidygraph::morph()` and `tidygraph::unmorph()` verbs. If the different structure is meant to last, the morpher can be provided to the `tidygraph::convert()` verb instead. An example is to convert a network into its minimum spanning tree, which contains only the minimum set of edges need such that all nodes are still connected like before.
+
+```{r}
+new_net = net |>
+ convert(to_minimum_spanning_tree, weights = length)
+```
+
+```{r}
+ggraph(new_net, "sf") +
+ geom_edge_sf() +
+ geom_node_sf(size = 4) +
+ theme_void()
+```
+
+### Spatial analysis with sf
+
+By writing methods for many spatial analytical functions from the `{sf}` package, `{sfnetworks}` allows these functions to be called directly on `sfnetwork` objects, without the need for conversion. See [here]() for an overview of all sf functions that have a method for `sfnetwork` objects. For a complete overview of the functionalities of `{sf}`, check the [sf documentation](https://r-spatial.github.io/sf/).
+
+#### Coordinate reference systems
+
+One of the many tasks that `{sf}` covers is the specification of **coordinate reference systems**, CRS for short, and the transformation of spatial coordinates between such systems. In a `sfnetwork` object the nodes and edges always have the same CRS, so it does not matter which of the elements is active when you retrieve the CRS, or when you transform coordinates into a different CRS.
+
+```{r}
+st_crs(net)$epsg
+```
+
+```{r}
+st_transform(net, 4326)
+```
+
+#### Spatial predicates
+
+In `{sf}` there are also multiple functions that implement the evaluation of a **spatial predicate**. A spatial predicate describes a spatial relation between two geometries. For example, a point A may be located *within* a polygon B. In the example below we show how to evaluate for each node in a spatial network if it is located within one of two given polygons.
+
+```{r}
+polys = mozart |>
+ slice(4, 15) |>
+ st_buffer(325) |>
+ mutate(label = c("A", "B")) |>
+ select(label)
+```
+
+```{r}
+ggraph(net, "sf") +
+ geom_sf(data = polys, linewidth = 1, color = "orange") +
+ geom_edge_sf() +
+ geom_node_sf(size = 4) +
+ theme_void()
+```
+
+```{r}
+net |>
+ activate(nodes) |>
+ st_within(polys)
+```
+
+#### Spatial joins and filters
+
+By using spatial predicates, `{sf}` offers the ability to perform **spatial joins** and **spatial filters**. Spatial joins join information from a spatial feature to all features it has the specified spatial relation with. Spatial filters keep those features that have the specified spatial relation, and removes those that have not. For more details on spatial joins and filters for spatial networks, see the vignette [Spatial joins and filters](https://luukvdmeer.github.io/sfnetworks/articles/sfn04_join_filter.html).
+
+```{r}
+new_net = net |>
+ activate(nodes) |>
+ st_join(polys, join = st_within)
+```
+
+```{r}
+ggraph(new_net, "sf") +
+ geom_sf(data = polys, linewidth = 1, color = "orange") +
+ geom_edge_sf() +
+ geom_node_sf(aes(color = as.factor(label)), size = 4) +
+ scale_colour_discrete("label") +
+ theme_void()
+```
+
+```{r}
+new_net = net |>
+ activate(nodes) |>
+ st_filter(polys, .predicate = st_within)
+```
+
+```{r}
+ggraph(new_net, "sf") +
+ geom_sf(data = polys, linewidth = 1, color = "orange") +
+ geom_edge_sf() +
+ geom_node_sf(size = 4) +
+ theme_void()
+```
+
+#### Geometric unary operations
+
+Finally, `{sf}` contains a group of functions known as **geometric unary operations**. These functions change the structure of individual geometries. Their usage in the context of spatial networks is limited, since many of them would break the network structure in which endpoints of edges are spatially equal to their respective nodes. Hence, only those geometric unary operations that do not change the type, shape, or position of the geometries have a method for `sfnetwork` objects. These include `sf::st_reverse()` to reverse edge geometries, and `sf::st_segmentize()` to add interior points to edge geometries. However, just as with unsupported tidyverse-verbs, you can still apply unsupported geometric unary operations by first extracting the active element as a `sf` object, and proceed your analysis on a single table.
+
+```{r}
+net |>
+ activate(nodes) |>
+ st_as_sf() |>
+ st_buffer(250)
+```
+
+### Spatial network specific additions
+
+As mentioned, `{sfnetworks}` extends the functionalities of `{tidygraph}` and `{sf}` by offering functions that are specific for spatial network analysis, as well as functions that allow for a smoother integration of `{sf}` into the design of `{tidygraph}` workflows. In contradiction to `{tidygraph}`, all functions in `{sfnetworks}` that consider edge weights will always use geographic edge length as the default weight.
+
+#### Relating networks and spatial simple features
+
+The first family of functions in `{sfnetworks}` have names formatted as `st_network_*`, adopting the naming conventions of `{sf}`. All of these functions take a spatial network as first input an implement an analysis task that involves both the network and a set of regular spatial simple features (points, lines, polygons). The returned object can either be a set of spatial simple features computed from that network, the network itself with other spatial features merged into it, or another object (e.g., a matrix) that is the result of a computation relating the network to the spatial features. This covers a large range of use-cases. Examples include drawing isochrone or isodistance polygons around nodes, finding geographic shortest paths between pairs of nodes, computing cost matrices of travel between nodes, extracting the faces of the network, and adding external point data as new nodes to the network. In the other vignettes you will find more details on all of these functions.
+
+There are also functions in `{sfnetworks}` that start only with `st_*`, and end with `*_network`. In contradiction to the `st_network_*` functions, these functions take a set of spatial features as first input, and the network as an additional argument. An example is `st_project_on_network`, that projects points onto the network.
+
+```{r}
+#| warning: false
+# Find shortest path between node 1 and node 17.
+path = st_network_paths(net, 1, 17)
+
+# Draw an isodistance polygon with 500m threshold around node 13.
+iso = st_network_iso(net, 13, 500)
+
+# Extract the faces of the network.
+faces = st_network_faces(net)
+
+# Blend external points as new nodes into the network.
+feats = st_sample(st_bbox(mozart), 10)
+blend = st_network_blend(net, feats)
+```
+
+```{r}
+#| layout-ncol: 2
+#| layout-nrow: 2
+#| fig-cap:
+#| - "Shortest path"
+#| - "Isodistance polygon"
+#| - "Network faces"
+#| - "Blended points"
+ggraph(net, "sf") +
+ geom_edge_sf() +
+ geom_sf(data = path, color = "orange", linewidth = 2) +
+ geom_node_sf(size = 4) +
+ geom_sf(data = st_geometry(net, "nodes")[c(1, 17)], size = 6) +
+ theme_void()
+
+ggraph(net, "sf") +
+ geom_sf(data = iso, fill = "orange", alpha = 0.7) +
+ geom_edge_sf() +
+ geom_node_sf(size = 4) +
+ geom_sf(data = st_geometry(net, "nodes")[13], size = 6) +
+ theme_void()
+
+faces_sf = st_sf(id = c(1:length(faces)), geometry = faces)
+
+ggraph(net, "sf") +
+ geom_sf(data = faces_sf, aes(fill = as.factor(id)), show.legend = FALSE) +
+ geom_edge_sf() +
+ geom_node_sf(size = 4) +
+ theme_void()
+
+ggraph(blend, "sf") +
+ geom_edge_sf() +
+ geom_node_sf(size = 4) +
+ geom_sf(data = feats, color = "orange", size = 4) +
+ geom_sf(
+ data = st_nearest_points(feats, st_combine(st_geometry(blend, "nodes"))),
+ color = "grey",
+ linetype = 2
+ ) +
+ theme_void()
+```
+
+#### Spatial measures
+
+As an extension to the measure functions of `{tidygraph}`, there are several **spatial measure functions** for nodes and edges to be found in `{sfnetworks}`. Just as in `{tidygraph}`, these functions are named in the style of `node_*` or `edge_*`, or when it is a centrality measure, `centrality_*`. Edge measures that can be computed include for example the geographic length of an edge, its azimuth, or its circuity (i.e. the ratio between its geographic length and the shortest euclidean distance from source to target node). For nodes, it is for example possible to compute the straightness centrality (i.e. the average ratio of euclidean distance and network distance between that node and all other nodes in the network).
+
+To better integrate spatial predicate functions of `{sf}` into the design of `{tidygraph}`, all applicable predicates are also implemented as node and edge measure function. These functions will return `TRUE` if the node or edge has the specified spatial relation with at least one of the given spatial features, and `FALSE` otherwise. This makes it easy to use the predicates directly inside functions such as `dplyr::filter()` and `dplyr::mutate()`.
+
+```{r}
+new_net = net |>
+ activate(edges) |>
+ filter(edge_intersects(polys)) |>
+ mutate(circuity = edge_circuity()) |>
+ activate(nodes) |>
+ mutate(
+ sc = centrality_straightness(),
+ in_poly = node_is_within(polys)
+ )
+
+new_net
+```
+
+#### Spatial clustering
+
+As an extension to the community detection functions in `{tidygraph}`, `{sfnetworks}` contains the `group_spatial_dbscan()` function to find **spatial clusters** of nodes. The idea is to offer multiple spatial clustering algorithms to choose from, but currently the only one implemented is the [DBSCAN](https://en.wikipedia.org/wiki/DBSCAN) algorithm (which does require the `{dbscan}` package to be installed). The algorithm is executed on the network distance matrix of the nodes, and not on the euclidean distance matrix.
+
+```{r}
+new_net = net |>
+ activate(nodes) |>
+ mutate(group = group_spatial_dbscan(300))
+```
+
+```{r}
+ggraph(new_net, "sf") +
+ geom_edge_sf() +
+ geom_node_sf(aes(color = as.factor(group)), size = 4) +
+ scale_colour_discrete("group") +
+ theme_void()
+```
+
+#### Spatial morphers
+
+As an extension to the morpher functions in `{tidygraph}`, there are many different **spatial morpher functions** implemented in `{sfnetworks}`. Just as in `{tidygraph}`, these function change the structure of the input network, for example by subsetting or splitting it, by combining multiple features into one, or by splitting single features. Common examples include subdividing the network at interior points in edge geometries, smoothing pseudo nodes that have a degree of 2, and contracting multiple nodes into one while preserving the network connectivity. In the other vignettes you will find more details on all of these functions. Many morphers are used for network cleaning operations, which are described in detail in the vignette [Cleaning spatial networks](https://luukvdmeer.github.io/sfnetworks/articles/sfn03_cleaning.html).
+
+```{r}
+#| warning: false
+# Smooth nodes of degree 2.
+smooth = convert(net, to_spatial_smooth)
+
+# Subdivide edges at each interior point in the smoothed network.
+# In this case this is the opposite of smoothing, it adds back the degree 2 nodes.
+division = convert(smooth, to_spatial_subdivision, all = TRUE)
+
+# Contract nodes that are in the same spatial cluster.
+contraction = net |>
+ activate(nodes) |>
+ mutate(group = group_spatial_dbscan(300)) |>
+ convert(to_spatial_contracted, group)
+
+# Subset the graph to only those edges in a shortest path.
+path = convert(net, to_spatial_shortest_paths, 1, 17)
+```
+
+```{r}
+#| layout-ncol: 2
+#| layout-nrow: 2
+#| fig-cap:
+#| - "Smooth network"
+#| - "Subdivision of the smooth network"
+#| - "Contracted groups of nodes"
+#| - "A single path"
+ggraph(smooth, "sf") +
+ geom_edge_sf() +
+ geom_node_sf(size = 4) +
+ theme_void()
+
+ggraph(division, "sf") +
+ geom_edge_sf() +
+ geom_node_sf(size = 4) +
+ theme_void()
+
+ggraph(contraction, "sf") +
+ geom_edge_sf() +
+ geom_node_sf(size = 4) +
+ theme_void()
+
+ggraph(path, "sf") +
+ geom_edge_sf() +
+ geom_node_sf(size = 4) +
+ theme_void()
+```
+
+#### Utilities
+
+Finally, `{sfnetworks}` exports all kind of utility functions that should make working with spatial networks less cumbersome. An example is finding the nearest node and nearest edge to a given spatial feature. For an overview of all exported functions of the package, see the [function reference](https://luukvdmeer.github.io/sfnetworks/reference/index.html).
+
+```{r}
+p = st_centroid(st_combine(mozart))
+
+nn = nearest_nodes(net, p)
+nn
+ne = nearest_edges(net, p)
+ne
+```
+
+```{r}
+#| layout-ncol: 2
+#| fig-cap:
+#| - "Nearest node"
+#| - "Nearest edge"
+ggraph(net, "sf") +
+ geom_sf(data = p, size = 6, pch = 8) +
+ geom_edge_sf(color = "grey") +
+ geom_node_sf(color = "grey", size = 4) +
+ geom_sf(data = nn, color = "orange", size = 6) +
+ theme_void()
+
+ggraph(net, "sf") +
+ geom_sf(data = p, size = 6, pch = 8) +
+ geom_edge_sf(color = "grey") +
+ geom_sf(data = ne, color = "orange", linewidth = 2) +
+ geom_node_sf(color = "grey", size = 4) +
+ theme_void()
+```
+
+### Non-tidyverse workflows
+
+Also if you are not a fan of the tidyverse style of data analysis, `{sfnetworks}` can be for you. Since `sfnetwork` objects inherit the `igraph` class, you can apply all functions from the large `{igraph}` package to it. This means, for example, that instead of using `dplyr::mutate()` and `dplyr::filter()`, you could proceed as follows (note that in `{igraph}`, the term *vertex* is used instead of *node*).
+
+```{r}
+# Mutate.
+vertex_attr(net, "label") = letters[1:vcount(net)]
+
+# Filter.
+drop = which(!sample(c(TRUE, FALSE), ecount(net), replace = TRUE))
+new_net = delete_edges(net, drop)
+
+new_net
+```
+
+As you can see, this returns a `igraph` object instead of a `sfnetwork` object. To preserve the class, you can use the `wrap_igraph()` function of `{sfnetworks}` for each `{igraph}` function that accepts a network as first input and returns another network. This function will check if the returned network is still spatial.
+
+```{r}
+#| message: false
+wrap_igraph(net, delete_edges, drop)
+```
+
+```{r}
+#| message: false
+mst = wrap_igraph(net, mst, weights = edge_attr(net, "length"))
+
+ggraph(mst, "sf") +
+ geom_edge_sf() +
+ geom_node_sf(size = 4) +
+ theme_void()
+```
+
+All spatial measure functions can be evaluated outside the `{tidygraph}` framework using `with_graph()`, which works similar to base R's `with()` function.
+
+```{r}
+with_graph(net, edge_circuity())
+```
+
+All spatial morpher functions that implement a larger workflow have their internal workers exported, meaning they can be called directly outside the `tidygraph::morph()` and `tidygraph::convert()` verbs. All other morphers are easy to replicate using `{sf}` or other `{sfnetworks}` functions directly.
+
+## Visualizing spatial networks
+
+To quickly visualize the network you can use the `plot()` method for `sfnetwork` objects. This will use `sf` to plot the geometries of nodes and edges in one view. Using the regular arguments of `plot()`, you can change the style of the plot as a whole. Arguments `node_args` and `edge_args` are added such that you can also provide style settings for nodes and edges separately, in list-format.
+
+```{r}
+#| layout-ncol: 3
+#| fig-cap:
+#| - "Default settings"
+#| - "Custom settings"
+#| - "Custom settings"
+plot(mst)
+
+plot(mst, col = "orange", pch = 8, cex = 4)
+
+plot(
+ mst,
+ node_args = list(col = "orange", cex = 3),
+ edge_args = list(col = "grey", lwd = 2)
+)
+```
+
+For more advanced visualizations, we recommend to use `{ggraph}`. This is an extension of `{ggplot}` for network data that works seamlessly with `tbl_graph` data structures. In fact, `{tidygraph}` was initially developed with the idea to provide a suitable data structure for `{ggraph}` visualizations. It now also has native support for `{sfnetworks}` with the *sf* layout and the `ggraph::geom_node_sf()` and `ggraph::geom_edge_sf()` geoms that are aware of the spatial nature of the data. Edges can also be plotted without their spatial embedding, using one of the many other geoms offerend by `{ggraph}`. To plot additional spatial layers that are not networks, you can simply use `ggplot::geom_sf()`. For more details, check the [ggraph documentation](https://ggraph.data-imaginist.com).
+
+```{r}
+#| layout-ncol: 2
+#| layout-nrow: 2
+#| fig-cap:
+#| - "Default settings"
+#| - "Custom settings"
+#| - "Custom settings"
+#| - "Additional layers"
+ggraph(mst, "sf") +
+ geom_edge_sf() +
+ geom_node_sf() +
+ theme_void()
+
+ggraph(mst, "sf") +
+ geom_edge_sf() +
+ geom_node_sf(aes(color = as.factor(type)), size = 4) +
+ scale_color_discrete("type") +
+ theme_void()
+
+ggraph(mst, "sf") +
+ geom_edge_fan(aes(alpha = after_stat(index)), show.legend = FALSE) +
+ geom_node_sf(aes(size = centrality_degree()), color = "orange") +
+ theme_void()
+
+ggraph(mst, "sf") +
+ geom_edge_sf(color = "grey") +
+ geom_node_sf(color = "orange", size = 4) +
+ geom_sf(
+ data = st_buffer(st_centroid(st_combine(mozart)), 300),
+ fill = "skyblue", alpha = 0.5, linewidth = 0.8
+ ) +
+ theme_void()
+```
+
+If you want to create Leaflet-based interactive maps, take a look at the packages `{mapview}` or `{tmap}`. These are well integrated with `{sf}`, so you can extract nodes and edges from the network, and plot them as two separate layers.
+
+## Learning more
+
+This vignette provided an introduction to `{sfnetworks}`. The documentation contains several other vignettes that dive into more detail:
+
+- [Creating and representing spatial networks](https://luukvdmeer.github.io/sfnetworks/articles/sfn02_create_represent.html)
+- [Cleaning spatial networks](https://luukvdmeer.github.io/sfnetworks/articles/sfn03_cleaning.html)
+- [Spatial joins and filters](https://luukvdmeer.github.io/sfnetworks/articles/sfn04_join_filter.html)
+- [Routing on spatial networks](https://luukvdmeer.github.io/sfnetworks/articles/sfn05_routing.html)
+
+```{r}
+#| include: false
+par(oldpar)
+options(oldoptions)
+```
diff --git a/vignettes/sfn01_structure.Rmd b/vignettes/sfn01_structure.Rmd
deleted file mode 100644
index 8b6c3647..00000000
--- a/vignettes/sfn01_structure.Rmd
+++ /dev/null
@@ -1,357 +0,0 @@
----
-title: "1. The sfnetwork data structure"
-date: "`r Sys.Date()`"
-output: rmarkdown::html_vignette
-vignette: >
- %\VignetteIndexEntry{1. The sfnetwork data structure}
- %\VignetteEngine{knitr::rmarkdown}
- %\VignetteEncoding{UTF-8}
----
-
-```{r setup, include=FALSE}
-knitr::opts_chunk$set(
- collapse = TRUE,
- comment = "#>"
-)
-knitr::opts_knit$set(global.par = TRUE)
-current_geos = numeric_version(sf::sf_extSoftVersion()["GEOS"])
-required_geos = numeric_version("3.7.0")
-geos37 = current_geos >= required_geos
-```
-
-```{r plot, echo=FALSE, results='asis'}
-# plot margins
-oldpar = par(no.readonly = TRUE)
-par(mar = c(1, 1, 1, 1))
-# crayon needs to be explicitly activated in Rmd
-oldoptions = options()
-options(crayon.enabled = TRUE)
-# Hooks needs to be set to deal with outputs
-# thanks to fansi logic
-old_hooks = fansi::set_knit_hooks(
- knitr::knit_hooks,
- which = c("output", "message", "error")
-)
-```
-
-The core of the sfnetworks package is the sfnetwork data structure. It inherits the tbl_graph class from the [tidygraph package](https://tidygraph.data-imaginist.com/index.html), which itself inherits the igraph class from the [igraph package](https://igraph.org/). Therefore, sfnetwork objects are recognized by all network analysis algorithms that `igraph` offers (which are a lot, see [here](https://igraph.org/r/doc/)) as well as by the tidy wrappers that `tidygraph` has built around them.
-
-It is possible to apply any function from the [tidyverse packages](https://www.tidyverse.org/) for data science directly to a sfnetwork, as long as `tidygraph` implemented a network specific method for it. On top of that, `sfnetworks` added several methods for functions from the [sf package](https://r-spatial.github.io/sf/) for spatial data science, such that you can also apply those directly to the network. This takes away the need to constantly switch between the tbl_graph, tbl_df and sf classes when working with geospatial networks.
-
-```{r, message=FALSE}
-library(sfnetworks)
-library(sf)
-library(tidygraph)
-library(igraph)
-library(ggplot2)
-```
-
-## Philosophy
-
-The philosophy of a tbl_graph object is best described by the following paragraph from the [tidygraph introduction](https://www.data-imaginist.com/2017/introducing-tidygraph/): "Relational data cannot in any meaningful way be encoded as a single tidy data frame. On the other hand, both node and edge data by itself fits very well within the tidy concept as each node and edge is, in a sense, a single observation. Thus, a close approximation of tidyness for relational data is two tidy data frames, one describing the node data and one describing the edge data."
-
-Since sfnetworks subclass tbl_graph, it shares the same philosophy. However, it extends it into the domain of geospatial data analysis, where each observation has a location in geographical space. For that, it brings `sf` into the game. An sf object stores the geographical coordinates of each observation in standardized format in a geometry list-column, which has a Coordinate Reference System (CRS) associated with it. Thus, in `sfnetworks`, we re-formulate the last sentence of the paragraph above to the following. "A close approximation of tidyness for relational *geospatial data* is two *sf objects*, one describing the node data and one describing the edge data."
-
-We do need to make a note here. In a geospatial network, the nodes *always* have coordinates in geographic space, and thus, can always be described by an sf object. The edges, however, can also be described by only the indices of the nodes at their ends. This still makes them geospatial, because they connect two specific points in space, but the spatial information is not *explicitly* attached to them. Both representations can be useful. In road networks, for example, it makes sense to explicitly draw a line geometry between two nodes, while in geolocated social networks, it probably does not. `sfnetworks` supports both types. It can either describe edges as an sf object, with a linestring geometry stored in a geometry list-column, or as a regular data frame, with the spatial information implicitly encoded in the node indices of the endpoints. We refer to these two different types of edges as *spatially explicit edges* and *spatially implicit edges* respectively. In most of the documentation, however, we focus on the first type, and talk about edges as being an sf object with linestring geometries.
-
-## Construction
-
-### From a nodes and edges table
-
-The most basic way to construct a sfnetwork with spatially explicit edges is by providing the `sfnetwork` construction function one sf object containing the nodes, and another sf object containing the edges. This edges table should include a *from* and *to* column referring to the node indices of the edge endpoints. With a node index we mean the position of a node in the nodes table (i.e. its rownumber). A small toy example:
-
-```{r}
-p1 = st_point(c(7, 51))
-p2 = st_point(c(7, 52))
-p3 = st_point(c(8, 52))
-p4 = st_point(c(8, 51.5))
-
-l1 = st_sfc(st_linestring(c(p1, p2)))
-l2 = st_sfc(st_linestring(c(p1, p4, p3)))
-l3 = st_sfc(st_linestring(c(p3, p2)))
-
-edges = st_as_sf(c(l1, l2, l3), crs = 4326)
-nodes = st_as_sf(c(st_sfc(p1), st_sfc(p2), st_sfc(p3)), crs = 4326)
-
-edges$from = c(1, 1, 3)
-edges$to = c(2, 3, 2)
-
-net = sfnetwork(nodes, edges)
-net
-class(net)
-```
-
-By default, the created network is a directed network. If you want to create an undirected network, set `directed = FALSE`. Note that for undirected networks, the indices in the *from* and *to* columns are re-arranged such that the *from* index is always smaller than (or equal to, for loop edges) the *to* index. However, the linestring geometries remain unchanged. That means that in undirected networks it can happen that for some edges the *from* index refers to the last point of the edge linestring, and the *to* index to the first point. The behavior of ordering the indices comes from `igraph` and might be confusing, but remember that in undirected networks the terms *from* and *to* do not have a meaning and can thus be used interchangeably.
-
-```{r}
-net = sfnetwork(nodes, edges, directed = FALSE)
-net
-```
-
-Instead of *from* and *to* columns containing integers that refer to node indices, the provided edges table can also have *from* and *to* columns containing characters that refer to node keys. In that case, you should tell the construction function which column in the nodes table contains these keys. Internally, they will then be converted to integer indices.
-
-```{r}
-nodes$name = c("city", "village", "farm")
-edges$from = c("city", "city", "farm")
-edges$to = c("village", "farm", "village")
-
-edges
-
-net = sfnetwork(nodes, edges, node_key = "name")
-net
-```
-
-If your edges table does not have linestring geometries, but only references to node indices or keys, you can tell the construction function to create the linestring geometries during construction. This will draw a straight line between the endpoints of each edge.
-
-```{r, fig.show='hold', out.width='50%'}
-st_geometry(edges) = NULL
-
-other_net = sfnetwork(nodes, edges, edges_as_lines = TRUE)
-
-plot(net, cex = 2, lwd = 2, main = "Original geometries")
-plot(other_net, cex = 2, lwd = 2, main = "Straight lines")
-```
-
-A sfnetwork should have a *valid* spatial network structure. For the nodes, this currently means that their geometries should all be of type *POINT*. In the case of spatially explicit edges, edge geometries should all be of type *LINESTRING*, nodes and edges should have the same CRS and endpoints of edges should match their corresponding node coordinates.
-
-If your provided data do not meet these requirements, the construction function will throw an error.
-
-```{r, error=TRUE}
-st_geometry(edges) = st_sfc(c(l2, l3, l1), crs = 4326)
-
-net = sfnetwork(nodes, edges)
-```
-
-You can skip the validity checks if you are already sure your input data meet the requirements, or if you don't care that they don't. To do so, set `force = TRUE`. However, remember that all functions in `sfnetworks` are designed with the assumption that the network has a valid structure.
-
-### From an sf object with linestring geometries
-
-Instead of already providing a nodes and edges table with a valid network structure, it is also possible to create a network by only providing an sf object with geometries of type *LINESTRING*. Probably, this way of construction is most convenient and will be most often used.
-
-It works as follows: the provided lines form the edges of the network, and nodes are created at their endpoints. Endpoints that are shared between multiple lines become one single node.
-
-See below an example using the Roxel dataset that comes with the package. This dataset is an sf object with *LINESTRING* geometries that form the road network of Roxel, a neighborhood in the German city of Münster.
-
-```{r, fig.height=5, fig.width=5}
-roxel
-net = as_sfnetwork(roxel)
-plot(net)
-```
-
-Other methods to convert 'foreign' objects into a sfnetwork exists as well, e.g. for SpatialLinesNetwork objects from `stplanr` and linnet objects from `spatstat`. See [here](https://luukvdmeer.github.io/sfnetworks/reference/as_sfnetwork.html) for an overview.
-
-## Activation
-
-A sfnetwork is a multitable object in which the core network elements (i.e. nodes and edges) are embedded as sf objects. However, thanks to the neat structure of `tidygraph`, there is no need to first extract one of those elements before you are able to apply your favorite sf function or tidyverse verb. Instead, there is always one element at a time labeled as *active*. This active element is the target of data manipulation. All functions from sf and the tidyverse that are called on a sfnetwork, are internally applied to that active element. The active element can be changed with the `activate()` verb, i.e. by calling `activate("nodes")` or `activate("edges")`. For example, setting the geographical length of edges as edge weights and subsequently calculating the betweenness centrality of nodes can be done as shown below. Note that `tidygraph::centrality_betweenness()` does require you to *always* explicitly specify which column should be used as edge weights, and if the network should be treated as directed or not.
-
-```{r}
-net %>%
- activate("edges") %>%
- mutate(weight = edge_length()) %>%
- activate("nodes") %>%
- mutate(bc = centrality_betweenness(weights = weight, directed = FALSE))
-```
-
-Some of the functions have effects also outside of the active element. For example, whenever nodes are removed from the network, the edges terminating at those nodes will be removed too. This behavior is *not* symmetric: when removing edges, the endpoints of those edges remain, even if they are not an endpoint of any other edge. This is because by definition edges can never exist without nodes on their ends, while nodes can peacefully exist in isolation.
-
-## Extraction
-
-Neither all sf functions nor all tidyverse verbs can be directly applied to a sfnetwork as described above. That is because there is a clear limitation in the relational data structure that requires rows to maintain their identity. Hence, a verb like `dplyr::summarise()` has no clear application for a network. For sf functions, this means also that the valid spatial network structure should be maintained. That is, functions that summarise geometries of an sf object, or (may) change their *type*, *shape* or *position*, are not supported directly. These are for example most of the [geometric unary operations](https://r-spatial.github.io/sf/reference/geos_unary.html).
-
-These functions cannot be directly applied to a sfnetwork, but no need to panic! The active element of the network can at any time be extracted with `sf::st_as_sf()` (or `tibble::as_tibble()`). This allows you to continue a specific part of your analysis *outside* of the network structure, using a regular sf object. Afterwards you could join inferred information back into the network. See the vignette about [spatial joins](https://luukvdmeer.github.io/sfnetworks/articles/sfn03_join_filter.html) for more details.
-
-```{r}
-net %>%
- activate("nodes") %>%
- st_as_sf()
-```
-
-Although we recommend for reasons of clarity to always explicitly activate an element before extraction, you can also use a shortcut by providing the name of the element you want to extract as extra argument to `sf::st_as_sf()`:
-
-```{r}
-st_as_sf(net, "edges")
-```
-
-## Visualization
-
-The `sfnetworks` package does not (yet?) include advanced visualization options. However, as already demonstrated before, a simple plot method is provided, which gives a quick view of how the network looks like.
-
-```{r, fig.width=5, fig.height=5}
-plot(net)
-```
-
-If you have `ggplot2` installed, you can also use `ggplot2::autoplot()` to directly create a simple ggplot of the network.
-
-```{r, message=FALSE, fig.width=5, fig.height=5}
-autoplot(net) + ggtitle("Road network of Münster Roxel")
-```
-
-For advanced visualization, we encourage to extract nodes and edges as `sf` objects, and use one of the many ways to map those in R, either statically or interactively. Think of sf's default plot method, `ggplot2::geom_sf()`, `tmap`, `mapview`, et cetera.
-
-```{r, fig.height=5, fig.width=5}
-net = net %>%
- activate("nodes") %>%
- mutate(bc = centrality_betweenness())
-
-ggplot() +
- geom_sf(data = st_as_sf(net, "edges"), col = "grey50") +
- geom_sf(data = st_as_sf(net, "nodes"), aes(col = bc, size = bc)) +
- ggtitle("Betweenness centrality in Münster Roxel")
-```
-
-*Note: it would be great to see this change in the future, for example by good integration with `ggraph`. Contributions are very welcome regarding this!*
-
-## Spatial information
-
-### Geometries
-
-Geometries of nodes and edges are stored in an 'sf-style' geometry list-column in respectively the nodes and edges tables of the network. The geometries of the active element of the network can be extracted with the sf function `sf::st_geometry()`, or from any element by specifying the element of interest as additional argument, e.g. `sf::st_geometry(net, "edges")`.
-
-```{r}
-net %>%
- activate("nodes") %>%
- st_geometry()
-```
-
-Geometries can be replaced using either `st_geometry(x) = value` or the pipe-friendly `st_set_geometry(x, value)`. However, a replacement that breaks the valid spatial network structure will throw an error.
-
-Replacing a geometry with `NULL` will remove the geometries. Removing edge geometries will result in a sfnetwork with spatially implicit edges. Removing node geometries will result in a tbl_graph, losing the spatial structure.
-
-```{r, fig.show = 'hold', out.width = "50%"}
-net %>%
- activate("edges") %>%
- st_set_geometry(NULL) %>%
- plot(draw_lines = FALSE, main = "Edges without geometries")
-
-net %>%
- activate("nodes") %>%
- st_set_geometry(NULL) %>%
- plot(vertex.color = "black", main = "Nodes without geometries")
-```
-
-Geometries can be replaced also by using [geometry unary operations](https://r-spatial.github.io/sf/reference/geos_unary.html), as long as they don't break the valid spatial network structure. In practice this means that only `sf::st_reverse()` and `sf::st_simplify()` are supported. When calling `sf::st_reverse()` on the edges of a directed network, not only the geometries will be reversed, but the *from* and *to* columns of the edges will be swapped as well. In the case of undirected networks these columns remain unchanged, since the terms *from* and *to* don't have a meaning in undirected networks and can be used interchangeably. Note that reversing linestrings using `sf::st_reverse()` only works when sf links to a GEOS version of at least 3.7.0.
-
-```{r, eval = geos37}
-as_sfnetwork(roxel, directed = TRUE) %>%
- activate("edges") %>%
- st_reverse()
-```
-
-### Coordinates
-
-The coordinates of the active element of a sfnetwork can be extracted with the sf function `sf::st_coordinates()`, or from any element by specifying the element of interest as additional argument, e.g. `sf::st_coordinate(net, "edges")`.
-
-```{r}
-node_coords = net %>%
- activate("nodes") %>%
- st_coordinates()
-
-node_coords[1:4, ]
-```
-
-Besides X and Y coordinates, the features in the network can possibly also have Z and M coordinates.
-
-```{r}
-# Currently there are neither Z nor M coordinates.
-st_z_range(net)
-st_m_range(net)
-
-# Add Z coordinates with value 0 to all features.
-# This will affect both nodes and edges, no matter which element is active.
-st_zm(net, drop = FALSE, what = "Z")
-```
-
-[Coordinate query functions](https://luukvdmeer.github.io/sfnetworks/reference/node_coordinates.html) can be used for the nodes to extract only specific coordinate values. Such query functions are meant to be used inside `dplyr::mutate()` or `dplyr::filter()` verbs. Whenever a coordinate value is not available for a node, `NA` is returned along with a warning. Note also that the two-digit coordinate values are only for printing. The real values contain just as much precision as in the geometry list column.
-
-```{r}
-net %>%
- st_zm(drop = FALSE, what = "Z") %>%
- mutate(X = node_X(), Y = node_Y(), Z = node_Z(), M = node_M())
-```
-
-### Coordinate Reference System
-
-The Coordinate Reference System in which the coordinates of the network geometries are stored can be extracted with the sf function `sf::st_crs()`. The CRS in a valid spatial network structure is *always* the same for nodes and edges.
-
-```{r}
-st_crs(net)
-```
-
-The CRS can be set using either `st_crs(x) = value` or the pipe-friendly `st_set_crs(x, value)`. The CRS will always be set for both the nodes and edges, no matter which element is active. However, setting the CRS only assigns the given CRS to the network. It does *not* transform the coordinates into a different CRS! Coordinates can be transformed using the sf function `sf::st_transform()`. Since the CRS is the same for nodes and edges, transforming coordinates of the active element into a different CRS will automatically also transform the coordinates of the inactive element into the same target CRS.
-
-```{r}
-st_transform(net, 3035)
-```
-
-### Precision
-
-The precision in which the coordinates of the network geometries are stored can be extracted with the sf function `sf::st_precision()`. The precision in a valid spatial network structure is *always* the same for nodes and edges.
-
-```{r}
-st_precision(net)
-```
-
-Precision can be set using `st_set_precision(x, value)`. The precision will always be set for both the nodes and edges, no matter which element is active.
-
-```{r}
-net %>%
- st_set_precision(1) %>%
- st_precision()
-```
-
-### Bounding box
-
-The bounding box of the active element of a sfnetwork can be extracted with the sf function `sf::st_bbox()`, or from any element by specifying the element of interest as additional argument, e.g. `sf::st_bbox(net, "edges")`.
-
-```{r}
-net %>%
- activate("nodes") %>%
- st_bbox()
-```
-
-The bounding boxes of the nodes and edges are not necessarily the same. Therefore, sfnetworks adds the `st_network_bbox()` function to retrieve the combined bounding box of the nodes and edges. In this combined bounding box, the most extreme coordinates of the two individual element bounding boxes are preserved. Hence, the `xmin` value of the network bounding box is the smallest `xmin` value of the node and edge bounding boxes, et cetera.
-
-```{r, fig.show='hold', out.width = "50%"}
-node1 = st_point(c(8, 51))
-node2 = st_point(c(7, 51.5))
-node3 = st_point(c(8, 52))
-node4 = st_point(c(9, 51))
-edge1 = st_sfc(st_linestring(c(node1, node2, node3)))
-
-nodes = st_as_sf(c(st_sfc(node1), st_sfc(node3), st_sfc(node4)))
-edges = st_as_sf(edge1)
-edges$from = 1
-edges$to = 2
-
-small_net = sfnetwork(nodes, edges)
-
-node_bbox = st_as_sfc(st_bbox(activate(small_net, "nodes")))
-edge_bbox = st_as_sfc(st_bbox(activate(small_net, "edges")))
-net_bbox = st_as_sfc(st_network_bbox(small_net))
-
-plot(small_net, lwd = 2, cex = 4, main = "Element bounding boxes")
-plot(node_bbox, border = "red", lty = 2, lwd = 4, add = TRUE)
-plot(edge_bbox, border = "blue", lty = 2, lwd = 4, add = TRUE)
-plot(small_net, lwd = 2, cex = 4, main = "Network bounding box")
-plot(net_bbox, border = "red", lty = 2, lwd = 4, add = TRUE)
-```
-
-### Attribute-geometry relationships
-
-In sf objects there is the possibility to store information about how attributes relate to geometries (for more information, see [here](https://r-spatial.github.io/sf/articles/sf1.html#how-attributes-relate-to-geometries)). You can get and set this information with the function `sf::st_agr()` (for the setter, you can also use the pipe-friendly version `sf::st_set_agr()`). In a sfnetwork, you can use the same functions to get and set this information for the active element of the network.
-
-Note that the *to* and *from* columns are not really attributes of edges seen from a network analysis perspective, but they are included in the agr factor to ensure smooth interaction with `sf`.
-
-```{r}
-net %>%
- activate("edges") %>%
- st_set_agr(c("name" = "constant", "type" = "constant")) %>%
- st_agr()
-```
-
-However, be careful, because we are currently not sure if this information survives all functions from `igraph` and `tidygraph`. If you have any issues with this, please let us know in our [issue tracker](https://github.com/luukvdmeer/sfnetworks/issues).
-
-```{r, include = FALSE}
-par(oldpar)
-options(oldoptions)
-```
diff --git a/vignettes/sfn02_create_represent.qmd b/vignettes/sfn02_create_represent.qmd
new file mode 100644
index 00000000..ed2d3653
--- /dev/null
+++ b/vignettes/sfn02_create_represent.qmd
@@ -0,0 +1,734 @@
+---
+title: "Creating and representing spatial networks"
+date: "`r Sys.Date()`"
+vignette: >
+ %\VignetteIndexEntry{2. Creating and representing spatial networks}
+ %\VignetteEncoding{UTF-8}
+ %\VignetteEngine{quarto::html}
+format:
+ html:
+ toc: true
+knitr:
+ opts_chunk:
+ collapse: true
+ comment: '#>'
+ opts_knit:
+ global.par: true
+---
+
+```{r}
+#| label: setup
+#| include: false
+current_geos = numeric_version(sf::sf_extSoftVersion()["GEOS"])
+required_geos = numeric_version("3.7.0")
+geos37 = current_geos >= required_geos
+```
+
+```{r}
+#| label: plot
+#| echo: false
+#| results: asis
+# plot margins
+oldpar = par(no.readonly = TRUE)
+par(mar = c(0, 0, 0, 0))
+# crayon needs to be explicitly activated in Rmd
+oldoptions = options()
+options(crayon.enabled = TRUE)
+# Hooks needs to be set to deal with outputs
+# thanks to fansi logic
+old_hooks = fansi::set_knit_hooks(
+ knitr::knit_hooks,
+ which = c("output", "message", "error")
+)
+```
+
+The `{sfnetworks}` contains the `sfnetwork` class to represent spatial networks in R. There are several ways in which you can create instances of this class. This vignette describes these ways, and provides more detail on the ins and outs of the data structure.
+
+```{r}
+#| message: false
+library(sfnetworks)
+library(sf)
+library(tidygraph)
+library(ggraph)
+library(dplyr)
+library(units)
+```
+
+## The sfnetwork class
+
+Spatial networks in `{sfnetworks}` are represented by objects of class `sfnetwork`. These objects inherit the `tbl_graph` class from `{tidygraph}`, which in turn inherit the `igraph` class from `{igraph}`. What this means is that in their core they are designed to store graph structures. However, thanks to the design of `{tidygraph}`, they look like a collection of two flat tables: one for the nodes, and one for the edges. In the documentation of `{tidygraph}`, this design choice is explained as follows.
+
+> Relational data cannot in any meaningful way be encoded as a single tidy data frame. On the other hand, both node and edge data by itself fits very well within the tidy concept as each node and edge is, in a sense, a single observation. Thus, a close approximation of tidyness for relational data is two tidy data frames, one describing the node data and one describing the edge data.
+
+Since the `sfnetwork` class inherits the `tbl_graph` class, it shares the same philosophy. However, it extends it into the domain of geospatial data analysis, where each observation has a location in geographical space. For that, it brings `{sf}` into the game. An object of class `sf` stores the geographical coordinates of each observation in standardized format in a geometry list-column, which has a coordinate reference system (CRS) associated with it. Thus, in `{sfnetworks}`, we re-formulate the last sentence of the paragraph above to the following.
+
+> A close approximation of tidyness for relational *geospatial data* is two *sf objects*, one describing the node data and one describing the edge data.
+
+### Structure
+
+Obviously a network is more than just a list of two distinct elements. Nodes and edges are related to each other. The first two columns of the edges table are always named *from* and *to* and contain integer indices of the source and target node of each edge. These integer indices correspond to rownumbers in the nodes table. That is, if nodes are filtered, or they order changes, the indices are updated.
+
+The geometries of the nodes and edges should also match. In `{sfnetworks}`, the following requirements are specified for a valid geospatial network:
+
+- Nodes should have geometries of type `POINT`.
+- Edges should have geometries of type `LINESTRING`.
+- The endpoints of edge geometries should be spatially equal to their corresponding node geometries.
+- Nodes and edge geometries should have the same coordinate reference system and the same coordinate precision.
+
+We do need to make a note here. In a geospatial network, the nodes always have coordinates in geographic space, and thus, can always be described by an sf object. The edges, however, can also be described by only the indices of the nodes at their ends. This still makes them geospatial, because they connect two specific points in space, but the spatial information is not explicitly attached to them. Both representations can be useful. In geolocated social networks, for example, there is often no explicit spatial embedding of edges. In road networks, however, edges are usually not straight lines, and their geometries should be stored explicitly. In `{sfnetworks}` both variants are supported: edges can be described by an sf object with their own geometries, but also by a regular tibble without a geometry column. We refer to them as *spatially explicit edges* and *spatially implicit edges*, respectively. In most of the documentation, however, we focus on the first type.
+
+The figure below summarizes the structure of `sfnetwork` objects.
+
+```{r}
+#| echo: false
+knitr::include_graphics("figures/data-structure.png", error = FALSE)
+```
+
+Just like in `tbl_graph` objects, there is always one element (nodes or edges) in an `sfnetwork` object that is *active*. This means that element is the main target of analysis. By default, nodes are the active element when creating a network. The active element can be changed using the `activate()` verb, which will also change the order in which the elements are printed.
+
+In practice, it all looks as follows. Note that here we create the network from a set of spatial lines, by creating nodes at their endpoints. See the section [Creating sfnetwork objects](#creating-sfnetwork-objects) for many more examples on how to create a spatial network.
+
+```{r}
+# Spatially explicit edges.
+net = as_sfnetwork(roxel)
+net
+```
+
+```{r}
+# Make edges spatially implicit.
+inet = net |>
+ activate(edges) |>
+ st_drop_geometry()
+
+inet
+```
+
+```{r}
+#| layout-ncol: 2
+#| fig-cap:
+#| - "Spatially explicit edges"
+#| - "Spatially implicit edges"
+ggraph(net, "sf") +
+ geom_edge_sf() +
+ geom_node_sf() +
+ theme_void()
+
+ggraph(inet, "sf") +
+ geom_edge_link(linetype = 2) +
+ geom_node_sf() +
+ theme_void()
+```
+
+### Directionality
+
+With `sfnetwork` objects it is possible to represent networks with directed edges, and with undirected edges. It is also possible to mimic mixed representations in which both these edge types exist.
+
+#### Directed networks
+
+By default an instance of the `sfnetwork` class will be initialized as a directed network. That means that each edge can only be traveled in from the source node to the target node, and not vice versa. In this case, the geometry of the edge always matches the direction, with the startpoint of the line being the location of the source node, and the endpoint of the line the location of the target node.
+
+#### Undirected networks
+
+The `sfnetwork` data structure also supports undirected networks. In such networks each edge can be traveled in both directions. Since there is no clear source and target, the node with the lowest index will be referenced in the *from* column, and the node with the highest index in the *to* column. The linestring geometry, however, remains unchanged. That is, in undirected networks the specified source node may not always correspond with the startpoint of the edge geometry, but instead with the endpoint. The behavior of ordering the indices comes from `{igraph}` and might be confusing, but remember that in undirected networks the terms *from* and *to* do not have a meaning and can thus be used interchangeably. If for a computation you really need the edge geometries to match the specified node indices, you can use the utility function `make_edges_follow_indices()`. This function will reverse edge geometries where needed.
+
+```{r}
+as_sfnetwork(roxel, directed = FALSE)
+```
+
+#### Mixed networks
+
+In `{sfnetworks}` there is no native support to represent mixed networks, i.e. networks in which some edges can be traveled in both ways, and others in only one way. However, these type of networks are quite common in some applications of spatial network analysis. For example, road networks in which some streets are oneway streets. By creating a directed network, but duplicating and reversing those edges that should be undirected, you can mimic such a mixed network structure. The morpher `to_spatial_mixed()` does exactly that. Since some of the [network cleaning](https://luukvdmeer.github.io/sfnetworks/articles/sfn03_cleaning.html) functions do not work well with duplicated and reversed edges, it is usually a good idea to first create the directed network, clean it, and only then mimic the mixed representation.
+
+```{r}
+# First we mark some streets as oneway.
+streets = roxel |>
+ mutate(oneway = sample(c(TRUE, FALSE), n(), replace = TRUE, prob = c(0.8, 0.2)))
+
+# Check the distribution of oneway vs twoway streets.
+streets |>
+ st_drop_geometry() |>
+ count(oneway)
+# Create a directed network.
+dnet = as_sfnetwork(streets)
+
+# Check the number of edges in the directed network.
+# This equals the total number of streets.
+n_edges(dnet)
+
+# Convert it into a mixed network.
+# Oneway streets remain directed.
+# Twoway streets are duplicated and reversed.
+mnet = dnet |>
+ convert(to_spatial_mixed, directed = oneway)
+
+# Check number of edges in the mixed network.
+# This equals the number of oneway streets ...
+# ... plus twice the number of twoway streets.
+n_edges(mnet)
+```
+
+### Geometries
+
+What makes `sfnetwork` objects different from `tbl_graph` objects is that nodes and (optionally) edges have geometries. These geometries are stored in a geometry list-column, following the design of `{sf}`. That also means that just as in `{sf}`, these columns are "sticky", and will survive column subsetting operations.
+
+#### Extract geometries
+
+Like in `{sf}`, you can always extract geometries of the active network element using `sf::st_geometry()`. The shortcuts `st_geometry(x, "nodes")` and `st_geometry(net, "edges")` can be used to extract geometries of a network element, regardless if it is active or not.
+
+```{r}
+net |>
+ activate(edges) |>
+ st_geometry()
+```
+
+#### Replace geometries
+
+The other way around, you can also replace geometries using the setter function `sf::set_set_geometry()` (or alternatively: `st_geometry(x) = new`). New node geometries are required to be of geometry type `POINT`, and new edge geometries of geometry type `LINESTRING`. To preserve the valid spatial network structure, the following is done:
+
+- When replacing node geometries, the endpoints of edge geometries will be replaced as well to match the new node geometries.
+- When replacing edge geometries, the endpoints of those geometries are added as new nodes to the network whenever they don't equal their original location. The original nodes remain in the network even if they are now isolated.
+
+```{r}
+#| layout-ncol: 2
+#| fig-cap:
+#| - "Original network"
+#| - "New network"
+orig_net = as_sfnetwork(mozart, "gabriel")
+
+orig_nodes = st_geometry(orig_net, "nodes")
+new_nodes = st_jitter(orig_nodes, 250)
+
+new_net = orig_net |>
+ st_set_geometry(new_nodes)
+
+ggraph(orig_net, "sf") +
+ geom_sf(data = new_nodes, color = "orange") +
+ geom_edge_sf() +
+ geom_node_sf(color = "darkgrey", size = 4) +
+ theme_void()
+
+ggraph(new_net, "sf") +
+ geom_sf(data = orig_nodes, color = "darkgrey") +
+ geom_edge_sf() +
+ geom_node_sf(color = "orange", size = 4) +
+ theme_void()
+```
+
+#### Drop geometries
+
+You can drop geometries using `sf::st_drop_geometry()` (or alternatively: `st_geometry(x) = NULL`). As already shown in the previous section, dropping edge geometries will still return a `sfnetwork` object, but now with spatially implicit instead of spatially explicit edges. Dropping node geometries, however, will return a `tbl_graph`.
+
+```{r}
+net |>
+ activate(nodes) |>
+ st_drop_geometry()
+```
+
+#### Bounding box
+
+The area that geometries occupy is bounded by their bounding box. You can use `sf::st_bbox()` to compute the bounding box of the active element in a `sfnetwork` object. To compute the bounding box of the full network, use `st_network_bbox()`. In some cases, the network bounding box may be different than the bounding box of the nodes only.
+
+```{r}
+#| message: false
+p1 = st_point(c(1, 0))
+p2 = st_point(c(0, 0.5))
+p3 = st_point(c(1, 1))
+p4 = st_point(c(2, 0.5))
+
+nodes = st_sf(geometry = c(st_sfc(p1), st_sfc(p3), st_sfc(p4)))
+edges = st_sf(geometry = st_sfc(st_linestring(c(p1, p2, p3))))
+edges$from = 1
+edges$to = 2
+
+G = sfnetwork(nodes, edges)
+
+node_bbox = G |>
+ st_bbox() |>
+ st_as_sfc()
+
+edge_bbox = G |>
+ activate(edges) |>
+ st_bbox() |>
+ st_as_sfc()
+
+net_bbox = G |>
+ st_network_bbox() |>
+ st_as_sfc()
+```
+
+```{r}
+#| layout-ncol: 2
+#| fig-cap:
+#| - "Element bounding boxes"
+#| - "Network bounding box"
+ggraph(G, "sf") +
+ geom_sf(
+ data = node_bbox,
+ color = "#F8766D", fill = NA,
+ linewidth = 1.5, linetype = 4
+ ) +
+ geom_sf(
+ data = edge_bbox,
+ color = "#619CFF", fill = NA,
+ linewidth = 1.5, linetype = 4
+ ) +
+ geom_edge_sf() +
+ geom_node_sf(size = 4) +
+ theme_void()
+
+ggraph(G, "sf") +
+ geom_sf(
+ data = net_bbox,
+ color = "orange", fill = NA,
+ linewidth = 1.5, linetype = 4
+ ) +
+ geom_edge_sf() +
+ geom_node_sf(size = 4) +
+ theme_void()
+```
+
+#### Coordinate reference system
+
+The coordinates of geometries are always expressed in a specified coordinate reference system (CRS). In a `sfnetwork` object, the CRS of the node and edge geometries is required to be equal. You can extract the CRS using `sf::st_crs()`. To transform coordinates into a different CRS, use `sf::st_transform()`, while specifying for example the EPSG code of the new CRS (other ways of specifying are possible as well, see the documentation of `{sf}`).
+
+```{r}
+st_transform(net, 3035)
+```
+
+#### Coordinate precision
+
+Geometries also have a coordinate precision associated with them. This precision does not round the coordinate values, but will be applied during spatial operations. Just as with the CRS, nodes and edges in a `sfnetwork` object are required to have the same precision. You can extract the precision using `sf::st_precision()`, and set it using `sf::st_set_precision()`. Precision values should be specified as a scale factor. For example, to specify 3 decimal places of precision, use a scale factor of 1000. When no precision is specified, it defaults to machine precision. However, in `{sfnetworks}`, functions that assess the spatial equality of nodes use a default precision of 1e12 (i.e. 12 decimal places) to speed up processing.
+
+```{r}
+# With unspecified precision, no node is equal to another node.
+any(lengths(st_equals(net)) > 1)
+
+# With an extremely low precision, all nodes are equal to each other.
+all(lengths(st_equals(st_set_precision(net, 1))) == n_nodes(net))
+```
+
+#### Attribute-geometry relations
+
+Thanks to `{sf}`, it is also possible to explicitly specify attribute-geometry relations. These define for each attribute column if the attribute is a constant, an aggregate, or an identity. See [here](https://r-spatial.github.io/sf/articles/sf1.html#how-attributes-relate-to-geometries) for more information. You can get and set attribute-geometry relations of the active network element with the function `sf::st_agr()`. For the setter, you can also use the pipe-friendly version `sf::st_set_agr()`. Note that the *to* and *from* columns are not really attributes of edges seen from a network analysis perspective, but they are included in the attribute-geometry relation specification to ensure smooth interaction with `{sf}`.
+
+```{r}
+net |>
+ activate(edges) |>
+ st_agr()
+```
+
+```{r}
+net |>
+ activate(edges) |>
+ st_set_agr(c(type = "aggregate")) |>
+ st_agr()
+```
+
+## Creating sfnetwork objects
+
+There are several ways to create a `sfnetwork` object, which are discussed in more detail below.
+
+### From node and edge tables
+The most basic way to create a `sfnetwork` object is to provide ready-to-use node and edge tables to the `sfnetwork()` construction function. Remember that the nodes should be an `sf` object with `POINT` geometries, while the first two columns in the edges table are required to be named *from* and *to* and contain the rownumbers of the nodes at the start and end of each edge. If the edges are spatially explicit, and hence, also have geometries, these should be of type `LINESTRING` and their endpoints should equal the locations of their source and target nodes. The construction function will check if the provided input meets these criteria. If you are already sure your data forms a valid spatial network, you can set `force = TRUE`.
+
+```{r}
+#| message: false
+p1 = st_point(c(6, 52))
+p2 = st_point(c(8, 53))
+p3 = st_point(c(8, 51))
+
+l1 = st_linestring(c(p1, p2))
+l2 = st_linestring(c(p2, p3))
+l3 = st_linestring(c(p3, p1))
+
+edges = st_sf(geometry = st_sfc(l1, l2, l3), crs = 4326)
+nodes = st_sf(geometry = st_sfc(p1, p2, p3), crs = 4326)
+
+edges$from = c(1, 2, 3)
+edges$to = c(2, 3, 1)
+
+net = sfnetwork(nodes, edges)
+net
+```
+
+It is also possible to provide nodes that are not an `sf` object, but can be converted to it. For example, a table with two coordinate columns. Any additional arguments provided to the `sfnetwork()` construction function will be forwarded to `sf::st_as_sf()` to convert the provided nodes table into a `sf` object.
+
+```{r}
+#| message: false
+nodes_tbl = tibble(x = c(6, 8, 8), y = c(52, 53, 51))
+
+net = sfnetwork(nodes_tbl, edges, coords = c("x", "y"), crs = 4326)
+net
+```
+
+Instead of integers referring to rownumbers in the nodes table, the *from* and *to* columns in the edges table can also contain characters that refer to values in a specific node attribute column. The name of that column should than be given as argument `node_key`. By default, it is assumed the column is named *name*. Internally, the construction function will convert the character values into integer indices referencing rownumbers.
+
+If your edges do not have geometries, you can still create a network with spatially explicit edges by setting `edges_as_lines = TRUE`. This will create linestring geometries as straight lines between the source and target nodes.
+
+```{r}
+#| message: false
+nodes$type = c("city", "village", "farm")
+
+edges = st_drop_geometry(edges)
+edges$from = c("city", "village", "farm")
+edges$to = c("village", "farm", "city")
+
+net = sfnetwork(nodes, edges, node_key = "type", edges_as_lines = TRUE)
+net
+```
+
+```{r}
+ggraph(net, "sf") +
+ geom_edge_sf() +
+ geom_node_sf(aes(color = as.factor(type)), size = 4) +
+ scale_color_discrete("type") +
+ theme_void()
+```
+
+### From spatial lines
+
+A more common way to create a spatial network is to start with only a set of `LINESTRING` geometries in `sf` format. These are assumed to be the edge of the network. Providing this to `as_sfnetwork()` will automatically call `create_from_spatial_lines()`. This function creates nodes at the endpoints of the lines. Endpoints that are shared between multiple lines, become a single node in the network.
+
+To determine spatial equality of endpoints, `{sfnetworks}` by default uses a 12-digit precision for the coordinates. You can change this by either setting a different precision (see [above](#coordinate-precision)) or by effectively rounding coordinate values using the utility function `st_round()`.
+
+```{r}
+# Linestring geometries.
+roxel
+```
+
+```{r}
+# Network.
+net = as_sfnetwork(roxel)
+net
+```
+
+```{r}
+#| layout-ncol: 2
+#| fig-cap:
+#| - "Lines"
+#| - "Network"
+ggplot(roxel) +
+ geom_sf() +
+ theme_void()
+
+ggraph(net, "sf") +
+ geom_edge_sf() +
+ geom_node_sf() +
+ theme_void()
+```
+
+Besides their endpoints, linestring geometries may have interior points that define their shape. It may be that multiple linestrings have interior points at the same location. Since these are not endpoints, they will not become a node in the network. If you also want to add nodes at shared interior points, set `subdivide = TRUE`, or call the `to_spatial_subdivision()` morpher after construction. See the vignette on [Spatial morphers]() for details.
+
+### From spatial points
+
+It is also possible to create a network from a set of `POINT` geometries. These are assumed to be the nodes of the network. Providing this to `as_sfnetwork()` will automatically call `create_from_spatial_points()`. As a second input it requires an adjacency matrix that specifies which nodes should be connected to each other. Adjacency matrices of networks are $n \times n$ matrices with $n$ being the number of nodes, and element $A_{ij}$ holding a \code{TRUE} value if there is an edge from node $i$ to node $j$, and a \code{FALSE} value otherwise.
+
+```{r}
+# Point geometries.
+mozart
+```
+
+```{r}
+# Adjacency matrix.
+adj = matrix(c(rep(TRUE, 17), rep(rep(FALSE, 17), 16)), nrow = 17)
+
+# Network.
+net = as_sfnetwork(mozart, adj)
+net
+```
+
+```{r}
+#| layout-ncol: 2
+#| fig-cap:
+#| - "Points"
+#| - "Network"
+ggplot(mozart) +
+ geom_sf(size = 4) +
+ theme_void()
+
+ggraph(net, "sf") +
+ geom_edge_sf() +
+ geom_node_sf(size = 4) +
+ theme_void()
+```
+
+The adjacency matrix can also be provided in sparse form, where for each node the indices of the nodes it is connected to are listed. This allows to directly forward the output of a [binary spatial predicate](https://r-spatial.github.io/sf/articles/sf3.html#binary-logical-operations). For example, using `sf::st_is_within_distance()`, we can connect nodes that are within a given distance from each other.
+
+```{r}
+adj = st_is_within_distance(mozart, dist = set_units(250, "m"))
+adj
+```
+
+```{r}
+net = as_sfnetwork(mozart, adj)
+```
+
+```{r}
+#| layout-ncol: 2
+#| fig-cap:
+#| - "Points"
+#| - "Network"
+ggplot(mozart) +
+ geom_sf(size = 4) +
+ theme_void()
+
+ggraph(net, "sf") +
+ geom_edge_sf() +
+ geom_node_sf(size = 4) +
+ theme_void()
+```
+
+Finally, `{sfnetworks}` can create the adjacency matrix internally according to some specified method. In that case, you just need to provide the name of the method. Supported options currently are:
+
+- `complete`: All nodes are directly connected to each other.
+- `sequence`: The nodes are sequentially connected to each other, meaning that the first node is connected to the second node, the second node is connected to the third node, etc.
+- `minimum_spanning_tree`: The nodes are connected by their spatial [minimum spanning tree](https://en.wikipedia.org/wiki/Minimum_spanning_tree), i.e. the set of edges with the minimum total edge length required to connect all nodes. Can also be specified as `mst`.
+- `delaunay`: The nodes are connected by their [Delaunay triangulation](https://en.wikipedia.org/wiki/Delaunay_triangulation). Requires the `{spdep}` package to be installed, and assumes planar coordinates.
+- `gabriel`: The nodes are connected as a [Gabriel graph](https://en.wikipedia.org/wiki/Gabriel_graph). Requires the `{spdep}` package to be installed, and assumes planar coordinates.
+- `relative_neighborhood`: The nodes are connected as a [relative neighborhood graph](https://en.wikipedia.org/wiki/Relative_neighborhood_graph). Can also be specified as `rn`. Requires the `{spdep}` package to be installed, and assumes planar coordinates.
+- `nearest_neighbors`: Each node is connected to its $k$ nearest neighbors, with $k$ being specified through the `k` argument. By default, `k = 1`, meaning that the nodes are connected as a [nearest neighbor graph](https://en.wikipedia.org/wiki/Nearest_neighbor_graph). Can also be specified as `knn`. Requires the `{spdep}` package to be installed.
+
+```{r}
+#| layout-ncol: 2
+#| layout-nrow: 4
+#| fig-cap:
+#| - "Complete"
+#| - "Sequential"
+#| - "Minimum spanning tree"
+#| - "Delaunay triangulation"
+#| - "Gabriel"
+#| - "Relative neighbors"
+#| - "Nearest neighbors"
+#| - "K nearest neighbors (k = 3)"
+make_ggraph = function(x) {
+ ggraph(x, "sf") +
+ geom_edge_sf() +
+ geom_node_sf(size = 4) +
+ theme_void()
+}
+
+make_ggraph(as_sfnetwork(mozart, "complete"))
+make_ggraph(as_sfnetwork(mozart, "sequence"))
+make_ggraph(as_sfnetwork(mozart, "mst"))
+make_ggraph(as_sfnetwork(mozart, "delaunay"))
+make_ggraph(as_sfnetwork(mozart, "gabriel"))
+make_ggraph(as_sfnetwork(mozart, "rn"))
+make_ggraph(as_sfnetwork(mozart, "knn"))
+make_ggraph(as_sfnetwork(mozart, "knn", k = 3))
+```
+
+### From other network representations
+
+The conversion function `as_sfnetwork()` can also be used to convert instances of other network classes to a `sfnetwork` object. This includes classes that are also designed for spatial networks, such as `dodgr_streetnet` from the `{dodgr}` package for routing on street networks, and `linnet` from the `{spatstat.linnet}` package for statistical point pattern analysis on spatial linear networks. However, it can also be used to convert instances of non-spatial network formats, as long as they do specify in some way a spatial location for the nodes. For example, an `igraph` object with x and y coordinates stored as node attributes. In such a case, any additional arguments provided to `as_sfnetwork()` will be forwarded to `sf::st_as_sf()` to convert the nodes of the given network into a `sf` object.
+
+```{r}
+# igraph object.
+inet = igraph::sample_grg(5, 0.5, coords = TRUE)
+inet
+```
+
+```{r}
+#| message: false
+# sfnetwork object.
+net = as_sfnetwork(inet, coords = c("x", "y"))
+net
+```
+
+### From files
+
+There are currently no functions in `{sfnetworks}` for reading and writing data (there are some [ideas](https://github.com/luukvdmeer/sfnetworks/discussions/264)). However, you can use `sf::st_read()` for spatial file formats to read in points and/or lines, and then construct a network using `sfnetwork()` (as described [here](#from-node-and-edge-tables)) or `as_sfnetwork()` (as described [here](#from-spatial-lines) for lines and [here](#from-spatial-points) for points). For network specific file types, you can use `igraph::read_graph()` to read the data into R, and then convert it to spatial network format using `as_sfnetwork()` as long as the required spatial information is present (see [here](#from-other-network-representations)).
+
+A common format of the latter category is GraphML. An example of such a file can be found [here](https://github.com/ComplexNetTSP/Power_grids), containing the power grid of The Netherlands. After reading it using `{igraph}`, we will first convert it to a `tbl_graph` such that we can easily explore the data.
+
+```{r}
+url = "https://raw.githubusercontent.com/ComplexNetTSP/Power_grids/v1.0.0/Countries/Netherlands/graphml/Netherlands_highvoltage.graphml"
+```
+
+```{r}
+igraph::read_graph(url, format = "graphml") |>
+ as_tbl_graph()
+```
+
+We can see that the spatial geometries of nodes and edges are stored as WKT strings in columns named *wktsrid4326*. Remember that additional arguments to `as_sfnetwork()` for `igraph` objects are forwarded to `sf::st_as_sf()` to convert the nodes into a `sf` object. This makes conversion into a `sfnetwork` object as easy as:
+
+```{r}
+#| message: false
+net = igraph::read_graph(url, format = "graphml") |>
+ as_sfnetwork(wkt = "wktsrid4326", crs = 4326) |>
+ rename(geometry = wktsrid4326)
+
+net
+```
+
+However, this did only affect the nodes table (since that one is *required* to have geometries). The edges do not have explicit geometries yet. Using the morpher function `to_spatial_explicit()`, we can "explicitize" the edge geometries of the constructed network. Also here, additional arguments are forwarded to `sf::st_as_sf()`.
+
+```{r}
+net = net |>
+ convert(to_spatial_explicit, wkt = "wktsrid4326", crs = 4326, .clean = TRUE) |>
+ activate(edges) |>
+ rename(geometry = wktsrid4326)
+
+net
+```
+
+```{r}
+ggraph(net, "sf") +
+ geom_edge_sf() +
+ geom_node_sf(size = 4) +
+ theme_void()
+```
+
+### From OpenStreetMap data
+
+A common source for spatial network data is [OpenStreetMap](https://www.openstreetmap.org).This is an open and collaborative geographic database. It can be used for example to extract geometries of streets or rivers anywhere in the world. In R, there are two main packages that allow to read OpenStreetMap data:
+
+- The `{osmdata}` package provides an interface to the Overpass API of OpenStreetMap.
+- The `{osmextract}` package can read OpenStreetMap data from `osm.pbf` files.
+
+For small areas and few repeated calls, the Overpass API is the easiest way to get the data. However, if your area of interest is large, or you want to load the data many times, it is preferred to not overload the API and read from `osm.pbf` files instead. [Geofabrik](https://download.geofabrik.de/) is one of the platforms where you can download such files for many different regions in the world.
+
+Here, we will show a small example using `{osmdata}`. We can read in the street centerlines of Anif, a small village in Austria, as shown below. For details on this workflow, check the [osmdata documentation](https://docs.ropensci.org/osmdata/articles/osmdata.html).
+
+```{r}
+library(osmdata)
+
+# Call the Overpass API to extract streets in Anif.
+data = opq("Anif, Austria") |>
+ add_osm_feature(key = "highway") |>
+ osmdata_sf() |>
+ osm_poly2line()
+
+# Extract only the linestring geometries from the response.
+streets = data$osm_lines |>
+ select(name, "type" = highway, surface)
+
+streets
+```
+
+Now, we can simply create a network out of these lines using `as_sfnetwork()`, as shown [before](#from-spatial-lines).
+
+```{r}
+net = as_sfnetwork(streets, directed = FALSE)
+net
+```
+
+However, we need to be aware that OpenStreetMap data is not created primarily with a network structure in mind. This means that locations where two streets connect are not always the endpoints of the linestring geometries, but they can be an interior point of such a geometry as well. By setting `subdivide = TRUE` linestring geometries will be subdivided at places where an interior point is shared between multiple features. In that way, a node will be placed at such locations.
+
+```{r}
+#| warning: false
+# The create network without subdivision has many disconnected components.
+with_graph(net, graph_component_count())
+
+# Creating the network with subdivsion reduces this number drastically.
+net = as_sfnetwork(streets, subdivide = TRUE)
+with_graph(net, graph_component_count())
+```
+
+```{r}
+ggraph(net, "sf") +
+ geom_edge_sf() +
+ geom_node_sf() +
+ theme_void()
+```
+
+### Random networks
+
+The function `play_geometric()` creates a [random geometric graph](https://en.wikipedia.org/wiki/Random_geometric_graph). This randomly samples $n$ nodes, and connects them by an edge if they are within a given distance threshold from each other. By default, sampling will take place on the unit square. However, through the `bounds` argument you can also provide any spatial feature to sample on. This will use `sf::st_sample()` internally.
+
+```{r}
+# Sample on a unit square.
+neta = play_geometric(10, 0.3)
+
+# Sample on a spatial feature.
+netb = play_geometric(20, set_units(250, "m"), bounds = st_bbox(mozart))
+```
+
+```{r}
+#| layout-ncol: 2
+#| fig-cap:
+#| - "Random network A"
+#| - "Random network B"
+ggraph(neta, "sf") +
+ geom_edge_sf() +
+ geom_node_sf(size = 4) +
+ theme_void()
+
+ggraph(netb, "sf") +
+ geom_edge_sf() +
+ geom_node_sf(size = 4) +
+ theme_void()
+```
+
+## Validating sfnetwork objects
+
+As described in the [beginning](#structure), there are several requirements for a `sfnetwork` object to be considered a valid spatial network. The `validate_network()` utility function checks if these requirements are met.
+
+```{r}
+validate_network(net)
+```
+
+These checks are executed already during construction. Trying to construct an invalid network will result in an error:
+
+```{r}
+#| error: true
+p1 = st_point(c(6, 52))
+p2 = st_point(c(8, 53))
+p3 = st_point(c(8, 51))
+p4 = st_point(c(7, 52.5))
+p5 = st_point(c(7, 52))
+
+l1 = st_linestring(c(p2, p4))
+l2 = st_linestring(c(p2, p5))
+
+edges = st_sf(geometry = st_sfc(l1, l2), crs = 4326)
+nodes = st_sf(geometry = st_sfc(p1, p2, p3), crs = 4326)
+
+edges$from = c(2, 2)
+edges$to = c(1, 3)
+
+net = sfnetwork(nodes, edges)
+```
+
+However, you could work around this by setting `force = TRUE`. This will skip the checks, and create the `sfnetwork` object even if its structure is not valid. Be aware that functions in `{sfnetworks}` are designed under the assumption that the analyzed network is valid.
+
+```{r}
+net = sfnetwork(nodes, edges, force = TRUE)
+```
+
+If your issue is that node and edge geometries do not match (i.e. the endpoints of the edges are not equal to the nodes that are supposed to be their source and target), there is the utility function `make_edges_valid()` that can fix this in two different ways:
+
+1) By default, it will replace each invalid endpoint in an edge geometry with the point geometry of the node that is referenced in the *from* or *to* column.
+2) If you set `preserve_geometries = TRUE`, the edge geometries remain unchanged. Invalid endpoints are added as new nodes to the network, and the *from* and *to* columns are updated accordingly.
+
+```{r}
+neta = make_edges_valid(net)
+netb = make_edges_valid(net, preserve_geometries = TRUE)
+```
+
+```{r}
+#| layout-ncol: 3
+#| fig-cap:
+#| - "Invalid network"
+#| - "Valid network A"
+#| - "Valid network B"
+ggraph(net, "sf") +
+ geom_edge_sf() +
+ geom_node_sf(size = 4) +
+ theme_void()
+
+ggraph(neta, "sf") +
+ geom_edge_sf() +
+ geom_node_sf(size = 4) +
+ theme_void()
+
+ggraph(netb, "sf") +
+ geom_edge_sf() +
+ geom_node_sf(size = 4) +
+ theme_void()
+```
+
+```{r}
+#| include: false
+par(oldpar)
+options(oldoptions)
+```
diff --git a/vignettes/sfn02_preprocess_clean.Rmd b/vignettes/sfn02_preprocess_clean.Rmd
deleted file mode 100644
index ea14920f..00000000
--- a/vignettes/sfn02_preprocess_clean.Rmd
+++ /dev/null
@@ -1,469 +0,0 @@
----
-title: "2. Network pre-processing and cleaning"
-date: "`r Sys.Date()`"
-output: rmarkdown::html_vignette
-vignette: >
- %\VignetteIndexEntry{2. Network pre-processing and cleaning}
- %\VignetteEngine{knitr::rmarkdown}
- %\VignetteEncoding{UTF-8}
----
-
-```{r setup, include=FALSE}
-knitr::opts_chunk$set(
- collapse = TRUE,
- comment = "#>"
-)
-knitr::opts_knit$set(global.par = TRUE)
-current_geos = numeric_version(sf::sf_extSoftVersion()["GEOS"])
-required_geos = numeric_version("3.7.0")
-geos37 = current_geos >= required_geos
-```
-
-```{r plot, echo=FALSE, results='asis'}
-# plot margins
-oldpar = par(no.readonly = TRUE)
-par(mar = c(1, 1, 1, 1))
-# crayon needs to be explicitly activated in Rmd
-oldoptions = options()
-options(crayon.enabled = TRUE)
-# Hooks needs to be set to deal with outputs
-# thanks to fansi logic
-old_hooks = fansi::set_knit_hooks(
- knitr::knit_hooks,
- which = c("output", "message", "error")
-)
-```
-
-Unfortunately real-world datasets are not always as friendly as those used in tutorials. Pre-processing of the data will often be needed, as well as cleaning the network after construction. This vignette presents some examples that may be of use when going through this phase.
-
-```{r, message=FALSE}
-library(sfnetworks)
-library(sf)
-library(tidygraph)
-library(igraph)
-library(dbscan)
-```
-
-## Common pre-processing tasks
-
-### Rounding coordinates
-You might have a set of lines in which some endpoints are *almost* shared between two lines. However, the coordinates are stored with so much precision that there is a minor difference between the two points. When constructing a sfnetwork these lines will *not* be connected because the points are not *exactly* equal.
-
-We can pre-process the lines by reducing the precision of the coordinates such that the points become *exactly* equal. A tiny example:
-
-```{r}
-p1 = st_point(c(7, 51))
-p2 = st_point(c(8, 52))
-p3 = st_point(c(8.000001, 52.000001))
-p4 = st_point(c(7, 52))
-
-l1 = st_sfc(st_linestring(c(p1, p2)))
-l2 = st_sfc(st_linestring(c(p3, p4)))
-
-edges = st_as_sf(c(l1, l2), crs = 4326)
-
-# The edges are not connected.
-as_sfnetwork(edges)
-```
-
-```{r}
-# Round coordinates to 0 digits.
-st_geometry(edges) = st_geometry(edges) %>%
- lapply(function(x) round(x, 0)) %>%
- st_sfc(crs = st_crs(edges))
-
-# The edges are connected.
-as_sfnetwork(edges)
-```
-
-Note that too much rounding can create linestring geometries of zero length, with the same start and endpoint. This may cause problems later.
-
-### Dealing with one-way edges
-In `sfnetworks` you can create directed and undirected networks. In directed ones, an edge can only be traveled from its start node to its end node. In undirected ones, an edge can be traveled both ways. However, in the real world there are networks where *some* edges can be traveled both ways and *some* edges only one way. For example, a road network in a city where there are one-way streets.
-
-Unfortunately, neither `igraph` nor `tidygraph` provides an interface for such networks. Therefore, the only way to deal with this is to create a directed network but first *duplicate* and *reverse* all edge linestrings that can be traveled both ways.
-
-See the small example below, where we have three lines with one-way information stored in a *oneway* column. One of the lines is a one-way street, the other two are not. By duplicating and reversing the two linestrings that are not one-way streets, we create a directed network that correctly models our situation. Note that reversing linestrings using `sf::st_reverse()` only works when sf links to a GEOS version of at least 3.7.0.
-
-```{r, eval = geos37}
-p1 = st_point(c(7, 51))
-p2 = st_point(c(7, 52))
-p3 = st_point(c(8, 52))
-
-l1 = st_sfc(st_linestring(c(p1, p2)))
-l2 = st_sfc(st_linestring(c(p1, p3)))
-l3 = st_sfc(st_linestring(c(p3, p2)))
-
-edges = st_as_sf(c(l1, l2, l3), crs = 4326)
-edges$oneway = c(TRUE, FALSE, FALSE)
-edges
-
-duplicates = edges[!edges$oneway, ]
-reversed_duplicates = st_reverse(duplicates)
-
-edges = rbind(edges, reversed_duplicates)
-net = as_sfnetwork(edges)
-activate(net, "edges")
-```
-
-## Network cleaning functions
-
-The `sfnetworks` package contains a set of spatial network specific cleaning functions. They are implemented as *spatial morpher functions*. To learn more about spatial morphers and what they are, see the [dedicated vignette](https://luukvdmeer.github.io/sfnetworks/articles/sfn05_morphers.html) for that. For now, it is sufficient to know that you can use any spatial morpher function inside the `tidygraph::convert()` verb to convert your network into a different state.
-
-Before presenting the cleaning functions that are currently implemented, lets create a network to be cleaned.
-
-```{r, fig.height=5, fig.width=5}
-p1 = st_point(c(0, 1))
-p2 = st_point(c(1, 1))
-p3 = st_point(c(2, 1))
-p4 = st_point(c(3, 1))
-p5 = st_point(c(4, 1))
-p6 = st_point(c(3, 2))
-p7 = st_point(c(3, 0))
-p8 = st_point(c(4, 3))
-p9 = st_point(c(4, 2))
-p10 = st_point(c(4, 0))
-p11 = st_point(c(5, 2))
-p12 = st_point(c(5, 0))
-p13 = st_point(c(5, -1))
-p14 = st_point(c(5.8, 1))
-p15 = st_point(c(6, 1.2))
-p16 = st_point(c(6.2, 1))
-p17 = st_point(c(6, 0.8))
-p18 = st_point(c(6, 2))
-p19 = st_point(c(6, -1))
-p20 = st_point(c(7, 1))
-
-l1 = st_sfc(st_linestring(c(p1, p2, p3)))
-l2 = st_sfc(st_linestring(c(p3, p4, p5)))
-l3 = st_sfc(st_linestring(c(p6, p4, p7)))
-l4 = st_sfc(st_linestring(c(p8, p11, p9)))
-l5 = st_sfc(st_linestring(c(p9, p5, p10)))
-l6 = st_sfc(st_linestring(c(p8, p9)))
-l7 = st_sfc(st_linestring(c(p10, p12, p13, p10)))
-l8 = st_sfc(st_linestring(c(p5, p14)))
-l9 = st_sfc(st_linestring(c(p15, p14)))
-l10 = st_sfc(st_linestring(c(p16, p15)))
-l11 = st_sfc(st_linestring(c(p14, p17)))
-l12 = st_sfc(st_linestring(c(p17, p16)))
-l13 = st_sfc(st_linestring(c(p15, p18)))
-l14 = st_sfc(st_linestring(c(p17, p19)))
-l15 = st_sfc(st_linestring(c(p16, p20)))
-
-lines = c(l1, l2, l3, l4, l5, l6, l7, l8, l9, l10, l11, l12, l13, l14, l15)
-
-edge_colors = function(x) rep(sf.colors(12, categorical = TRUE)[-2], 2)[c(1:ecount(x))]
-
-net = as_sfnetwork(lines)
-plot(st_geometry(net, "edges"), col = edge_colors(net), lwd = 4)
-plot(st_geometry(net, "nodes"), pch = 20, cex = 2, add = TRUE)
-```
-
-### Simplify network
-A network may contain sets of edges that connect the same pair of nodes. Such edges can be called *multiple edges*. Also, it may contain an edge that starts and ends at the same node. Such an edge can be called a *loop edge*.
-
-In graph theory, a *simple graph* is defined as a graph that does *not* contain multiple edges nor loop edges. To obtain a simple version of our network, we can remove multiple edges and loop edges by calling tidygraphs edge filter functions `tidygraph::edge_is_multiple()` and `tidygraph::edge_is_loop()`.
-
-```{r, fig.show='hold', out.width = '50%'}
-simple = net %>%
- activate("edges") %>%
- filter(!edge_is_multiple()) %>%
- filter(!edge_is_loop())
-
-plot(st_geometry(net, "edges"), col = edge_colors(net), lwd = 4)
-plot(st_geometry(net, "nodes"), pch = 20, cex = 1.5, add = TRUE)
-plot(st_geometry(simple, "edges"), col = edge_colors(simple), lwd = 4)
-plot(st_geometry(simple, "nodes"), pch = 20, cex = 1.5, add = TRUE)
-```
-
-Note that removing multiple edges in that way always *keeps* the *first* edge in each set of multiple edges, and drops all the other members of the set. Hence, the resulting network does not contain multiple edges anymore, but the connections between the nodes are preserved. Which of the multiple edges is the first one in a set depends on the order of the edges in the edges table. That is, by re-arranging the edges table before applying the filter you can influence which edges are kept whenever sets of multiple edges are detected. For example, you might want to always keep the edge with the shortest distance in the set.
-
-```{r, fig.show='hold', out.width = '50%'}
-simple = net %>%
- activate("edges") %>%
- arrange(edge_length()) %>%
- filter(!edge_is_multiple()) %>%
- filter(!edge_is_loop())
-
-plot(st_geometry(net, "edges"), col = edge_colors(net), lwd = 4)
-plot(st_geometry(net, "nodes"), pch = 20, cex = 1.5, add = TRUE)
-plot(st_geometry(simple, "edges"), col = edge_colors(simple), lwd = 4)
-plot(st_geometry(simple, "nodes"), pch = 20, cex = 1.5, add = TRUE)
-```
-
-By arranging the edges table first, you can influence *which* edge in a set of multiple edges is kept. However, this way of simplifying still selects a single edge from the set, and drops all the others. When your edges have attributes, you might prefer to *merge* the edges in a set into a single new edge. This new edge has the geometry of the first edge in the set, but its attribute values are a combination of the attributes of all the edges in the set. This is exactly what the function `to_spatial_simple()` does. It has an argument `summarise_attributes` that lets you specify exactly how you want to merge the attributes of each set of multiple edges.
-
-How attributes should be combined is of course dependent on the type and purpose of each attribute. Therefore, the combination technique can be specified on a per-attribute basis. There are two ways to specify the combination technique for an attribute:
-
-- As a character, referring to the name of a pre-defined combination technique in `igraph`. Examples include `mean`, `sum`, `first` and `last`. See [here](https://igraph.org/r/doc/igraph-attribute-combination.html) for an overview of all implemented techniques.
-- As a function, taking a vector of attribute values as input and returning a single value. This is helpful when you want to combine attributes in a way that is not pre-defined in `igraph`.
-
-Providing a single character or a single function (e.g. `summarise_attributes = "sum"`) will apply the same technique to each attribute. Instead, you can provide a named list with a different technique for each attribute. This list can also include one unnamed element containing the technique that should be applied to all attributes that were not referenced in any of the other elements. Note that the geometry-list column, the tidygraph index column, as well as the *from* and *to* columns are not attributes!
-
-The analogue of this in tidyverse terms is a `dplyr::group_by()` operation followed by a `dplyr::summarise()` call. The groups in this case are defined by the start and end node indices of the edges. Edges that connect the same pair of nodes (i.e. multiple edges) are in the same group. Then, attributes are summarised into a single value for each group separately.
-
-Enough theory! Lets look at a practical example instead. We will first add some attribute columns to the edges, and then specify different combination techniques when simplifying the network.
-
-```{r, fig.show='hold', out.width = '50%'}
-# Add some attribute columns to the edges table.
-flows = sample(1:10, ecount(net), replace = TRUE)
-types = c(rep("path", 8), rep("road", 7))
-foo = sample(c(1:ecount(net)), ecount(net))
-bar = sample(letters, ecount(net))
-
-net = net %>%
- activate("edges") %>%
- arrange(edge_length()) %>%
- mutate(flow = flows, type = types, foo = foo, bar = bar)
-
-net
-# We know from before that our example network has one set of multiple edges.
-# Lets look at them.
-net %>%
- activate("edges") %>%
- filter(edge_is_between(6, 7)) %>%
- st_as_sf()
-# Define how we want to combine the attributes.
-# For the flows:
-# --> It makes sense to sum them when edges get merged.
-# For the type:
-# --> Preserve the type only if all edges in a set have the same type.
-# For all other attributes:
-# --> Drop these attributes.
-combinations = list(
- flow = "sum",
- type = function(x) if (length(unique(x)) == 1) x[1] else "unknown",
- "ignore"
-)
-
-# Simplify the network with to_spatial_simple.
-simple = convert(net, to_spatial_simple, summarise_attributes = combinations)
-
-# Inspect our merged set of multiple edges.
-simple %>%
- activate("edges") %>%
- filter(edge_is_between(6, 7)) %>%
- st_as_sf()
-plot(st_geometry(net, "edges"), col = edge_colors(net), lwd = 4)
-plot(st_geometry(net, "nodes"), pch = 20, cex = 1.5, add = TRUE)
-plot(st_geometry(simple, "edges"), col = edge_colors(simple), lwd = 4)
-plot(st_geometry(simple, "nodes"), pch = 20, cex = 1.5, add = TRUE)
-```
-
-When the edges table does not have attribute columns, using `to_spatial_simple` does not have advantages and applying the filter functions as shown before is faster.
-
-### Subdivide edges
-When constructing a sfnetwork from a set of sf linestrings, the *endpoints* of those linestrings become nodes in the network. If endpoints are shared between multiple lines, they become a single node, and the edges are connected. However, a linestring geometry can also contain *interior points* that define the shape of the line, but are not its endpoints. It can happen that such an interior point in one edge is *exactly* equal to either an interior point or endpoint of another edge. In the network structure, however, these two edges are not connected, because they don't share *endpoints*. If this is unwanted, we need to split these two edges at their shared point and connect them accordingly.
-
-In graph theory terms the process of splitting and edge is called *subdivision*: the subdivision of an edge $o = \{a, b\}$ (i.e. an edge from node $a$ to node $b$) is the addition of a new node $c$ and the replacement of $o$ by two new edges $p = \{a, c\}$ and $q = \{c, b\}$.
-
-The function `to_spatial_subdivision()` subdivides edges at interior points whenever these interior points are equal to one or more interior points or endpoints of other edges, and recalculates network connectivity afterwards.
-
-To illustrate the workflow, lets consider a situation where an interior point $p_{x}$ in edge $x$ is shared with point $p_{y}$ in edge $y$. That gives two possible situations:
-
-- $p_{y}$ is an interior point of $y$. Since $p_{x}$ and $p_{y}$ are both interior points, neither of them is already a node in the network. Then:
- - $x$ is subdivided at $p_{x}$ into two new edges $x_{1}$ and $x_{2}$ and new node $p_{x}$.
- - $y$ is subdivided at $p_{y}$ into two new edges $y_{1}$ and $y_{2}$ and new node $p_{y}$.
- - The new nodes $p_{x}$ and $p_{y}$ are merged into a single node $p$ with the edge set $\{x_{1}, x_{2}, y_{1}, y_{2}\}$ as incidents.
-- $p_{y}$ is and endpoint of $y$. Since $p_{y}$ is an edge endpoint, it is already a node in the network. Then:
- - $x$ is subdivided at $p_{x}$ into two new edges $x_{1}$ and $x_{2}$ and new node $p_{x}$.
- - The new node $p_{x}$ is merged with node $p_{y}$ into a single node $p$ with the edge set $\{y, x_{1}, x_{2}\}$ as incidents.
-
-Note that an edge is *not* subdivided when it crosses another edge at a location that is not an interior point or endpoint in the linestring geometry of any of the two edges.
-
-For our example network, this means:
-
-```{r, fig.show='hold', out.width = '50%'}
-subdivision = convert(simple, to_spatial_subdivision)
-
-plot(st_geometry(simple, "edges"), col = edge_colors(simple), lwd = 4)
-plot(st_geometry(simple, "nodes"), pch = 20, cex = 1.5, add = TRUE)
-plot(st_geometry(subdivision, "edges"), col = edge_colors(subdivision), lwd = 4)
-plot(st_geometry(subdivision, "nodes"), pch = 20, cex = 1.5, add = TRUE)
-```
-
-### Smooth pseudo nodes
-A network may contain nodes that have only one incoming and one outgoing edge. For tasks like calculating shortest paths, such nodes are redundant, because they don't represent a point where different directions can possibly be taken. Sometimes, these type of nodes are referred to as *pseudo nodes*. Note that their equivalent in undirected networks is any node with only two incident edges, since *incoming* and *outgoing* does not have a meaning there. To reduce complexity of subsequent operations, we might want to get rid of these pseudo nodes.
-
-In graph theory terms this process is the opposite of subdivision and also called *smoothing*: smoothing a node $b$ with incident edges $o = \{a, b\}$ and $p = \{b, c\}$ removes $b$, $o$ and $p$ and creates the new edge $q = \{a, c\}$.
-
-The function `to_spatial_smooth()` iteratively smooths pseudo nodes, and after each removal concatenates the linestring geometries of the two affected edges together into a new, single linestring geometry.
-
-```{r, message=FALSE, fig.show='hold', out.width = '50%'}
-smoothed = convert(subdivision, to_spatial_smooth)
-
-plot(st_geometry(subdivision, "edges"), col = edge_colors(subdivision), lwd = 4)
-plot(st_geometry(subdivision, "nodes"), pch = 20, cex = 1.5, add = TRUE)
-plot(st_geometry(smoothed, "edges"), col = edge_colors(smoothed), lwd = 4)
-plot(st_geometry(smoothed, "nodes"), pch = 20, cex = 1.5, add = TRUE)
-```
-There are different ways in which the smoothing process can be tuned. Firstly, it is possible to specify how attributes of concatenated edges should be summarized, just as with `to_spatial_simple()`. That is, you can specify a single summarise technique to be applied to all attributes (e.g. `summarise_attributes = "sum"`), a named list with a different summarise technique per attribute (e.g. `summarise_attributes = list(foo = "sum", bar = "mean")`), or a named list including an unnamed default technique for those attributes that where not mentioned (e.g. `summarise_attributes = list(foo = "sum", "mean")`). The techniques can be chosen from a set of pre-defined functions, but can also be a custom function provided by you. See the [igraph documentation](https://igraph.org/r/doc/igraph-attribute-combination.html) for details. Note that the geometry-list column, the tidygraph index column, as well as the *from* and *to* columns are not attributes!
-
-```{r}
-# We know from before that our example network has two pseudo nodes.
-# Lets look at the attributes of their incident edges.
-subdivision %>%
- activate("edges") %>%
- filter(edge_is_incident(2) | edge_is_incident(9)) %>%
- st_as_sf()
-
-# Define how we want to combine the attributes.
-# For the flows:
-# --> It makes sense to sum them when edges get merged.
-# For the type:
-# --> Preserve the type only if all edges in a set have the same type.
-combinations = list(
- flow = "sum",
- type = function(x) if (length(unique(x)) == 1) x[1] else "unknown",
- "ignore"
-)
-
-# Apply the morpher.
-other_smoothed = convert(subdivision, to_spatial_smooth, summarise_attributes = combinations)
-
-# Inspect our concatenated edges.
-other_smoothed %>%
- activate("edges") %>%
- filter(edge_is_between(1, 2) | edge_is_between(7, 3)) %>%
- st_as_sf()
-```
-Secondly, it is possible to only remove those pseudo nodes for which attributes among their incident edges are equal. To do this, set `require_equal = TRUE`. Optionally, you can provide a list of attribute names instead, such that only those attributes are checked for equality, instead of all attributes. Again, remember that the geometry-list column, the tidygraph index column, as well as the *from* and *to* columns are not considered attributes in this case.
-
-In our example, our first pseudo node has incident edges of the same type ("road"), while the second pseudo node has incident edges of differing types ("road" and "unknown"). If we require the type attribute to be equal, the second pseudo node will not be removed.
-
-```{r, message=FALSE, fig.show='hold', out.width = '50%'}
-other_smoothed = convert(subdivision, to_spatial_smooth, require_equal = "type")
-
-plot(st_geometry(subdivision, "edges"), col = edge_colors(subdivision), lwd = 4)
-plot(st_geometry(subdivision, "nodes"), pch = 20, cex = 1.5, add = TRUE)
-plot(st_geometry(other_smoothed, "edges"), col = edge_colors(smoothed), lwd = 4)
-plot(st_geometry(other_smoothed, "nodes"), pch = 20, cex = 1.5, add = TRUE)
-```
-Thirdly, it is also possible to directly specify a set of nodes that should never be removed, even if they are a pseudo node. This can be done by either providing a vector of node indices or a set of geospatial points to the `protect` argument. In the latter case, the function will protect the nearest node to each of these points. This can be helpful when you already know you want to use this nodes at a later stage for routing purposes. For example:
-
-```{r, message=FALSE, fig.show='hold', out.width = '50%'}
-other_smoothed = convert(subdivision, to_spatial_smooth, protect = 2)
-
-plot(st_geometry(subdivision, "edges"), col = edge_colors(subdivision), lwd = 4)
-plot(st_geometry(subdivision, "nodes"), pch = 20, cex = 1.5, add = TRUE)
-plot(st_geometry(other_smoothed, "edges"), col = edge_colors(smoothed), lwd = 4)
-plot(st_geometry(other_smoothed, "nodes"), pch = 20, cex = 1.5, add = TRUE)
-```
-
-### Simplify intersections
-
-Especially in road networks you may find that intersections between edges are not modelled by a single node. Instead, each leg of the intersection has a dedicated edge. To simplify the topology of your network, you might want to reduce such complex intersection structures into a single node. Hence, we want to reduce a group of nodes into a single node, while maintaining the connectivity of the network.
-
-In graph theory terms this process is called *contraction*: the contraction of a set of nodes $P = \{p_{1}, p_{2}, ..., p_{n}\}$ is the replacement of $S$ and all its incident edges by a *single* node $p^{*}$ and a set of edges that connect $p^{*}$ to all nodes that were adjacent to any node $p_{i} \in P$.
-
-The function `to_spatial_contracted()` contracts groups of nodes based on a given grouping variable. The geometry of each contracted node is the *centroid* of the original group members' geometries. Moreover, the geometries of the edges that start or end at a contracted node are updated such that their boundaries match the new node geometries.
-
-Grouping variables are internally forwarded to `dplyr::group_by()`. That means you can group the nodes based on any (combination of) attribute(s). However, in this case, we want to group the nodes spatially, such that nodes that are very close to each other in space will form a group and get contracted. To do so, we can use any spatial clustering algorithm. In this example, we apply the well-known DBSCAN algorithm as implemented in the R package `dbscan`.
-
-```{r}
-# Retrieve the coordinates of the nodes.
-node_coords = smoothed %>%
- activate("nodes") %>%
- st_coordinates()
-
-# Cluster the nodes with the DBSCAN spatial clustering algorithm.
-# We set eps = 0.5 such that:
-# Nodes within a distance of 0.5 from each other will be in the same cluster.
-# We set minPts = 1 such that:
-# A node is assigned a cluster even if it is the only member of that cluster.
-clusters = dbscan(node_coords, eps = 0.5, minPts = 1)$cluster
-
-# Add the cluster information to the nodes of the network.
-clustered = smoothed %>%
- activate("nodes") %>%
- mutate(cls = clusters)
-```
-
-Now we have assigned each node to a spatial cluster. However, we forgot one important point. When simplifying intersections, it is not only important that the contracted nodes are close to each other in space. They should also be *connected*. Two nodes that are close to each other but *not* connected, can never be part of the same intersection. Hence, a group of nodes to be contracted should in this case be located in the same *component* of the network. We can use `tidygraph::group_components()` to assign a component index to each node. Note that in our example network this is not so much of use, since the whole network forms a single connected component. But for the sake of completeness, we will still show it:
-
-```{r}
-clustered = clustered %>%
- mutate(cmp = group_components())
-
-select(clustered, cls, cmp)
-```
-
-The combination of the cluster index and the component index can now be used to define the groups of nodes to be contracted. Nodes that form a group on their own will remain unchanged.
-
-A point of attention is that contraction introduces new *multiple edges* and/or *loop edges*. Multiple edges are introduced by contraction when there are several connections between the same groups of nodes. Loop edges are introduced by contraction when there are connections within a group. Setting `simplify = TRUE` will remove the multiple and loop edges after contraction. However, note that this also removes multiple and loop edges that already existed before contraction.
-
-```{r, fig.show='hold', out.width = '50%'}
-contracted = convert(
- clustered,
- to_spatial_contracted,
- cls, cmp,
- simplify = TRUE
-)
-
-plot(st_geometry(smoothed, "edges"), col = edge_colors(smoothed), lwd = 4)
-plot(st_geometry(smoothed, "nodes"), pch = 20, cex = 1.5, add = TRUE)
-plot(st_geometry(contracted, "edges"), col = edge_colors(contracted), lwd = 4)
-plot(st_geometry(contracted, "nodes"), pch = 20, cex = 1.5, add = TRUE)
-```
-
-One thing we did not mention yet: in the same way as `to_spatial_simple()` allowed you to combine attributes of multiple edges and `to_spatial_smooth()` allowed to combine attributes of concatenated edges, `to_spatial_contracted()` allows you to combine attributes of contracted nodes. It works exactly the same: you can specify a single summarise technique to be applied to all attributes (e.g. `summarise_attributes = "sum"`), a named list with a different summarise technique per attribute (e.g. `summarise_attributes = list(foo = "sum", bar = "mean")`), or a named list including an unnamed default technique for those attributes that where not mentioned (e.g. `summarise_attributes = list(foo = "sum", "mean")`). The techniques can be chosen from a set of pre-defined functions, but can also be a custom function provided by you. See the [igraph documentation](https://igraph.org/r/doc/igraph-attribute-combination.html) for details. Note that the geometry-list column, the tidygraph index column, as well as the *from* and *to* columns are not attributes!
-
-An example:
-
-```{r}
-# Add some additional attribute columns to the nodes table.
-clustered = clustered %>%
- activate("nodes") %>%
- mutate(is_priority = sample(
- c(TRUE, FALSE),
- vcount(clustered),
- replace = TRUE
- ))
-
-# We know from before there is one group with several close, connected nodes.
-# Lets look at them.
-clustered %>%
- activate("nodes") %>%
- filter(cls == 4 & cmp == 1) %>%
- st_as_sf()
-# Define how we want to combine the attributes.
-# For the boolean is_priority variable:
-# --> We want it to be TRUE if any of the nodes has a values of TRUE.
-# For the others, which were used as grouping variables:
-# --> Drop these attributes.
-combinations = list(
- is_priority = function(x) any(x),
- "ignore"
-)
-
-# Contract with to_spatial_contracted.
-contracted = convert(
- clustered,
- to_spatial_contracted,
- cls, cmp,
- simplify = TRUE,
- summarise_attributes = combinations
-)
-
-# Inspect our contracted group of nodes.
-contracted %>%
- activate("nodes") %>%
- slice(4) %>%
- st_as_sf()
-```
-
-### Overview
-
-After applying all the network cleaning functions described in the previous sections, we have cleaned our original network as follows:
-
-```{r, fig.show='hold', out.width = '50%'}
-plot(st_geometry(net, "edges"), col = edge_colors(net), lwd = 4)
-plot(st_geometry(net, "nodes"), pch = 20, cex = 1.5, add = TRUE)
-plot(st_geometry(contracted, "edges"), col = edge_colors(contracted), lwd = 4)
-plot(st_geometry(contracted, "nodes"), pch = 20, cex = 1.5, add = TRUE)
-```
-
-```{r, include = FALSE}
-par(oldpar)
-options(oldoptions)
-```
diff --git a/vignettes/sfn03_cleaning.qmd b/vignettes/sfn03_cleaning.qmd
new file mode 100644
index 00000000..255603bf
--- /dev/null
+++ b/vignettes/sfn03_cleaning.qmd
@@ -0,0 +1,385 @@
+---
+title: "Cleaning spatial networks"
+date: "`r Sys.Date()`"
+vignette: >
+ %\VignetteIndexEntry{3. Cleaning spatial networks}
+ %\VignetteEncoding{UTF-8}
+ %\VignetteEngine{quarto::html}
+format:
+ html:
+ toc: true
+knitr:
+ opts_chunk:
+ collapse: true
+ comment: '#>'
+ opts_knit:
+ global.par: true
+editor_options:
+ chunk_output_type: console
+---
+
+```{r}
+#| label: setup
+#| include: false
+current_geos = numeric_version(sf::sf_extSoftVersion()["GEOS"])
+required_geos = numeric_version("3.7.0")
+geos37 = current_geos >= required_geos
+```
+
+```{r}
+#| label: plot
+#| echo: false
+#| results: asis
+# plot margins
+oldpar = par(no.readonly = TRUE)
+par(mar = c(1, 1, 1, 1))
+# crayon needs to be explicitly activated in Rmd
+oldoptions = options()
+options(crayon.enabled = TRUE)
+# Hooks needs to be set to deal with outputs
+# thanks to fansi logic
+old_hooks = fansi::set_knit_hooks(
+ knitr::knit_hooks,
+ which = c("output", "message", "error")
+)
+```
+
+Unfortunately real-world datasets are not always as friendly as those used in tutorials. Cleaning the network data therefore forms a crucial part of a spatial network analysis workflow. This vignette presents some examples that may be of use when going through this phase.
+
+```{r}
+#| message: false
+library(sfnetworks)
+library(sf)
+library(tidygraph)
+library(ggraph)
+library(dplyr)
+```
+
+## The basics
+
+Functions that modify the topology of the network are implemented in `{sfnetworks}` as [spatial morphers](https://luukvdmeer.github.io/sfnetworks/articles/sfn01_intro.html/#spatial-morphers). Network cleaning functions belong to this family. For the applications described here, it is sufficient to know that you can use any spatial morpher function inside the `tidygraph::convert()` verb to convert your network into a new structure. One thing you will notice is that `{tidygraph}` keeps track of the original node and edge indices through the columns *.tidygraph_node_index* and *.tidygraph_edge_index*. If you do not want them in your output, add `.clean = TRUE` to the `tidygraph::convert()` call.
+
+Before presenting the cleaning functions that are currently implemented, lets create a network to be cleaned.
+
+```{r}
+p01 = st_point(c(0, 1))
+p02 = st_point(c(1, 1))
+p03 = st_point(c(2, 1))
+p04 = st_point(c(3, 1))
+p05 = st_point(c(4, 1))
+p06 = st_point(c(3, 2))
+p07 = st_point(c(3, 0))
+p08 = st_point(c(4, 3))
+p09 = st_point(c(4, 2))
+p10 = st_point(c(4, 0))
+p11 = st_point(c(5, 2))
+p12 = st_point(c(5, 0))
+p13 = st_point(c(5, -1))
+p14 = st_point(c(5.8, 1))
+p15 = st_point(c(6, 1.2))
+p16 = st_point(c(6.2, 1))
+p17 = st_point(c(6, 0.8))
+p18 = st_point(c(6, 2))
+p19 = st_point(c(6, -1))
+p20 = st_point(c(7, 1))
+p21 = st_point(c(0, 2))
+p22 = st_point(c(0, -1))
+
+l01 = st_sfc(st_linestring(c(p01, p02, p03)))
+l02 = st_sfc(st_linestring(c(p03, p04, p05)))
+l03 = st_sfc(st_linestring(c(p06, p04, p07)))
+l04 = st_sfc(st_linestring(c(p08, p11, p09)))
+l05 = st_sfc(st_linestring(c(p09, p05, p10)))
+l06 = st_sfc(st_linestring(c(p08, p09)))
+l07 = st_sfc(st_linestring(c(p10, p12, p13, p10)))
+l08 = st_sfc(st_linestring(c(p05, p14)))
+l09 = st_sfc(st_linestring(c(p15, p14)))
+l10 = st_sfc(st_linestring(c(p16, p15)))
+l11 = st_sfc(st_linestring(c(p14, p17)))
+l12 = st_sfc(st_linestring(c(p17, p16)))
+l13 = st_sfc(st_linestring(c(p15, p18)))
+l14 = st_sfc(st_linestring(c(p17, p19)))
+l15 = st_sfc(st_linestring(c(p16, p20)))
+l16 = st_sfc(st_linestring(c(p21, p01)))
+l17 = st_sfc(st_linestring(c(p22, p01)))
+
+lines = c(
+ l01, l02, l03, l04, l05,
+ l06, l07, l08, l09, l10,
+ l11, l12, l13, l14, l15
+)
+
+edges = st_sf(id = seq_along(lines), geometry = lines)
+
+net = as_sfnetwork(edges) |>
+ bind_spatial_nodes(st_sf(geometry = st_sfc(p01, p21, p22))) |>
+ mutate(
+ foo = sample(letters, n(), replace = TRUE),
+ bar = sample(c(1:10), n(), replace = TRUE)
+ ) |>
+ activate(edges) |>
+ bind_spatial_edges(st_sf(from = c(17, 18), to = c(16, 16), geometry = c(l16, l17))) |>
+ mutate(
+ foo = sample(letters, n(), replace = TRUE),
+ bar = sample(c(1:10), n(), replace = TRUE)
+ )
+```
+
+```{r}
+make_ggraph = function(x) {
+ ggraph(x, "sf") +
+ geom_edge_sf(aes(color = as.factor(id)), linewidth = 2, show.legend = FALSE) +
+ geom_node_sf(size = 4) +
+ theme_void()
+}
+
+make_ggraph(net)
+```
+
+## Simplify the network
+
+A network may contain sets of edges that connect the same pair of nodes. Such edges can be called *multiple edges*. Also, it may contain an edge that starts and ends at the same node. Such an edge can be called a *loop edge*.
+
+In graph theory, a *simple graph* is defined as a graph that does *not* contain multiple edges nor loop edges. To obtain a simple version of our network, we can remove multiple edges and loop edges by using the morpher `to_spatial_simple()`.
+
+```{r}
+simple = net |>
+ convert(to_spatial_simple)
+```
+
+```{r}
+#| layout-ncol: 2
+make_ggraph(net)
+make_ggraph(simple)
+```
+
+By re-arranging the edges table before applying the morpher you can influence which edges are kept whenever sets of multiple edges are detected. For example, you might want to always keep the edge with the shortest distance in the set.
+
+```{r}
+simple = net |>
+ activate(edges) |>
+ arrange(edge_length()) |>
+ convert(to_spatial_simple)
+```
+
+```{r}
+#| layout-ncol: 2
+make_ggraph(net)
+make_ggraph(simple)
+```
+
+## Subdivide edges
+
+When constructing a sfnetwork from a set of sf linestrings, the endpoints of those linestrings become nodes in the network. If endpoints are shared between multiple lines, they become a single node, and the edges are connected. However, a linestring geometry can also contain interior points that define the shape of the line, but are not its endpoints. It can happen that such an interior point in one edge is equal to either an interior point or endpoint of another edge. In the network structure, however, these two edges are not connected, because they don't share endpoints. If this is unwanted, we need to split these two edges at their shared point and connect them accordingly.
+
+In graph theory terms the process of splitting and edge is called *subdivision*: the subdivision of an edge $o = \{a, b\}$ (i.e. an edge from node $a$ to node $b$) is the addition of a new node $c$ and the replacement of $o$ by two new edges $p = \{a, c\}$ and $q = \{c, b\}$.
+
+The function `to_spatial_subdivision()` subdivides edges at interior points whenever these interior points are equal to one or more interior points or endpoints of other edges, and recalculates network connectivity afterwards.
+
+To illustrate the workflow, lets consider a situation where an interior point $p_{x}$ in edge $x$ is shared with point $p_{y}$ in edge $y$. That gives two possible situations:
+
+- $p_{y}$ is an interior point of $y$. Since $p_{x}$ and $p_{y}$ are both interior points, neither of them is already a node in the network. Then:
+ - $x$ is subdivided at $p_{x}$ into two new edges $x_{1}$ and $x_{2}$ and new node $p_{x}$.
+ - $y$ is subdivided at $p_{y}$ into two new edges $y_{1}$ and $y_{2}$ and new node $p_{y}$.
+ - The new nodes $p_{x}$ and $p_{y}$ are merged into a single node $p$ with the edge set $\{x_{1}, x_{2}, y_{1}, y_{2}\}$ as incidents.
+- $p_{y}$ is and endpoint of $y$. Since $p_{y}$ is an edge endpoint, it is already a node in the network. Then:
+ - $x$ is subdivided at $p_{x}$ into two new edges $x_{1}$ and $x_{2}$ and new node $p_{x}$.
+ - The new node $p_{x}$ is merged with node $p_{y}$ into a single node $p$ with the edge set $\{y, x_{1}, x_{2}\}$ as incidents.
+
+Note that an edge is not subdivided when it crosses another edge at a location that is not an interior point or endpoint in the linestring geometry of any of the two edges.
+
+For our example network, this means:
+
+```{r}
+#| warning: false
+subdivision = simple |>
+ convert(to_spatial_subdivision)
+```
+
+```{r}
+#| layout-ncol: 2
+make_ggraph(simple)
+make_ggraph(subdivision)
+```
+
+
+
+## Smooth pseudo nodes
+
+A network may contain nodes that have only one incoming and one outgoing edge. For tasks like calculating shortest paths, such nodes are redundant, because they don't represent a point where different directions can possibly be taken. Sometimes, these type of nodes are referred to as *pseudo nodes*. Note that their equivalent in undirected networks is any node with only two incident edges, since incoming and outgoing does not have a meaning there. To reduce complexity of subsequent operations, we might want to get rid of these pseudo nodes.
+
+In graph theory terms this process is the opposite of subdivision and also called *smoothing*: smoothing a node $b$ with incident edges $o = \{a, b\}$ and $p = \{b, c\}$ removes $b$, $o$ and $p$ and creates the new edge $q = \{a, c\}$.
+
+The function `to_spatial_smooth()` iteratively smooths pseudo nodes, and after each removal concatenates the linestring geometries of the two affected edges together into a new, single linestring geometry.
+
+```{r}
+smooth = subdivision |>
+ convert(to_spatial_smooth)
+```
+
+```{r}
+#| layout-ncol: 2
+make_ggraph(subdivision)
+make_ggraph(smooth)
+```
+
+#### Require equal attributes
+
+Pseudo nodes may have a function by representing a location where a certain attribute changes. For example, from that point on the street has a different surface type. The `require_equal` argument allows you to specify one or more edge attributes that will be checked for equality before removing a pseudo node. If those attributes are equal, the node will be removed, but if not, it will remain. The argument value is evaluated using tidy selection, meaning you can specify column names unquoted, but also use [selection helpers](https://dplyr.tidyverse.org/reference/select.html).
+
+```{r}
+# Only smooth pseudo nodes if incident edges have the same value for "foo".
+smooth_b = net |>
+ convert(to_spatial_smooth, require_equal = foo)
+
+# Only smooth pseudo nodes if incident edges have the same value for all attributes.
+smooth_c = net |>
+ convert(to_spatial_smooth, require_equal = everything())
+```
+
+#### Summarize attribute values
+
+The `to_spatial_smooth()` morpher also allows you to specify if and how you want to combine attributes of concatenated edges. This is done through the `attribute_summary` argument. How attributes should be combined is of course dependent on the type and purpose of each attribute. Therefore, the combination technique can be specified on a per-attribute basis. There are two ways to specify the combination technique for an attribute:
+
+- As a character, referring to the name of a pre-defined combination technique in `{igraph}`. Examples include `mean`, `sum`, `first` and `last`. See [here](https://igraph.org/r/doc/igraph-attribute-combination.html) for an overview of all implemented techniques.
+- As a function, taking a vector of attribute values as input and returning a single value. This is helpful when you want to combine attributes in a way that is not pre-defined.
+
+Providing a single character or a single function (e.g. `attribute_summary = "sum"`) will apply the same technique to each attribute. Instead, you can provide a named list with a different technique for each attribute. This list can also include one unnamed element containing the technique that should be applied to all attributes that were not referenced in any of the other elements. Note that the geometry-list column, the tidygraph index columns, as well as the *from* and *to* columns are not considered as attributes!
+
+```{r}
+smooth_d = subdivision |>
+ convert(
+ to_spatial_smooth,
+ attribute_summary = list(foo = paste, bar = "sum")
+ )
+
+smooth_d
+```
+
+
+
+## Simplify intersections
+
+Especially in road networks you may find that intersections between edges are not modeled by a single node. Instead, each leg of the intersection has a dedicated edge. To simplify the topology of your network, you might want to reduce such complex intersection structures into a single node. Hence, we want to reduce a group of nodes into a single node, while maintaining the connectivity of the network.
+
+In graph theory terms this process is called *contraction*: the contraction of a set of nodes $P = \{p_{1}, p_{2}, ..., p_{n}\}$ is the replacement of $S$ and all its incident edges by a single node $p^{*}$ and a set of edges that connect $p^{*}$ to all nodes that were adjacent to any node $p_{i} \in P$.
+
+The morpher `to_spatial_contracted()` contracts groups of nodes based on a given grouping variable. The geometry of each contracted node is (by default) the centroid of the original group members' geometries. Moreover, the geometries of the edges that start or end at a contracted node are updated such that their boundaries match the new node geometries.
+
+Grouping variables are internally forwarded to `dplyr::group_by()`. That means you can group the nodes based on any (combination of) attribute(s). However, in this case, we want to group the nodes spatially, such that nodes that are very close to each other in space will form a group and get contracted. Spatial grouping of nodes can be done with the `group_spatial_dbscan()` function, which exposes the DBSCAN clustering algorithm through the `{dbscan}` package. The main parameter $\epsilon$ defines the radius of the neighborhood of each node in meters. By default, this is based on network distance rather than euclidean distance.
+
+```{r}
+contraction = smooth |>
+ activate(nodes) |>
+ convert(to_spatial_contracted, group_spatial_dbscan(0.5))
+```
+
+```{r}
+#| layout-ncol: 2
+make_ggraph(smooth)
+make_ggraph(contraction)
+```
+
+## Merge nodes at equal locations
+
+The attentive reader may have noticed our network still consists of two disconnected components, caused by the fact that there are two seperate nodes at exactly the same location.
+
+```{r}
+st_equals(contraction)
+```
+
+```{r}
+with_graph(contraction, graph_component_count())
+```
+
+The `to_spatial_unique()` morpher implements a special variation of node contraction: it contracts nodes that share their spatial location. All incident edges of the contracted nodes become incident to the new node.
+
+```{r}
+unique = contraction |>
+ convert(to_spatial_unique)
+```
+
+```{r}
+#| layout-ncol: 2
+make_ggraph(contraction)
+make_ggraph(unique)
+```
+
+Visually we do not see a difference, but we have now reduced our number of components to 1:
+
+```{r}
+with_graph(unique, graph_component_count())
+```
+
+```{r}
+#| layout-ncol: 2
+# Plot components before and after.
+contraction |>
+ mutate(comp = group_components()) |>
+ activate(edges) |>
+ mutate(comp = .N()$comp[from]) |>
+ ggraph("sf") +
+ geom_edge_sf(aes(color = as.factor(comp)), linewidth = 2, show.legend = FALSE) +
+ geom_node_sf(size = 4) +
+ theme_void()
+
+unique |>
+ mutate(comp = group_components()) |>
+ activate(edges) |>
+ mutate(comp = .N()$comp[from]) |>
+ ggraph("sf") +
+ geom_edge_sf(aes(color = as.factor(comp)), linewidth = 2, show.legend = FALSE) +
+ geom_node_sf(size = 4) +
+ theme_void()
+```
+
+The `to_spatial_unique()` morpher also allows you to specify if and how you want to combine attributes of contracted nodes. This is done through the `attribute_summary` argument and works the same as explained [above](#summarize-attributes) for `to_spatial_smooth()`.
+
+## Overview
+
+In a single pipeline, we can clean our dirty network as follows.
+
+```{r}
+#| warning: false
+clean = net |>
+ activate(edges) |>
+ arrange(edge_length()) |>
+ activate(nodes) |>
+ convert(to_spatial_simple) |>
+ convert(to_spatial_subdivision) |>
+ convert(to_spatial_smooth) |>
+ convert(to_spatial_contracted, group_spatial_dbscan(0.5)) |>
+ convert(to_spatial_unique)
+```
+
+```{r}
+#| layout-ncol: 2
+make_ggraph(net)
+make_ggraph(clean)
+```
+
+## Non-tidyverse workflow
+
+All cleaning morphers have their internal worker exported as well. They can be used if you want to work outside the "tidy workflow". The same cleaning operation can be done as follows:
+
+```{r}
+#| warning: false
+simple = simplify_network(net)
+subdivision = subdivide_edges(simple)
+smooth = smooth_pseudo_nodes(subdivision)
+groups = with_graph(smooth, group_spatial_dbscan(0.5))
+contraction = contract_nodes(smooth, groups)
+unique = contract_nodes(contraction, st_match(st_geometry(net)))
+```
+
+```{r}
+#| layout-ncol: 2
+make_ggraph(net)
+make_ggraph(unique)
+```
+
+```{r}
+#| include: false
+par(oldpar)
+options(oldoptions)
+```
diff --git a/vignettes/sfn03_join_filter.Rmd b/vignettes/sfn03_join_filter.Rmd
deleted file mode 100644
index b50e9c91..00000000
--- a/vignettes/sfn03_join_filter.Rmd
+++ /dev/null
@@ -1,385 +0,0 @@
----
-title: "3. Spatial joins and filters"
-date: "`r Sys.Date()`"
-output: rmarkdown::html_vignette
-vignette: >
- %\VignetteIndexEntry{3. Spatial joins and filters}
- %\VignetteEngine{knitr::rmarkdown}
- %\VignetteEncoding{UTF-8}
----
-
-```{r setup, include=FALSE}
-knitr::opts_chunk$set(
- collapse = TRUE,
- comment = "#>"
-)
-knitr::opts_knit$set(global.par = TRUE)
-```
-
-```{r plot, echo=FALSE, results='asis'}
-# plot margins
-oldpar = par(no.readonly = TRUE)
-par(mar = c(1, 1, 1, 1))
-# crayon needs to be explicitly activated in Rmd
-oldoptions = options()
-options(crayon.enabled = TRUE)
-# Hooks needs to be set to deal with outputs
-# thanks to fansi logic
-old_hooks = fansi::set_knit_hooks(
- knitr::knit_hooks,
- which = c("output", "message", "error")
-)
-```
-
-The integration with `sf` and addition of several spatial network specific functions in `sfnetworks` allow to easily filter information from a network based on spatial relationships, and to join new information into a network based on spatial relationships. This vignette presents several ways to do that.
-
-Both spatial filters and spatial joins use spatial predicate functions to examine spatial relationships. Spatial predicates are mathematically defined binary spatial relations between two simple feature geometries. Often used examples include the predicate *equals* (geometry x is equal to geometry y) and the predicate *intersects* (geometry x has at least one point in common with geometry y). For an overview of all available spatial predicate functions in `sf` and links to detailed explanations of the underlying algorithms, see [here](https://r-spatial.github.io/sf/reference/geos_binary_pred.html).
-
-```{r, message=FALSE}
-library(sfnetworks)
-library(sf)
-library(tidygraph)
-library(igraph)
-library(dplyr)
-```
-
-## Spatial filters
-
-### Using st_filter
-
-Information can be filtered from a network by using spatial predicate functions inside the sf function `sf::st_filter()`, which works as follows: the function is applied to a set of geometries A with respect to another set of geometries B, and removes features from A based on their spatial relation with the features in B. A practical example: when using the predicate *intersects*, all geometries in A that do not intersect with any geometry in B are removed.
-
-When applying `sf::st_filter()` to a sfnetwork, it is internally applied to the active element of that network. For example: filtering information from a network A with activated nodes, using a set of polygons B and the predicate *intersects*, will remove those nodes that do not intersect with any of the polygons in B from the network. When edges are active, it will remove the edges that do not intersect with any of the polygons in B from the network.
-
-Although the filter is applied only to the active element of the network, it may also affect the other element. When nodes are removed, their incident edges are removed as well. However, when edges are removed, the nodes at their endpoints remain, even if they don't have any other incident edges. This behavior is inherited from `tidygraph` and understandable from a graph theory point of view: by definition nodes can exist peacefully in isolation, while edges can never exist without nodes at their endpoints.
-
-```{r, fig.show='hold', out.width = '50%'}
-p1 = st_point(c(4151358, 3208045))
-p2 = st_point(c(4151340, 3207120))
-p3 = st_point(c(4151856, 3207106))
-p4 = st_point(c(4151874, 3208031))
-
-poly = st_multipoint(c(p1, p2, p3, p4)) %>%
- st_cast("POLYGON") %>%
- st_sfc(crs = 3035)
-
-net = as_sfnetwork(roxel) %>%
- st_transform(3035)
-
-filtered = st_filter(net, poly, .pred = st_intersects)
-
-plot(net, col = "grey")
-plot(poly, border = "red", lty = 4, lwd = 4, add = TRUE)
-plot(net, col = "grey")
-plot(filtered, add = TRUE)
-```
-
-```{r, fig.show='hold', out.width = '50%'}
-filtered = net %>%
- activate("edges") %>%
- st_filter(poly, .pred = st_intersects)
-
-plot(net, col = "grey")
-plot(poly, border = "red", lty = 4, lwd = 4, add = TRUE)
-plot(net, col = "grey")
-plot(filtered, add = TRUE)
-```
-
-The isolated nodes that remain after filtering the edges can be easily removed using a combination of a regular `dplyr::filter()` verb and the `tidygraph::node_is_isolated()` query function.
-
-```{r, fig.show='hold', out.width = '50%'}
-filtered = net %>%
- activate("edges") %>%
- st_filter(poly, .pred = st_intersects) %>%
- activate("nodes") %>%
- filter(!node_is_isolated())
-
-plot(net, col = "grey")
-plot(poly, border = "red", lty = 4, lwd = 4, add = TRUE)
-plot(net, col = "grey")
-plot(filtered, add = TRUE)
-```
-
-Filtering can also be done with other predicates.
-
-```{r, fig.show='hold', out.width = '50%'}
-point = st_centroid(st_combine(net))
-
-filtered = net %>%
- activate("nodes") %>%
- st_filter(point, .predicate = st_is_within_distance, dist = 500)
-
-plot(net, col = "grey")
-plot(point, col = "red", cex = 3, pch = 20, add = TRUE)
-plot(net, col = "grey")
-plot(filtered, add = TRUE)
-```
-
-For non-spatial filters applied to attribute columns, simply use `dplyr::filter()` instead of `sf::st_filter()`.
-
-### Using spatial node and edge query functions
-
-In `tidygraph`, filtering information from networks is done by using specific node or edge query functions inside the `dplyr::filter()` verb. An example was already shown above, where isolated nodes were removed from the network.
-
-In `sfnetworks`, several spatial predicates are implemented as node and edge query functions such that you can also do spatial filtering in tidygraph style. See [here](https://luukvdmeer.github.io/sfnetworks/reference/spatial_node_predicates.html) for a list of all implemented spatial node query functions, and [here](https://luukvdmeer.github.io/sfnetworks/reference/spatial_edge_predicates.html) for the spatial edge query functions.
-
-```{r, fig.show='hold', out.width = '50%'}
-filtered = net %>%
- activate("edges") %>%
- filter(edge_intersects(poly)) %>%
- activate("nodes") %>%
- filter(!node_is_isolated())
-
-plot(net, col = "grey")
-plot(poly, border = "red", lty = 4, lwd = 4, add = TRUE)
-plot(net, col = "grey")
-plot(filtered, add = TRUE)
-```
-
-A nice application of this in road networks is to find underpassing and overpassing roads (i.e. edges that cross other edges but are not connected at that point). As we can see in the example below, such roads are not present in our Roxel data, which results in a network without edges.
-
-The `tidygraph::.E()` function used in the example makes it possible to directly access the complete edges table inside verbs. In this case, that means that for each edge we evaluate if it crosses with *any* other edge in the network. Similarly, we can use `tidygraph::.N()` to access the nodes table and `tidygraph::.G()` to access the network object as a whole.
-
-```{r}
-net %>%
- activate("edges") %>%
- filter(edge_crosses(.E()))
-```
-
-If you just want to store the information about the investigated spatial relation, without filtering the network, you can also use the spatial node and edge query functions inside a `dplyr::mutate()` verb.
-
-```{r}
-net %>%
- mutate(in_poly = node_intersects(poly))
-```
-
-Besides predicate query functions, you can also use the [coordinate query functions](https://luukvdmeer.github.io/sfnetworks/reference/node_coordinates.html) for spatial filters on the nodes. For example:
-
-```{r, fig.show='hold', out.width = '50%'}
-v = 4152000
-l = st_linestring(rbind(c(v, st_bbox(net)["ymin"]), c(v, st_bbox(net)["ymax"])))
-
-filtered_by_coords = net %>%
- activate("nodes") %>%
- filter(node_X() > v)
-
-plot(net, col = "grey")
-plot(l, col = "red", lty = 4, lwd = 4, add = TRUE)
-plot(net, col = "grey")
-plot(filtered_by_coords, add = TRUE)
-```
-
-### Clipping
-
-Filtering returns a subset of the original geometries, but leaves those geometries themselves unchanged. This is different from clipping, in which they get cut at the border of a provided clip feature. There are three ways in which you can do this: `sf::st_intersection()` keeps only those parts of the original geometries that lie within the clip feature, `sf::st_difference()` keeps only those parts of the original geometries that lie outside the clip feature, and `sf::st_crop()` keeps only those parts of the original geometries that lie within the bounding box of the clip feature.
-
-Note that in the case of the nodes, clipping is not different from filtering, since point geometries cannot fall party inside and partly outside another feature. However, in the case of the edges, clipping will cut the linestring geometries of the edges at the border of the clip feature (or in the case of cropping, the bounding box of that feature). To preserve a valid spatial network structure, `sfnetworks` adds new nodes at these cut locations.
-
-```{r, fig.show='hold', out.width = '50%'}
-clipped = net %>%
- activate("edges") %>%
- st_intersection(poly) %>%
- activate("nodes") %>%
- filter(!node_is_isolated())
-
-plot(net, col = "grey")
-plot(poly, border = "red", lty = 4, lwd = 4, add = TRUE)
-plot(net, col = "grey")
-plot(clipped, add = TRUE)
-```
-Note: Neither of the clipping function currently works well with undirected networks!
-
-## Spatial joins
-
-### Using st_join
-
-Information can be spatially joined into a network by using spatial predicate functions inside the sf function `sf::st_join()`, which works as follows: the function is applied to a set of geometries A with respect to another set of geometries B, and attaches feature attributes from features in B to features in A based on their spatial relation. A practical example: when using the predicate *intersects*, feature attributes from feature y in B are attached to feature x in A whenever x intersects with y.
-
-When applying `sf::st_join()` to a sfnetwork, it is internally applied to the active element of that network. For example: joining information into network A with activated nodes, from a set of polygons B and using the predicate *intersects*, will attach attributes from a polygon in B to those nodes that intersect with that specific polygon. When edges are active, it will attach the same information but to the intersecting edges instead.
-
-Lets show this with an example in which we first create imaginary postal code areas for the Roxel dataset.
-
-```{r, fig.show='hold', out.width = '50%'}
-codes = net %>%
- st_make_grid(n = c(2, 2)) %>%
- st_as_sf() %>%
- mutate(post_code = as.character(seq(1000, 1000 + n() * 10 - 10, 10)))
-
-joined = st_join(net, codes, join = st_intersects)
-joined
-plot(net, col = "grey")
-plot(codes, col = NA, border = "red", lty = 4, lwd = 4, add = TRUE)
-text(st_coordinates(st_centroid(st_geometry(codes))), codes$post_code, cex = 2)
-plot(st_geometry(joined, "edges"))
-plot(st_as_sf(joined, "nodes"), pch = 20, add = TRUE)
-```
-
-In the example above, the polygons are spatially distinct. Hence, each node can only intersect with a single polygon. But what would happen if we do a join with polygons that overlap? The attributes from which polygon will then be attached to a node that intersects with multiple polygons at once? In `sf` this issue is solved by duplicating such a point as much times as the number of polygons it intersects with, and attaching attributes of each intersecting polygon to one of these duplicates. This approach does not fit the network case, however. An edge can only have a single node at each of its endpoints, and thus, the duplicated nodes will be isolated and redundant in the network structure. Therefore, `sfnetworks` will only join the information from the first match whenever there are multiple matches for a single node. A warning is given in that case such that you are aware of the fact that not all information was joined into the network.
-
-Note that in the case of joining on the edges, multiple matches per edge are not a problem for the network structure. It will simply duplicate the edge (i.e. creating a set of parallel edges) whenever this occurs.
-
-```{r}
-two_equal_polys = st_as_sf(c(poly, poly)) %>%
- mutate(foo = c("a", "b"))
-
-# Join on nodes gives a warning that only the first match per node is joined.
-# The number of nodes in the resulting network remains the same.
-st_join(net, two_equal_polys, join = st_intersects)
-# Join on edges duplicates edges that have multiple matches.
-# The number of edges in the resulting network is higher than in the original.
-net %>%
- activate("edges") %>%
- st_join(two_equal_polys, join = st_intersects)
-```
-
-For non-spatial joins based on attribute columns, simply use a join function from `dplyr` (e.g. `dplyr::left_join()` or `dplyr::inner_join()`) instead of `sf::st_join()`.
-
-### Snapping points to their nearest node before joining
-
-Another network specific use-case of spatial joins would be to join information from external points of interest (POIs) into the nodes of the network. However, to do so, such points need to have *exactly* equal coordinates to one of the nodes. Often this will not be the case. To solve such situations, you will first need to update the coordinates of the POIs to match those of their *nearest node*. This process is also called *snapping*. To find the nearest node in the network for each POI, you can use the sf function `sf::st_nearest_feature()`.
-
-```{r, fig.show='hold', out.width = '50%'}
-# Create a network.
-node1 = st_point(c(0, 0))
-node2 = st_point(c(1, 0))
-edge = st_sfc(st_linestring(c(node1, node2)))
-
-net = as_sfnetwork(edge)
-
-# Create a set of POIs.
-pois = data.frame(poi_type = c("bakery", "butcher"),
- x = c(0, 0.6), y = c(0.2, 0.2)) %>%
- st_as_sf(coords = c("x", "y"))
-
-# Find indices of nearest nodes.
-nearest_nodes = st_nearest_feature(pois, net)
-
-# Snap geometries of POIs to the network.
-snapped_pois = pois %>%
- st_set_geometry(st_geometry(net)[nearest_nodes])
-
-# Plot.
-plot_connections = function(pois) {
- for (i in seq_len(nrow(pois))) {
- connection = st_nearest_points(pois[i, ], net)[nearest_nodes[i]]
- plot(connection, col = "grey", lty = 2, lwd = 2, add = TRUE)
- }
-}
-
-plot(net, cex = 2, lwd = 4)
-plot_connections(pois)
-plot(pois, pch = 8, cex = 2, lwd = 2, add = TRUE)
-plot(net, cex = 2, lwd = 4)
-plot(snapped_pois, pch = 8, cex = 2, lwd = 2, add = TRUE)
-```
-
-After snapping the POIs, we can use `sf::st_join()` as expected. Do remember that if multiple POIs are snapped to the same node, only the information of the first one is joined into the network.
-
-```{r}
-st_join(net, snapped_pois)
-```
-
-### Blending points into a network
-
-In the example above, it makes sense to include the information from the first POI in an already existing node. For the second POI, however, its *nearest node* is quite far away relative to the *nearest location* on its *nearest edge*. In that case, you might want to split the edge at that location, and add a *new node* to the network. For this combination process we use the metaphor of throwing the network and POIs together in a blender, and mix them smoothly together.
-
-The function `st_network_blend()` does exactly that. For each POI, it finds the nearest location $p$ on the nearest edge $e$. If $p$ is an already existing node (i.e. $p$ is an endpoint of $e$), it joins the information from the POI into that node. If $p$ is *not* an already existing node, it subdivides $e$ at $p$, adds $p$ as a *new node* to the network, and joins the information from the POI into that new node. For this process, it does *not* matter if $p$ is an interior point in the linestring geometry of $e$.
-
-```{r, fig.show='hold', out.width = '50%'}
-blended = st_network_blend(net, pois)
-blended
-plot_connections = function(pois) {
- for (i in seq_len(nrow(pois))) {
- connection = st_nearest_points(pois[i, ], activate(net, "edges"))
- plot(connection, col = "grey", lty = 2, lwd = 2, add = TRUE)
- }
-}
-
-plot(net, cex = 2, lwd = 4)
-plot_connections(pois)
-plot(pois, pch = 8, cex = 2, lwd = 2, add = TRUE)
-plot(blended, cex = 2, lwd = 4)
-```
-
-The `st_network_blend()` function has a `tolerance` parameter, which defines the maximum distance a POI can be from the network in order to be blended in. Hence, only the POIs that are at least as close to the network as the tolerance distance will be blended, and all others will be ignored. The tolerance can be specified as a non-negative number. By default it is assumed its units are meters, but this behaviour can be changed by manually setting its units with `units::units()`.
-
-```{r, fig.show='hold', out.width = '50%'}
-pois = data.frame(poi_type = c("bakery", "butcher", "bar"),
- x = c(0, 0.6, 0.4), y = c(0.2, 0.2, 0.3)) %>%
- st_as_sf(coords = c("x", "y"))
-
-blended = st_network_blend(net, pois)
-blended_with_tolerance = st_network_blend(net, pois, tolerance = 0.2)
-
-plot(blended, cex = 2, lwd = 4)
-plot_connections(pois)
-plot(pois, pch = 8, cex = 2, lwd = 2, add = TRUE)
-plot(blended_with_tolerance, cex = 2, lwd = 4)
-plot_connections(pois)
-plot(pois, pch = 8, cex = 2, lwd = 2, add = TRUE)
-```
-
-There are a few important details to be aware of when using `st_network_blend()`. Firstly: when multiple POIs have the same *nearest location on the nearest edge*, only the first of them is blended into the network. This is for the same reasons as explained before: in the network structure there is no clear approach for dealing with duplicated nodes. By arranging your table of POIs with `dplyr::arrange()` before blending you can influence which (type of) POI is given priority in such cases.
-
-Secondly: when a single POI has multiple nearest edges, it is only blended into the first of these edges. Therefore, it might be a good idea to run the `to_spatial_subdivision()` morpher after blending, such that intersecting but unconnected edges get connected. See the [Network pre-processing and cleaning](https://luukvdmeer.github.io/sfnetworks/articles/sfn02_preprocess_clean.html#subdivide-edges) vignette for more details.
-
-Lastly: it is important to be aware of *floating point precision*. See the discussion in [this GitHub issue](https://github.com/r-spatial/sf/issues/790) for more background. In short: due to internal rounding of rational numbers in R it is actually possible that even the intersection point between two lines is *not* evaluated as intersecting those lines themselves. Sounds confusing? It is! But see the example below:
-
-```{r}
-# Create two intersecting lines.
-p1 = st_point(c(0.53236, 1.95377))
-p2 = st_point(c(0.53209, 1.95328))
-l1 = st_sfc(st_linestring(c(p1, p2)))
-
-p3 = st_point(c(0.53209, 1.95345))
-p4 = st_point(c(0.53245, 1.95345))
-l2 = st_sfc(st_linestring(c(p3, p4)))
-
-# The two lines share an intersection point.
-st_intersection(l1, l2)
-
-# But this intersection point does not intersects the line itself!
-st_intersects(l1, st_intersection(l1, l2), sparse = FALSE)
-
-# The intersection point is instead located a tiny bit next to the line.
-st_distance(l1, st_intersection(l1, l2))
-```
-
-That is: you would expect an intersection with an edge to be blended into the network even if you set `tolerance = 0`, but in fact that will not always happen. To avoid having these problems, you can better set the tolerance to a very small number instead of zero.
-
-```{r, fig.show='hold', out.width = '50%'}
-net = as_sfnetwork(l1)
-p = st_intersection(l1, l2)
-
-plot(l1)
-plot(l2, col = "grey", lwd = 2, add = TRUE)
-plot(st_network_blend(net, p, tolerance = 0), lwd = 2, cex = 2, add = TRUE)
-plot(l1)
-plot(l2, col = "grey", lwd = 2, add = TRUE)
-plot(st_network_blend(net, p, tolerance = 1e-10), lwd = 2, cex = 2, add = TRUE)
-```
-
-### Joining two networks
-
-In the examples above it was all about joining information from external features into a network. But how about joining two networks? This is what the `st_network_join()` function is for. It takes two sfnetworks as input and makes a spatial full join on the geometries of the nodes data, based on the *equals* spatial predicate. That means, all nodes from network x *and* all nodes from network y are present in the joined network, but if there were nodes in x with equal geometries to nodes in y, these nodes become a *single node* in the joined network. Edge data are combined using a `dplyr::bind_rows()` semantic, meaning that data are matched by column name and values are filled with `NA` if missing in either of the networks. The *from* and *to* columns in the edge data are updated automatically such that they correctly match the new node indices of the joined network. There is no spatial join performed on the edges. Hence, if there is an edge in x with an equal geometry to an edge in y, they remain separate edges in the joined network.
-
-```{r, fig.show='hold', out.width = '50%'}
-node3 = st_point(c(1, 1))
-node4 = st_point(c(0, 1))
-edge2 = st_sfc(st_linestring(c(node2, node3)))
-edge3 = st_sfc(st_linestring(c(node3, node4)))
-
-net = as_sfnetwork(c(edge, edge2))
-other_net = as_sfnetwork(c(edge2, edge3))
-
-joined = st_network_join(net, other_net)
-joined
-plot(net, pch = 15, cex = 2, lwd = 4)
-plot(other_net, col = "red", pch = 18, cex = 2, lty = 2, lwd = 4, add = TRUE)
-plot(joined, cex = 2, lwd = 4)
-```
-
-```{r, include = FALSE}
-par(oldpar)
-options(oldoptions)
-```
diff --git a/vignettes/sfn04_join_filter.qmd b/vignettes/sfn04_join_filter.qmd
new file mode 100644
index 00000000..eb249246
--- /dev/null
+++ b/vignettes/sfn04_join_filter.qmd
@@ -0,0 +1,522 @@
+---
+title: "Spatial joins and filters"
+date: "`r Sys.Date()`"
+vignette: >
+ %\VignetteIndexEntry{4. Spatial joins and filters}
+ %\VignetteEncoding{UTF-8}
+ %\VignetteEngine{quarto::html}
+format:
+ html:
+ toc: true
+knitr:
+ opts_chunk:
+ collapse: true
+ comment: '#>'
+ opts_knit:
+ global.par: true
+---
+
+```{r}
+#| label: setup
+#| include: false
+current_geos = numeric_version(sf::sf_extSoftVersion()["GEOS"])
+required_geos = numeric_version("3.7.0")
+geos37 = current_geos >= required_geos
+```
+
+```{r}
+#| label: plot
+#| echo: false
+#| results: asis
+# plot margins
+oldpar = par(no.readonly = TRUE)
+par(mar = c(1, 1, 1, 1))
+# crayon needs to be explicitly activated in Rmd
+oldoptions = options()
+options(crayon.enabled = TRUE)
+# Hooks needs to be set to deal with outputs
+# thanks to fansi logic
+old_hooks = fansi::set_knit_hooks(
+ knitr::knit_hooks,
+ which = c("output", "message", "error")
+)
+```
+
+The integration with `{sf}` and addition of several spatial network specific functions in `sfnetworks` allow to easily filter information from a network based on spatial relationships, and to join new information into a network based on spatial relationships. This vignette presents several ways to do that.
+
+Both spatial filters and spatial joins use spatial predicate functions to examine spatial relationships. Spatial predicates are mathematically defined binary spatial relations between two simple feature geometries. Often used examples include the predicate *equals* (geometry x is equal to geometry y) and the predicate *intersects* (geometry x has at least one point in common with geometry y). For an overview of all available spatial predicate functions in `{sf}` and links to detailed explanations of the underlying algorithms, see [here](https://r-spatial.github.io/sf/reference/geos_binary_pred.html).
+
+```{r}
+#| message: false
+library(sfnetworks)
+library(sf)
+library(tidygraph)
+library(ggraph)
+library(dplyr)
+```
+
+## Spatial filters
+
+### Using st_filter
+
+Information can be filtered from a network by using spatial predicate functions inside the sf function `sf::st_filter()`, which works as follows: the function is applied to a set of geometries A with respect to another set of geometries B, and removes features from A based on their spatial relation with the features in B. A practical example: when using the predicate *intersects*, all geometries in A that do not intersect with any geometry in B are removed.
+
+When applying `sf::st_filter()` to a sfnetwork, it is internally applied to the active element of that network. For example: filtering information from a network A with activated nodes, using a set of polygons B and the predicate *intersects*, will remove those nodes that do not intersect with any of the polygons in B from the network. When edges are active, it will remove the edges that do not intersect with any of the polygons in B from the network.
+
+Although the filter is applied only to the active element of the network, it may also affect the other element. When nodes are removed, their incident edges are removed as well. However, when edges are removed, the nodes at their endpoints remain, even if they don't have any other incident edges. This behavior is inherited from `{tidygraph}` and understandable from a graph theory point of view: by definition nodes can exist peacefully in isolation, while edges can never exist without nodes at their endpoints. The isolated nodes that remain after filtering the edges can be easily removed using a combination of a regular `dplyr::filter()` verb and the `tidygraph::node_is_isolated()` query function.
+
+```{r}
+net = as_sfnetwork(mozart, "gabriel")
+ply = st_buffer(st_centroid(st_combine(mozart)), 300)
+
+filtered_by_nodes = net |>
+ st_filter(ply, .pred = st_intersects)
+
+filtered_by_edges_a = net |>
+ activate(edges) |>
+ st_filter(ply, .pred = st_intersects)
+
+filtered_by_edges_b = net |>
+ activate(edges) |>
+ st_filter(ply, .pred = st_intersects) |>
+ activate(nodes) |>
+ filter(!node_is_isolated())
+```
+
+```{r}
+#| layout-ncol: 2
+#| layout-nrow: 2
+#| fig-cap:
+#| - "Original network"
+#| - "Filtered by nodes"
+#| - "Filtered by edges"
+#| - "Removed isolated nodes"
+ggraph(net, "sf") +
+ geom_edge_sf() +
+ geom_node_sf(size = 4) +
+ geom_sf(data = ply, color = "orange", fill = NA, linewidth = 1) +
+ theme_void()
+
+ggraph(filtered_by_nodes, "sf") +
+ geom_edge_sf() +
+ geom_node_sf(size = 4) +
+ geom_sf(data = ply, color = "orange", fill = NA, linewidth = 1) +
+ theme_void()
+
+ggraph(filtered_by_edges_a, "sf") +
+ geom_edge_sf() +
+ geom_node_sf(size = 4) +
+ geom_sf(data = ply, color = "orange", fill = NA, linewidth = 1) +
+ theme_void()
+
+ggraph(filtered_by_edges_b, "sf") +
+ geom_edge_sf() +
+ geom_node_sf(size = 4) +
+ geom_sf(data = ply, color = "orange", fill = NA, linewidth = 1) +
+ theme_void()
+```
+
+For non-spatial filters applied to attribute columns, simply use `dplyr::filter()` instead of `sf::st_filter()`.
+
+### Using spatial node and edge query functions
+
+In `{tidygraph}`, filtering information from networks is done by using specific node or edge query functions inside the `dplyr::filter()` verb. An example was already shown above, where isolated nodes were removed from the network.
+
+In `{sfnetworks}`, several spatial predicates are implemented as node and edge query functions such that you can also do spatial filtering in tidygraph style. See [here](https://luukvdmeer.github.io/sfnetworks/reference/spatial_node_predicates.html) for a list of all implemented spatial node query functions, and [here](https://luukvdmeer.github.io/sfnetworks/reference/spatial_edge_predicates.html) for the spatial edge query functions. Using them makes spatial filter operations fit better into the tidy workflows of `{tidygraph}`. For example, we could filter edges that do not cross any other edge. The `tidygraph::.E()` function used in the example makes it possible to directly access the complete edges table inside verbs. Similarly, we can use `tidygraph::.N()` to access the nodes table and `tidygraph::.G()` to access the network object as a whole.
+
+```{r}
+complete_net = as_sfnetwork(mozart, "complete")
+
+filtered = complete_net |>
+ activate(edges) |>
+ filter(!edge_crosses(.E())) |>
+ activate(nodes) |>
+ filter(!node_is_isolated())
+```
+
+```{r}
+#| layout-ncol: 2
+#| fig-cap:
+#| - "Original network"
+#| - "Filtered network"
+ggraph(complete_net, "sf") +
+ geom_edge_sf() +
+ geom_node_sf(size = 4) +
+ theme_void()
+
+ggraph(filtered, "sf") +
+ geom_edge_sf() +
+ geom_node_sf(size = 4) +
+ theme_void()
+```
+
+Besides predicate query functions, you can also use the [coordinate query functions](https://luukvdmeer.github.io/sfnetworks/reference/node_coordinates.html) for spatial filters on the nodes. For example:
+
+```{r}
+x = 4549358
+
+filtered_by_coords = net |>
+ filter(node_X() > x)
+```
+
+```{r}
+#| layout-ncol: 2
+#| fig-cap:
+#| - "Original network"
+#| - "Filtered network"
+ggraph(net, "sf") +
+ geom_edge_sf() +
+ geom_node_sf(size = 4) +
+ geom_vline(xintercept = x, linewidth = 1, color = "orange") +
+ theme_void()
+
+ggraph(filtered_by_coords, "sf") +
+ geom_edge_sf() +
+ geom_node_sf(size = 4) +
+ geom_vline(xintercept = x, linewidth = 1, color = "orange") +
+ theme_void()
+```
+
+### Clipping
+
+Filtering returns a subset of the original geometries, but leaves those geometries themselves unchanged. This is different from clipping, in which they get cut at the border of a provided clip feature. There are three ways in which you can do this: `sf::st_intersection()` keeps only those parts of the original geometries that lie within the clip feature, `sf::st_difference()` keeps only those parts of the original geometries that lie outside the clip feature, and `sf::st_crop()` keeps only those parts of the original geometries that lie within the bounding box of the clip feature.
+
+Note that in the case of the nodes, clipping is not different from filtering, since point geometries cannot fall party inside and partly outside another feature. However, in the case of the edges, clipping will cut the linestring geometries of the edges at the border of the clip feature (or in the case of cropping, the bounding box of that feature). To preserve a valid spatial network structure, `{sfnetworks}` adds new nodes at these cut locations.
+
+```{r}
+clipped = net |>
+ activate(edges) |>
+ st_intersection(ply) |>
+ activate(nodes) |>
+ filter(!node_is_isolated())
+```
+
+```{r}
+#| layout-ncol: 3
+#| fig-cap:
+#| - "Original network"
+#| - "Filtered"
+#| - "Clipped"
+ggraph(net, "sf") +
+ geom_edge_sf() +
+ geom_node_sf(size = 4) +
+ geom_sf(data = ply, color = "orange", fill = NA, linewidth = 1) +
+ theme_void()
+
+ggraph(filtered_by_edges_b, "sf") +
+ geom_edge_sf() +
+ geom_node_sf(size = 4) +
+ geom_sf(data = ply, color = "orange", fill = NA, linewidth = 1) +
+ theme_void()
+
+ggraph(clipped, "sf") +
+ geom_edge_sf() +
+ geom_node_sf(size = 4) +
+ geom_sf(data = ply, color = "orange", fill = NA, linewidth = 1) +
+ theme_void()
+```
+
+## Spatial joins
+
+### Using st_join
+
+Information can be spatially joined into a network by using spatial predicate functions inside the sf function `sf::st_join()`, which works as follows: the function is applied to a set of geometries A with respect to another set of geometries B, and attaches feature attributes from features in B to features in A based on their spatial relation. A practical example: when using the predicate *intersects*, feature attributes from feature y in B are attached to feature x in A whenever x intersects with y.
+
+When applying `sf::st_join()` to a `sfnetwork` object, it is internally applied to the active element of that network. For example: joining information into network A with activated nodes, from a set of polygons B and using the predicate *intersects*, will attach attributes from a polygon in B to those nodes that intersect with that specific polygon. When edges are active, it will attach the same information but to the intersecting edges instead.
+
+Lets show this with an example in which we first create imaginary postal code areas for the Mozart dataset.
+
+```{r}
+codes = net |>
+ st_make_grid(n = c(2, 2)) |>
+ st_as_sf() |>
+ mutate(code = as.character(seq(1000, 1000 + n() * 10 - 10, 10)))
+
+joined = net |>
+ st_join(codes, join = st_intersects)
+
+joined
+```
+
+```{r}
+#| layout-ncol: 2
+#| fig-cap:
+#| - "Original network and postal codes"
+#| - "Network with joined information"
+ggraph(net, "sf") +
+ geom_sf(data = codes, aes(fill = code)) +
+ geom_edge_sf() +
+ geom_node_sf(size = 4) +
+ theme_void()
+
+ggraph(joined, "sf") +
+ geom_edge_sf() +
+ geom_node_sf(aes(color = code), size = 4) +
+ theme_void()
+```
+
+In the example above, the polygons are spatially distinct. Hence, each node can only intersect with a single polygon. But what would happen if we do a join with polygons that overlap? The attributes from which polygon will then be attached to a node that intersects with multiple polygons at once? In `{sf}` this issue is solved by duplicating such a point as much times as the number of polygons it intersects with, and attaching attributes of each intersecting polygon to one of these duplicates. This approach does not fit the network case, however. An edge can only have a single node at each of its endpoints, and thus, the duplicated nodes will be isolated and redundant in the network structure. Therefore, `{sfnetworks}` will only join the information from the first match whenever there are multiple matches for a single node. A warning is given in that case such that you are aware of the fact that not all information was joined into the network.
+
+Only when you set `ignore_multiple = FALSE`, multiple matches will result in duplicated nodes, but these duplicates are isolated (i.e. not connected to the rest of the network). You can then use the morpher `to_spatial_unique()` to merge spatially duplicated nodes into one, specifying how their attributes should be combined. See [here](https://luukvdmeer.github.io/sfnetworks/articles/sfn03_cleaning.html/#merge-nodes-at-equal-locations) for an example.
+
+Note that in the case of joining on the edges, multiple matches per edge are not a problem for the network structure. It will simply duplicate the edge (i.e. creating a set of parallel edges) whenever this occurs.
+
+```{r}
+box = st_as_sfc(st_bbox(mozart))
+
+two_equal_polys = st_as_sf(c(box, box)) |>
+ mutate(foo = c("a", "b"))
+
+# Join on nodes gives a warning that only the first match per node is joined.
+# The number of nodes in the resulting network remains the same.
+net |>
+ st_join(two_equal_polys, join = st_intersects)
+# With these settings multiple matches result in duplicated nodes.
+# In this example it means we have twice the number of nodes than before.
+# The duplicated nodes are isolated, i.e. not connected to any other node.
+net |>
+ st_join(two_equal_polys, join = st_intersects, ignore_multiple = FALSE)
+# Join on edges duplicates edges that have multiple matches.
+# The number of edges in the resulting network is higher than in the original.
+net |>
+ activate(edges) |>
+ st_join(two_equal_polys, join = st_intersects)
+```
+
+For non-spatial joins based on attribute columns, simply use a join function from `dplyr` (e.g. `dplyr::left_join()` or `dplyr::inner_join()`) instead of `sf::st_join()`.
+
+### Join points to their nearest node
+
+Another network specific use-case of spatial joins would be to join information from external points of interest (POIs) into the nodes of the network. However, to do so, such points need to have exactly equal coordinates to one of the nodes. Often this will not be the case. To solve such situations, you will first need to update the coordinates of the POIs to match those of their nearest node. This can be done using `st_project_on_network()`.
+
+```{r}
+# Create a network.
+n1 = st_point(c(0, 0))
+n2 = st_point(c(1, 0))
+
+net = st_sf(geometry = st_sfc(st_linestring(c(n1, n2)))) |>
+ as_sfnetwork()
+
+# Create a set of POIs.
+p1 = st_point(c(0, 0.2))
+p2 = st_point(c(0.6, 0.2))
+
+pois = st_sf(
+ poi_type = c("bakery", "butcher"),
+ geometry = st_sfc(p1, p2)
+)
+
+# Update coordinates of POIs to match their nearest node.
+ppois = st_project_on_network(pois, net, on = "nodes")
+```
+
+```{r}
+#| layout-ncol: 2
+#| fig-cap:
+#| - "Original network and POIs"
+#| - "POIs snapped to the network"
+ggraph(net, "sf") +
+ geom_sf(
+ data = pois, aes(color = poi_type),
+ pch = 8, size = 4, show.legend = FALSE
+ ) +
+ geom_edge_sf() +
+ geom_node_sf(size = 4) +
+ theme_void()
+
+ggraph(net, "sf") +
+ geom_sf(
+ data = st_nearest_points(pois, st_combine(ppois)),
+ color = "grey",
+ linetype = 2
+ ) +
+ geom_sf(
+ data = pois, aes(color = poi_type),
+ pch = 8, size = 4, show.legend = FALSE
+ ) +
+ geom_edge_sf() +
+ geom_node_sf(size = 4) +
+ geom_sf(
+ data = ppois, aes(color = poi_type),
+ pch = 8, size = 4, show.legend = FALSE
+ ) +
+ theme_void()
+```
+
+After snapping the POIs, we can use `sf::st_join()` as expected. Do remember that if multiple POIs are snapped to the same node, only the information of the first one is joined into the network, unless you set `ignore_multiple = FALSE`.
+
+```{r}
+st_join(net, ppois)
+```
+
+### Blending points into a network
+
+In the example above, it makes sense to include the information from the first POI in an already existing node. For the second POI, however, its nearest node is quite far away relative to the nearest location on its nearest edge. In that case, you might want to split the edge at that location, and add a new node to the network. For this combination process we use the metaphor of throwing the network and POIs together in a blender, and mix them smoothly together.
+
+The function `st_network_blend()` does exactly that. For each POI, it finds the nearest location $p$ on the nearest edge $e$. If $p$ is an already existing node (i.e. $p$ is an endpoint of $e$), it joins the information from the POI into that node. If $p$ is not an already existing node, it subdivides $e$ at $p$, adds $p$ as a new node to the network, and joins the information from the POI into that new node. For this process, it does not matter if $p$ is an interior point in the linestring geometry of $e$.
+
+```{r}
+blend = st_network_blend(net, pois)
+blend
+```
+
+```{r}
+#| layout-ncol: 2
+#| fig-cap:
+#| - "Original network and POIs"
+#| - "POIs blended into the network"
+ggraph(net, "sf") +
+ geom_sf(
+ data = pois, aes(color = poi_type),
+ pch = 8, size = 4, show.legend = FALSE
+ ) +
+ geom_edge_sf() +
+ geom_node_sf(size = 4) +
+ theme_void()
+
+ggraph(blend, "sf") +
+ geom_sf(
+ data = st_nearest_points(pois, st_combine(st_geometry(blend, "nodes"))),
+ color = "grey",
+ linetype = 2
+ ) +
+ geom_sf(
+ data = pois, aes(color = poi_type),
+ pch = 8, size = 4, show.legend = FALSE
+ ) +
+ geom_edge_sf() +
+ geom_node_sf(size = 4) +
+ theme_void()
+```
+
+The `st_network_blend()` function has a `tolerance` parameter, which defines the maximum distance a POI can be from the network in order to be blended in. Hence, only the POIs that are at least as close to the network as the tolerance distance will be blended, and all others will be ignored. The tolerance can be specified as a non-negative number. By default it is assumed its units are meters, but this behavior can be changed by manually setting its units with `units::units()`.
+
+```{r}
+# Update the POIs.
+p3 = st_point(c(0.4, 0.3))
+
+pois = st_sf(
+ poi_type = c("bakery", "butcher", "bar"),
+ geometry = st_sfc(p1, p2, p3)
+)
+
+blend = st_network_blend(net, pois)
+blend_with_tolerance = st_network_blend(net, pois, tolerance = 0.2)
+```
+
+```{r}
+#| layout-ncol: 2
+#| fig-cap:
+#| - "Blend without tolerance"
+#| - "Blend with tolerance"
+ggraph(blend, "sf") +
+ geom_sf(
+ data = st_nearest_points(pois, st_combine(st_geometry(blend, "nodes"))),
+ color = "grey",
+ linetype = 2
+ ) +
+ geom_sf(
+ data = pois, aes(color = poi_type),
+ pch = 8, size = 4, show.legend = FALSE
+ ) +
+ geom_edge_sf() +
+ geom_node_sf(size = 4) +
+ theme_void()
+
+ggraph(blend_with_tolerance, "sf") +
+ geom_sf(
+ data = st_nearest_points(pois, st_combine(st_geometry(blend, "nodes"))),
+ color = "grey",
+ linetype = 2
+ ) +
+ geom_sf(
+ data = pois, aes(color = poi_type),
+ pch = 8, size = 4, show.legend = FALSE
+ ) +
+ geom_edge_sf() +
+ geom_node_sf(size = 4) +
+ theme_void()
+```
+
+There are a few important details to be aware of when using `st_network_blend()`. Firstly: when multiple POIs have the same nearest location on the nearest edge, only the first of them is blended into the network. This is for the same reasons as explained before: in the network structure there is no clear approach for dealing with duplicated nodes. By arranging your table of POIs with `dplyr::arrange()` before blending you can influence which (type of) POI is given priority in such cases. There is also the option to set `ignore_duplicates = FALSE`. Then, duplicated projections will result in duplicated nodes, but these duplicates are isolated (i.e. not connected to the rest of the network). You can then use the morpher `to_spatial_unique()` to merge spatially duplicated nodes into one, specifying how their attributes should be combined. See [here](https://luukvdmeer.github.io/sfnetworks/articles/sfn03_cleaning.html/#merge-nodes-at-equal-locations) for an example.
+
+Secondly: when a single POI has multiple nearest edges, it is only blended into the first of these edges. Therefore, it might be a good idea to run the `to_spatial_subdivision()` morpher after blending, such that intersecting but unconnected edges get connected.
+
+Lastly: it is important to be aware of *floating point precision*. See the discussion in [this GitHub issue](https://github.com/r-spatial/sf/issues/790) for more background. In short: due to internal rounding of rational numbers in R it is actually possible that even the intersection point between two lines is *not* evaluated as intersecting those lines themselves. Sounds confusing? It is! But see the example below:
+
+```{r}
+# Create two intersecting lines.
+p1 = st_point(c(0.53236, 1.95377))
+p2 = st_point(c(0.53209, 1.95328))
+l1 = st_sfc(st_linestring(c(p1, p2)))
+
+p3 = st_point(c(0.53209, 1.95345))
+p4 = st_point(c(0.53245, 1.95345))
+l2 = st_sfc(st_linestring(c(p3, p4)))
+
+# The two lines share an intersection point.
+st_intersection(l1, l2)
+
+# But this intersection point does not intersects the line itself!
+st_intersects(l1, st_intersection(l1, l2), sparse = FALSE)
+
+# The intersection point is instead located a tiny bit next to the line.
+st_distance(l1, st_intersection(l1, l2))
+```
+
+That is: you would expect an intersection with an edge to be blended into the network even if you set `tolerance = 0`, but in fact that will not always happen. To avoid having these problems, you can better set the tolerance to a very small number instead of zero.
+
+```{r}
+#| layout-ncol: 2
+net = as_sfnetwork(l1)
+p = st_intersection(l1, l2)
+
+plot(l1)
+plot(l2, col = "grey", lwd = 2, add = TRUE)
+plot(st_network_blend(net, p, tolerance = 0), lwd = 2, cex = 2, add = TRUE)
+plot(l1)
+plot(l2, col = "grey", lwd = 2, add = TRUE)
+plot(st_network_blend(net, p, tolerance = 1e-10), lwd = 2, cex = 2, add = TRUE)
+```
+
+### Joining two networks
+
+In the examples above it was all about joining information from external features into a network. But how about joining two networks? This is what the `st_network_join()` function is for. It takes two sfnetworks as input and makes a spatial full join on the geometries of the nodes data, based on the *equals* spatial predicate. That means, all nodes from network x and all nodes from network y are present in the joined network, but if there were nodes in x with equal geometries to nodes in y, these nodes become a single node in the joined network. Edge data are combined using a `dplyr::bind_rows()` semantic, meaning that data are matched by column name and values are filled with `NA` if missing in either of the networks. The *from* and *to* columns in the edge data are updated automatically such that they correctly match the new node indices of the joined network. There is no spatial join performed on the edges. Hence, if there is an edge in x with an equal geometry to an edge in y, they remain separate edges in the joined network.
+
+```{r}
+# Create two networks.
+# Create a network.
+n1 = st_point(c(0, 0))
+n2 = st_point(c(1, 0))
+n3 = st_point(c(1, 1))
+n4 = st_point(c(0, 1))
+e1 = st_sfc(st_linestring(c(n1, n2)))
+e2 = st_sfc(st_linestring(c(n2, n3)))
+e3 = st_sfc(st_linestring(c(n3, n4)))
+
+neta = st_sf(geometry = c(e1, e2)) |>
+ as_sfnetwork()
+
+netb = st_sf(geometry = c(e2, e3)) |>
+ as_sfnetwork()
+
+# Join them into a single network.
+joined = st_network_join(neta, netb)
+joined
+```
+
+```{r}
+#| layout-ncol: 2
+#| fig-cap:
+#| - "Two networks"
+#| - "Joined network"
+plot(neta, col = "skyblue", pch = 15, cex = 2, lwd = 4)
+plot(netb, col = "orange", pch = 18, cex = 2, lty = 2, lwd = 4, add = TRUE)
+plot(joined, cex = 2, lwd = 4)
+```
+
+```{r}
+#| include: false
+par(oldpar)
+options(oldoptions)
+```
diff --git a/vignettes/sfn04_routing.Rmd b/vignettes/sfn04_routing.Rmd
deleted file mode 100644
index 11aba01f..00000000
--- a/vignettes/sfn04_routing.Rmd
+++ /dev/null
@@ -1,465 +0,0 @@
----
-title: "4. Routing"
-date: "`r Sys.Date()`"
-output: rmarkdown::html_vignette
-vignette: >
- %\VignetteIndexEntry{4. Routing}
- %\VignetteEncoding{UTF-8}
- %\VignetteEngine{knitr::rmarkdown}
-editor_options:
- chunk_output_type: console
----
-
-```{r setup, include=FALSE}
-knitr::opts_chunk$set(
- collapse = TRUE,
- comment = "#>"
-)
-knitr::opts_knit$set(global.par = TRUE)
-```
-
-```{r plot, echo=FALSE, results='asis'}
-# plot margins
-oldpar = par(no.readonly = TRUE)
-par(mar = c(1, 1, 1, 1))
-# crayon needs to be explicitly activated in Rmd
-oldoptions = options()
-options(crayon.enabled = TRUE)
-# Hooks needs to be set to deal with outputs
-# thanks to fansi logic
-old_hooks = fansi::set_knit_hooks(
- knitr::knit_hooks,
- which = c("output", "message", "error")
-)
-```
-
-Calculating shortest paths between pairs of nodes is a core task in network analysis. The `sfnetworks` package offers wrappers around the path calculation functions of `igraph`, making it easier to use them when working with spatial data and tidyverse packages. This vignette demonstrates their functionality.
-
-In this regard it is important to remember that `sfnetworks` is a general-purpose package for spatial network analysis, not specifically optimized for a single task. If your *only* purpose is many-to-many routing in large networks, there might be other approaches that are faster and fit better to your needs. For example, the [dodgr](https://github.com/UrbanAnalyst/dodgr) package was designed for many-to-many routing on large dual-weighted graphs, with its main focus on OpenStreetMap road network data. The [cppRouting](https://github.com/vlarmet/cppRouting) package contains functions to calculate shortest paths and isochrones/isodistances on weighted graphs. When working with OpenStreetMap data, it is also possible to use the interfaces to external routing engines such as [graphhopper](https://github.com/crazycapivara/graphhopper-r), [osrm](https://github.com/riatelab/osrm) and [opentripplanner](https://github.com/ropensci/opentripplanner). The [stplanr](https://github.com/ropensci/stplanr) package for sustainable transport planning lets you use many routing engines from a single interface, through `stplanr::route()`, including routing using local R objects. Of course, all these packages can be happily used *alongside* `sfnetworks`.
-
-```{r, message=FALSE}
-library(sfnetworks)
-library(sf)
-library(tidygraph)
-library(dplyr)
-library(purrr)
-library(TSP)
-```
-
-## Calculating shortest paths
-
-The function `st_network_paths()` is a wrapper around the igraph function `igraph::shortest_paths()`. There are two main differences:
-
-- Besides node indices and node names, `st_network_paths()` gives the additional option to provide any (set of) geospatial point(s) as *from* and *to* location(s) of the shortest paths, either as sf or sfc object. Provided points that do not equal any node in the network will be snapped to their nearest node before calculating the paths.
-- To allow smooth integration with the tidyverse, the output of `st_network_paths()` is a tibble, with one row per returned path. The column *node_paths* contains the ordered list of node indices in the path, and the column *edge_paths* contains the ordered list of edge indices in the path.
-
-Just as `igraph::shortest_paths()`, the `st_network_paths()` function is meant for one-to-one and one-to-many routing. Hence, it is only possible to provide a single *from* location, while the *to* locations can be more than one.
-
-Lets start with the most basic example of providing node indices as *from* and *to* locations. Remember that a node index in a sfnetwork refers to the position of the node in the nodes table of the network (i.e. its row number). There is also the possibility to use character encoded node names instead of numeric node indices. This requires the nodes table to have a column *name* with a unique name for each node.
-
-We will use geographic edge lengths as the edge weights to be used for shortest paths calculation. In weighted networks, `igraph::shortest_paths()` applies the Dijkstra algorithm to find the shortest paths. In the case of unweighted networks, it uses breadth-first search instead.
-
-```{r, fig.height=5, fig.width=5}
-net = as_sfnetwork(roxel, directed = FALSE) %>%
- st_transform(3035) %>%
- activate("edges") %>%
- mutate(weight = edge_length())
-
-paths = st_network_paths(net, from = 495, to = c(458, 121), weights = "weight")
-paths
-paths %>%
- slice(1) %>%
- pull(node_paths) %>%
- unlist()
-
-paths %>%
- slice(1) %>%
- pull(edge_paths) %>%
- unlist()
-
-plot_path = function(node_path) {
- net %>%
- activate("nodes") %>%
- slice(node_path) %>%
- plot(cex = 1.5, lwd = 1.5, add = TRUE)
-}
-
-colors = sf.colors(3, categorical = TRUE)
-
-plot(net, col = "grey")
-paths %>%
- pull(node_paths) %>%
- walk(plot_path)
-net %>%
- activate("nodes") %>%
- st_as_sf() %>%
- slice(c(495, 121, 458)) %>%
- plot(col = colors, pch = 8, cex = 2, lwd = 2, add = TRUE)
-```
-
-Next we will create some geospatial points that do not intersect with any node in the network. Providing them to `st_network_paths()` will first find the nearest node to each of them, and then calculate the shortest paths accordingly.
-
-```{r, fig.height=5, fig.width=5}
-p1 = st_geometry(net, "nodes")[495] + st_sfc(st_point(c(50, -50)))
-st_crs(p1) = st_crs(net)
-p2 = st_geometry(net, "nodes")[458]
-p3 = st_geometry(net, "nodes")[121] + st_sfc(st_point(c(-10, 100)))
-st_crs(p3) = st_crs(net)
-
-paths = st_network_paths(net, from = p1, to = c(p2, p3), weights = "weight")
-
-plot(net, col = "grey")
-paths %>%
- pull(node_paths) %>%
- walk(plot_path)
-plot(c(p1, p2, p3), col = colors, pch = 8, cex = 2, lwd = 2, add = TRUE)
-```
-
-Simply finding the nearest node to given points is not always the best way to go. For example: when a provided point is close to a location on an edge linestring, but relatively far from any node, it gives better results when the routing does not start at its nearest node, but at that nearby location on the edge linestring. To accommodate this, you can first *blend* your input points into the network, and then proceed as normal. Blending is the name we gave to the combined process of snapping a point to its nearest point on its nearest edge, splitting the edge there, and adding the snapped point as node to the network. It is implement in the function `st_network_blend()`. See the vignette on [spatial joins and filters](https://luukvdmeer.github.io/sfnetworks/articles/sfn03_join_filter.html#blending-points-into-a-network) for more details.
-
-Another issue may arise wen your network consists of multiple components that are not connected to each other. In that case, it is possible that the nearest node to a provided point is located in a tiny component and only a few other nodes can be reached from it. In such cases it might be good to first reduce the network to its largest (or *n* largest) component(s) before calculating shortest paths. The tidygraph function `tidygraph::group_components()` can help with this. It assigns an integer to each node identifying the component it is in, with `1` being the largest component in the network, `2` the second largest, and so on.
-
-```{r, fig.height=5, fig.width=5}
-# Our network consists of several unconnected components.
-with_graph(net, graph_component_count())
-
-connected_net = net %>%
- activate("nodes") %>%
- filter(group_components() == 1)
-
-plot(net, col = colors[2])
-plot(connected_net, cex = 1.1, lwd = 1.1, add = TRUE)
-```
-
-Another way to calculate shortest paths, which fits nicely in the tidygraph style of working, is by using the `to_spatial_shortest_paths()` morpher function. This will subset the original network to only contain those nodes and edges that appear in a shortest path between two nodes. See the vignette on [Spatial morphers](https://luukvdmeer.github.io/sfnetworks/articles/sfn05_morphers.html#to_spatial_shortest_paths) for details.
-
-## Retrieving an OD cost matrix
-
-The shortest paths calculation as described above is only supported for one-to-one and one-to-many routing. The alternative for many-to-many routing is the calculation of an origin-destination cost matrix. Instead of providing the individual paths, it returns a matrix in which entry $i,j$ is the total cost (i.e. sum of weights) of the shortest path from node $i$ to node $j$. The origin-destination cost matrix is usually an important starting point for further analysis. For example, it can serve as input to route optimization algorithms, spatial clustering algorithms and the calculation of statistical measures based on spatial proximity.
-
-The igraph function for this purpose is `igraph::distances()`, which in `sfnetworks` is wrapped by `st_network_cost()`, allowing again to provide sets of geospatial points as *from* and *to* locations. Note that the calculated costs refer to the paths between the *nearest nodes* of the input points. Their units are the same as the units of weights used in the calculation, in this case meters.
-
-```{r}
-st_network_cost(net, from = c(p1, p2, p3), to = c(p1, p2, p3), weights = "weight")
-```
-
-If we don't provide any from and to points, `st_network_cost()` will by default calculate the cost matrix for the entire network.
-
-```{r}
-# Our network has 701 nodes.
-with_graph(net, graph_order())
-
-cost_matrix = st_network_cost(net, weights = "weight")
-dim(cost_matrix)
-```
-
-In directed networks, `st_network_cost()` also gives you the possibility to define if you want to allow travel only over outbound edges from the *from* points (by setting `direction = "out"`, the default), only over inbound edges (by setting `direction = "in"`), or both (by setting `direction = "all"`, i.e. assuming an undirected network).
-
-```{r}
-net %>%
- convert(to_spatial_directed) %>%
- st_network_cost(
- from = c(p1, p2, p3),
- to = c(p1, p2, p3),
- direction = "in"
- )
-
-net %>%
- convert(to_spatial_directed) %>%
- st_network_cost(
- from = c(p1, p2, p3),
- to = c(p1, p2, p3),
- direction = "out"
- )
-
-net %>%
- convert(to_spatial_directed) %>%
- st_network_cost(
- from = c(p1, p2, p3),
- to = c(p1, p2, p3),
- direction = "all"
- )
-```
-
-All `...` arguments are forwarded internally to `igraph::distances()`. Among other options, this means that you can define which algorithm is used for the paths calculation. By default, `igraph` will choose the most suitable algorithm based on characteristics of the request, such as the type of weights (e.g. only positive or also negative) and the number of given from locations. For details, see the [igraph documentation](https://igraph.org/r/doc/distances.html).
-
-## Applications
-
-In this section, we will show a small set of applications of the routing related functions. It is definitely not meant to be an overview that covers everything! Also, remember again that `sfnetworks` is a general-purpose spatial network analysis package not optimized for a specific application. However, especially in combination with other packages it can address a wide variety of use-cases.
-
-### Closest facility analysis
-
-The purpose of closest facility analysis is, given a set of destination locations (also referred to as the *facilities*) and origin locations (also referred to as the *sites*), to find the closest *n* facilities to each site. For example, you might want to find the nearest transit hub for each address in a city, or the nearest hospital to high-risk road intersections.
-
-To solve this problem, you can calculate the OD cost matrix with the sites as *from* points, and the facilities as *to* points. Then, for each row (i.e. each site) you find the column(s) with the lowest cost value.
-
-```{r, fig.height=5, fig.width=5}
-# Select a random set of sites and facilities.
-# We select random locations within the bounding box of the nodes.
-set.seed(128)
-hull = net %>%
- activate("nodes") %>%
- st_geometry() %>%
- st_combine() %>%
- st_convex_hull()
-
-sites = st_sample(hull, 50, type = "random")
-facilities = st_sample(hull, 5, type = "random")
-
-# Blend the sites and facilities into the network to get better results.
-# Also select only the largest connected component.
-new_net = net %>%
- activate("nodes") %>%
- filter(group_components() == 1) %>%
- st_network_blend(c(sites, facilities))
-
-# Calculate the cost matrix.
-cost_matrix = st_network_cost(new_net, from = sites, to = facilities, weights = "weight")
-
-# Find for each site which facility is closest.
-closest = facilities[apply(cost_matrix, 1, function(x) which(x == min(x))[1])]
-
-# Create a line between each site and its closest facility, for visualization.
-draw_lines = function(sources, targets) {
- lines = mapply(
- function(a, b) st_sfc(st_cast(c(a, b), "LINESTRING"), crs = st_crs(net)),
- sources,
- targets,
- SIMPLIFY = FALSE
- )
- do.call("c", lines)
-}
-
-connections = draw_lines(sites, closest)
-
-# Plot the results.
-plot(new_net, col = "grey")
-plot(connections, col = colors[2], lwd = 2, add = TRUE)
-plot(facilities, pch = 8, cex = 2, lwd = 2, col = colors[3], add = TRUE)
-plot(sites, pch = 20, cex = 2, col = colors[2], add = TRUE)
-```
-
-### Route optimization
-
-The traveling salesman problem aims to find the shortest tour that visits a set of locations exactly once and then returns to the starting location. In `sfnetworks`, there are no dedicated functions to solve this. However, we can bring in other packages here to assist us. Probably the best known R package for solving traveling salesman problems is [TSP](https://CRAN.R-project.org/package=TSP). To do so, it requires a matrix that specifies the travel cost between each pair of locations. As shown above, we can use `sfnetworks` to calculate such a cost matrix for our network.
-
-Lets first generate a set of random points within the bounding box of the network. These will serve as the locations the traveling salesman has to visit.
-
-```{r}
-set.seed(403)
-rdm = net %>%
- st_bbox() %>%
- st_as_sfc() %>%
- st_sample(10, type = "random")
-```
-
-We can then compute the cost matrix using `st_network_cost()`. Internally, it will first snap the provided points to their nearest node in the network, and then use the *weight* column to calculate the travel costs between these nodes. Passing the matrix to `TSP::TSP()` will make it suitable for usage inside the `TSP` package.
-
-```{r}
-net = activate(net, "nodes")
-cost_matrix = st_network_cost(net, from = rdm, to = rdm, weights = "weight")
-
-# Use nearest node indices as row and column names.
-rdm_idxs = st_nearest_feature(rdm, net)
-row.names(cost_matrix) = rdm_idxs
-colnames(cost_matrix) = rdm_idxs
-
-round(cost_matrix, 0)
-```
-
-By passing the cost matrix to the solver `TSP::solve_TSP()` we obtain an object of class `TOUR`. This object contains a named vector specifying the indices of the provided locations in the optimal order of visit.
-
-```{r}
-tour = solve_TSP(TSP(units::drop_units(cost_matrix)))
-tour_idxs = as.numeric(names(tour))
-tour_idxs
-
-# Approximate length of the route.
-# In meters, since that was the unit of our cost values.
-round(tour_length(tour), 0)
-```
-
-Knowing the optimal order to visit all provided locations, we move back to `sfnetworks` to match this route to the network. We do so by computing the shortest paths between each location and its subsequent one, until we reach the starting point again.
-
-For more details on solving travelling salesman problems in R, see the [TSP package documentation](https://CRAN.R-project.org/package=TSP).
-
-```{r, fig.height=5, fig.width=5}
-# Define the nodes to calculate the shortest paths from.
-# Define the nodes to calculate the shortest paths to.
-# All based on the calculated order of visit.
-from_idxs = tour_idxs
-to_idxs = c(tour_idxs[2:length(tour_idxs)], tour_idxs[1])
-
-# Calculate the specified paths.
-tsp_paths = mapply(st_network_paths,
- from = from_idxs,
- to = to_idxs,
- MoreArgs = list(x = net, weights = "weight")
- )["node_paths", ] %>%
- unlist(recursive = FALSE)
-
-# Plot the results.
-plot(net, col = "grey")
-plot(rdm, pch = 20, col = colors[2], add = TRUE)
-
-walk(tsp_paths, plot_path) # Reuse the plot_path function defined earlier.
-
-plot(
- st_as_sf(slice(net, rdm_idxs)),
- pch = 20, col = colors[3], add = TRUE
-)
-plot(
- st_as_sf(slice(net, tour_idxs[1])),
- pch = 8, cex = 2, lwd = 2, col = colors[3], add = TRUE
-)
-text(
- st_coordinates(st_as_sf(slice(net, tour_idxs[1]))) - c(200, 90),
- labels = "start/end\npoint"
-)
-```
-
-### Isochrones and isodistances
-
-With respect to a given point $p$ and a given travel time $t$, an isochrone is the line for which it holds that the travel time from any point on the line to or from $p$ is equal to $t$. When using distances instead of time, it is called an isodistance.
-
-In `sfnetworks` there are no dedicated, optimized functions for calculating isochrones or isodistances. However, we can roughly approximate them by using a combination of sf and tidygraph functions. Lets first calculate imaginary travel times for each edge, using randomly generated average walking speeds for each type of edge.
-
-```{r, warning=FALSE}
-# How many edge types are there?
-types = net %>%
- activate("edges") %>%
- pull(type) %>%
- unique()
-
-types
-
-# Randomly define a walking speed in m/s for each type.
-# With values between 3 and 7 km/hr.
-set.seed(1)
-speeds = runif(length(types), 3 * 1000 / 60 / 60, 7 * 1000 / 60 / 60)
-
-# Assign a speed to each edge based on its type.
-# Calculate travel time for each edge based on that.
-net = net %>%
- activate("edges") %>%
- group_by(type) %>%
- mutate(speed = units::set_units(speeds[cur_group_id()], "m/s")) %>%
- mutate(time = weight / speed) %>%
- ungroup()
-
-net
-```
-
-Now, we can calculate the total travel time for each shortest path between the (nearest node of the) origin point and all other nodes in the network, using the node measure function `tidygraph::node_distance_from()` with the values in the *time* column as weights. Then, we filter the nodes reachable within a given travel time from the origin. By drawing a convex hull around these selected nodes we roughly approximate the isochrone. If we wanted isochrones for travel times *towards* the central point, we could have used the node measure function `tidygraph::node_distance_to()` instead.
-
-```{r, fig.height=5, fig.width=5}
-net = activate(net, "nodes")
-
-p = net %>%
- st_geometry() %>%
- st_combine() %>%
- st_centroid()
-
-iso = net %>%
- filter(node_distance_from(st_nearest_feature(p, net), weights = time) <= 600)
-
-iso_poly = iso %>%
- st_geometry() %>%
- st_combine() %>%
- st_convex_hull()
-
-plot(net, col = "grey")
-plot(iso_poly, col = NA, border = "black", lwd = 3, add = TRUE)
-plot(iso, col = colors[2], add = TRUE)
-plot(p, col = colors[1], pch = 8, cex = 2, lwd = 2, add = TRUE)
-```
-
-The workflow presented above is generalized in a spatial morpher function `to_spatial_neighborhood()`, which can be used inside the `tidygraph::convert()` verb to filter only those nodes that can be reached within a given travel cost from the given origin node.
-
-```{r, fig.width=5, fig.height=5}
-# Define the threshold values (in seconds).
-# Define also the colors to plot the neighborhoods in.
-thresholds = rev(seq(60, 600, 60))
-palette = sf.colors(n = 10)
-
-# Plot the results.
-plot(net, col = "grey")
-
-for (i in c(1:10)) {
- nbh = convert(net, to_spatial_neighborhood, p, thresholds[i], weights = "time")
- plot(nbh, col = palette[i], add = TRUE)
-}
-
-plot(p, pch = 8, cex = 2, lwd = 2, add = TRUE)
-```
-
-### Custom routing
-
-In many cases the shortest path based geographical distances or travel time is not necessarily the optimal path. The most appropriate route may depend on many factors, for example staying away from large and potentially unpleasant highways for motor traffic. All networks have different characteristics. In OpenStreetMap networks the 'highway' type is often a good (albeit crude) approximation of the network. The `roxel` demo dataset is derived from OpenStreetMap and represents a largely residential network:
-
-```{r}
-table(roxel$type)
-```
-
-Building on the shortest paths calculated in a previous section we can try an alternative routing profile. Let's take a look at these paths to see what type of ways they travel on:
-
-```{r, fig.height=5, fig.width=5}
-paths_sf = net %>%
- activate("edges") %>%
- slice(unlist(paths$edge_paths)) %>%
- st_as_sf()
-
-table(paths_sf$type)
-
-plot(paths_sf["type"], lwd = 4, key.pos = 4, key.width = lcm(4))
-```
-
-As with the overall network, the paths we calculated are dominated by residential streets.
-For the purposes of illustration, lets imagine we're routing for a vehicle that we want to keep away from residential roads, and which has a much lower cost per unit distance on secondary roads:
-
-```{r}
-weighting_profile = c(
- cycleway = Inf,
- footway = Inf,
- path = Inf,
- pedestrian = Inf,
- residential = 3,
- secondary = 1,
- service = 1,
- track = 10,
- unclassified = 1
-)
-
-weighted_net = net %>%
- activate("edges") %>%
- mutate(multiplier = weighting_profile[type]) %>%
- mutate(weight = edge_length() * multiplier)
-```
-
-We can now recalculate the routes. The result show routes that avoid residential networks.
-
-```{r, fig.show='hold', out.width = '50%'}
-weighted_paths = st_network_paths(weighted_net, from = 495, to = c(458, 121), weights = "weight")
-
-weighted_paths_sf = weighted_net %>%
- activate("edges") %>%
- slice(unlist(weighted_paths$edge_paths)) %>%
- st_as_sf()
-
-table(weighted_paths_sf$type)
-
-plot(st_as_sf(net, "edges")["type"], lwd = 4,
- key.pos = NULL, reset = FALSE, main = "Distance weights")
-plot(st_geometry(paths_sf), add = TRUE)
-plot(st_as_sf(net, "edges")["type"], lwd = 4,
- key.pos = NULL, reset = FALSE, main = "Custom weights")
-plot(st_geometry(weighted_paths_sf), add = TRUE)
-```
-
-Note that developing more sophisticated routing profiles is beyond the scope of this package.
-If you need complex mode-specific routing profiles, we recommend looking at routing profiles associated with open source routing engines, such as [bike.lua](https://github.com/fossgis-routing-server/cbf-routing-profiles/blob/master/bike.lua) in the OSRM project. Another direction of travel could be to extend on the approach illustrated here, but this work could be well-suited to a separate package that builds on `sfnetworks` (remembering that sophisticated routing profiles account for nodes and edges). If you'd like to work on such a project to improve mode-specific routing in R by building on this package, please let us know in the [discussion room](https://github.com/luukvdmeer/sfnetworks/discussions)!
-
-```{r, include = FALSE}
-par(oldpar)
-options(oldoptions)
-```
diff --git a/vignettes/sfn05_morphers.Rmd b/vignettes/sfn05_morphers.Rmd
deleted file mode 100644
index 82ad32d3..00000000
--- a/vignettes/sfn05_morphers.Rmd
+++ /dev/null
@@ -1,392 +0,0 @@
----
-title: "5. Spatial morphers"
-date: "`r Sys.Date()`"
-output: rmarkdown::html_vignette
-vignette: >
- %\VignetteIndexEntry{5. Spatial morphers}
- %\VignetteEngine{knitr::rmarkdown}
- %\VignetteEncoding{UTF-8}
----
-
-```{r setup, include=FALSE}
-knitr::opts_chunk$set(
- collapse = TRUE,
- comment = "#>"
-)
-knitr::opts_knit$set(global.par = TRUE)
-```
-
-```{r plot, echo=FALSE, results='asis'}
-# plot margins
-oldpar = par(no.readonly = TRUE)
-par(mar = c(1, 1, 1, 1))
-# crayon needs to be explicitly activated in Rmd
-oldoptions = options()
-options(crayon.enabled = TRUE)
-# Hooks needs to be set to deal with outputs
-# thanks to fansi logic
-old_hooks = fansi::set_knit_hooks(
- knitr::knit_hooks,
- which = c("output", "message", "error")
-)
-```
-
-In some of the previous vignettes they were already mentioned here and there: spatial morpher functions. This vignette describes in more detail what they are and how to use them.
-
-```{r, message=FALSE}
-library(sfnetworks)
-library(sf)
-library(tidygraph)
-library(igraph)
-```
-
-## Morphing and unmorphing
-*Morphing* networks is a functionality that has its roots in `tidygraph`. It allows you to *temporarily* change the topology of the original network with the `tidygraph::morph()` verb, perform some actions on this "morphed state" of the network using `dplyr` verbs, and finally merge the changes back into the original network with the `tidygraph::unmorph()` verb. How the topology is changed during morphing depends on the used *morpher function*, which you provide to `tidygraph::morph()`.
-
-You can choose between a wide range of available *morpher functions* in `tidygraph`. The names of these functions all start with `to_`. The `sfnetworks` package adds a set of dedicated *spatial morphers*, having names starting with `to_spatial_`. They will be presented later in this vignette. Lets first show a practical example to get a better idea of how morphing can be useful.
-
-In network analysis, community detection algorithms allow you to discover cohesive groups in networks, based on specific network properties. There is a large offer of (wrappers around) such algorithms in `tidygraph`, see [here](https://tidygraph.data-imaginist.com/reference/group_graph.html). Most of them are intended for and can only be applied to the nodes. For example, the often used [Louvain algorithm](https://en.wikipedia.org/wiki/Louvain_method), that seeks to optimize [modularity](https://en.wikipedia.org/wiki/Modularity_(networks)) of the partition.
-
-But what if we want to detect communities within *edges*? Then, the `tidygraph::to_linegraph()` morpher comes in handy. It converts a network into its linegraph, where nodes become edges and edges become nodes. That is, we can morph the network into its linegraph, run the community detection algorithm on the *nodes* of the morphed state, attach to each node information about the group it is assigned to, and automatically merge those changes back into the *edges* of the original network.
-
-```{r}
-net = as_sfnetwork(roxel, directed = FALSE) %>%
- st_transform(3035)
-
-grouped_net = net %>%
- morph(to_linegraph) %>%
- mutate(group = group_louvain()) %>%
- unmorph()
-
-grouped_net
-# The algorithm detected 34 communities.
-grouped_net %>%
- activate("edges") %>%
- pull(group) %>%
- unique() %>%
- length()
-```
-
-In all grouping functions in `tidygraph`, the group index `1` belongs the largest group, the index `2` to the second largest group, et cetera. Lets plot only the first third of the 34 groups, to keep the plot clear.
-
-```{r, fig.width=5, fig.height=5}
-plot(st_geometry(net, "edges"), col = "grey", lwd = 0.5)
-
-grouped_net %>%
- activate("edges") %>%
- st_as_sf() %>%
- transmute(group = as.factor(group)) %>%
- filter(group %in% c(1:11)) %>%
- plot(lwd = 4, add = TRUE)
-```
-
-Another application of the `tidygraph::to_linegraph()` morpher is to find "cut edges" in the network. These are edges that break the connectivity of a connected component when they are removed. Hence, they have a crucial function in preserving the connectivity of a network.
-
-```{r, fig.show='hold', out.width = '50%'}
-new_net = net %>%
- mutate(is_cut = node_is_cut()) %>%
- morph(to_linegraph) %>%
- mutate(is_cut = node_is_cut()) %>%
- unmorph()
-
-cut_nodes = new_net %>%
- activate("nodes") %>%
- filter(is_cut) %>%
- st_geometry()
-
-cut_edges = new_net %>%
- activate("edges") %>%
- filter(is_cut) %>%
- st_geometry()
-
-plot(net, col = "grey", main = "Cut nodes")
-plot(cut_nodes, col = "red", pch = 20, cex = 2, add = TRUE)
-plot(net, col = "grey", main = "Cut edges")
-plot(cut_edges, col = "red", lwd = 4, add = TRUE)
-```
-
-Internally, a morphed state of a network is a list, in which each element is a network on its own. Some morphers create a list with only a single element, like the linegraph example above, while others create a list with multiple elements. For example, the `tidygraph::to_components()` morpher splits the original network into its unconnected components, storing each component as a separate network in the list.
-
-```{r}
-morphed_net = morph(net, to_components)
-
-morphed_net
-class(morphed_net)
-
-length(morphed_net)
-```
-
-All dplyr verbs for which a tbl_graph method exists can also be applied to a morphed state. Technically, that means that the same operation is applied to each element in the list. In `sfnetworks`, it is also possible to use `sf::st_join()` and `sf::st_filter()` on morphed states of networks.
-
-The mapping back to the original network is stored in *.tidygraph_node_index* and *.tidygraph_edge_index* columns, for nodes and edges respectively. These columns contain the indices (i.e. rownumber) of the original nodes and edges. During unmorphing these indices are used to join the changes made in the morphed state back into the original network. A `dplyr::left_join()` semantic is applied here. That means that the network has the *same* number of nodes and edges after unmorphing as it had before morphing, no matter if either the morpher function or the subsequent operations on the morphed state added or removed any nodes or edges. Whenever an original node or edge index occurs more than once in a morphed state, only the information added to its first instance is merged back into the original network.
-
-## Converting
-
-Morphing is meant to make *temporary* changes to your network. However, what if you want to make *lasting* changes? Good news! The *same* morpher functions that are used for temporary conversions during morphing can be used for lasting conversions when you provide them to the `tidygraph::convert()` verb instead. In that case, the morphed state of the original network will be returned as a new network itself. This is mainly meant for those morpher functions that produce a morphed state with a *single* element. For example converting the network into its complement, where nodes are connected only if they were not connected in the original graph.
-
-```{r}
-convert(net, to_complement)
-```
-
-The internally created *.tidygraph_node_index* and/or *.tidygraph_edge_index* columns are present in the network returned by `tidygraph::convert()`. If you rather don't have that, set `.clean = TRUE`.
-
-Whenever the morphed state consists of *multiple* elements, only one of them is returned as the new network. By default that is the first one, but you can pick an element yourself by setting the `.select` argument, e.g. `.select = 2`.
-
-## Spatial morphers
-
-A lot of morpher functions are offered by `tidygraph`. See the overview of them [here](https://tidygraph.data-imaginist.com/reference/morphers.html). Most of them should work without problems on sfnetworks, and can be useful for spatial network analysis. On top of that offer, `sfnetworks` adds some additional *spatial morphers* that fit use-cases for which `tidygraph` does not have an implemented morpher function. See the overview of them [here](https://luukvdmeer.github.io/sfnetworks/reference/spatial_morphers.html). Below they will all be shortly introduced.
-
-### to_spatial_contracted
-
-The `to_spatial_contracted()` morpher groups nodes based on given grouping variables, and merges each group of nodes into a single, new node. The geometry of this new node will be the centroid of the geometries of all group members. Edges that were incidents to any of the group members are updated such that they are now incident to the new node. Their boundaries are updated accordingly to preserve the valid spatial network structure. The morphed state contains a single sfnetwork.
-
-To create the groups of nodes, the morpher accepts grouping variables as `...` argument. These will be evaluated in the same way as arguments to `dplyr::group_by()`. That means you can group the nodes based on any (combination of) attribute(s). For an example of grouping based on a spatial clustering algorithm, see the [Network pre-processing and cleaning](https://luukvdmeer.github.io/sfnetworks/articles/sfn02_preprocess_clean.html#simplify-intersections) vignette.
-
-A point of attention is that contraction introduces new *multiple edges* and/or *loop edges*. Multiple edges are introduced by contraction when there are several connections between the same groups of nodes. Loop edges are introduced by contraction when there are connections within a group. Setting `simplify = TRUE` will remove the multiple and loop edges after contraction. However, note that this also removes multiple and loop edges that already existed before contraction.
-
-The `to_spatial_contracted()` morpher gives the options to summarise attributes of nodes in a group. By setting the `summarise_attributes` argument you can specify on a per-attribute basis how the attributes of the new node should be inferred from the attributes of the group members. There are two ways to specify the combination technique for an attribute:
-
-- As a character, referring to the name of a pre-defined combination technique in `igraph`. Examples include `mean`, `sum`, `first` and `last`. See [here](https://igraph.org/r/doc/igraph-attribute-combination.html) for an overview of all implemented techniques.
-- As a function, taking a vector of attribute values as input and returning a single value. This is helpful when you want to combine attributes in a way that is not pre-defined in `igraph`.
-
-Providing a single character or a single function (e.g. `summarise_attributes = "sum"`) will apply the same technique to each attribute. Instead, you can provide a named list with a different technique for each attribute. This list can also include one unnamed element containing the technique that should be applied to all attributes that were not referenced in any of the other elements.
-
-```{r, fig.show='hold', out.width = '50%'}
-new_net = net %>%
- activate("nodes") %>%
- filter(group_components() == 1) %>%
- mutate(foo = sample(c(1:10), graph_order(), replace = TRUE)) %>%
- mutate(bar = sample(c(TRUE, FALSE), graph_order(), replace = TRUE)) %>%
- mutate(louvain = as.factor(group_louvain()))
-
-contracted_net = convert(
- new_net,
- to_spatial_contracted,
- louvain,
- simplify = TRUE,
- summarise_attributes = list(
- foo = "sum",
- bar = function(x) any(x),
- louvain = "first"
- )
-)
-
-plot(st_geometry(new_net, "edges"), main = "Grouped nodes")
-plot(st_as_sf(new_net)["louvain"], key.pos = NULL, pch = 20, add = TRUE)
-plot(st_geometry(contracted_net, "edges"), main = "Contracted network")
-plot(
- st_as_sf(contracted_net)["louvain"],
- cex = 2, key.pos = NULL,
- pch = 20, add = TRUE
-)
-```
-
-### to_spatial_directed
-
-The `to_spatial_directed()` morpher turns an undirected network into a directed one based on the direction given by the linestring geometries of the edges. Hence, from the node corresponding to the first point of the linestring, to the node corresponding to the last point of the linestring. This in contradiction to `tidygraph::to_directed()`, which bases the direction on the node indices given in the *to* and *from* columns of the edges. In undirected networks the lowest node index is always used as *from* index, no matter the order of endpoints in the edges' linestring geometry. Therefore, the *from* and *to* node indices of an edge may not always correspond to the first and last endpoint of the linestring geometry, and `to_spatial_directed()` gives different results as `tidygraph::to_directed()`.
-
-```{r}
-net %>%
- activate("nodes") %>%
- mutate(bc_undir = centrality_betweenness()) %>%
- morph(to_spatial_directed) %>%
- mutate(bc_dir = centrality_betweenness()) %>%
- unmorph() %>%
- mutate(bc_diff = bc_dir - bc_undir) %>%
- arrange(desc(bc_diff))
-```
-
-### to_spatial_explicit
-
-If your original network is *spatially implicit* (i.e. edges do not have a geometry list column), the `to_spatial_explicit()` morpher explicitizes the edges by creating a geometry list column for them. If the edges table can be directly converted to an sf object using `sf::st_as_sf()`, extra arguments can be provided as `...`, which will be forwarded to `sf::st_as_sf()` internally. Otherwise, straight lines will be drawn between the end nodes of each edge. The morphed state contains a single sfnetwork.
-
-```{r, fig.show='hold', out.width = '50%'}
-implicit_net = st_set_geometry(activate(net, "edges"), NULL)
-explicit_net = convert(implicit_net, to_spatial_explicit)
-
-plot(implicit_net, draw_lines = FALSE, main = "Implicit edges")
-plot(explicit_net, main = "Explicit edges")
-```
-
-### to_spatial_neighborhood
-
-The `to_spatial_neighborhood()` morpher limits the original network to those nodes that are part of the neighborhood of a specified *origin* node. This origin node can be specified by a node index, but also by any geospatial point (as sf or sfc object). Internally, such a point will be snapped to its nearest node before calculating the neighborhood. A neighborhood contains all nodes that can be reached within a certain cost threshold from the origin node. The morphed state contains a single sfnetwork. See the [Routing](https://luukvdmeer.github.io/sfnetworks/articles/sfn04_routing.html#isochrones-and-isodistances) vignette for more details and examples.
-
-```{r, fig.width=5, fig.height=5}
-# Define the origin location.
-p = net %>%
- st_geometry() %>%
- st_combine() %>%
- st_centroid()
-
-# Subset neighborhood.
-neigh_net = net %>%
- activate("edges") %>%
- convert(to_spatial_neighborhood, p, threshold = 500, weights = edge_length())
-
-plot(net, col = "grey")
-plot(neigh_net, col = "red", add = TRUE)
-```
-
-### to_spatial_shortest_paths
-
-The `to_spatial_shortest_paths()` morpher limits the original network to those nodes and edges that are part of the shortest path between two nodes. Just as with the other shortest path calculation functionalities presented in the [Routing](https://luukvdmeer.github.io/sfnetworks/articles/sfn04_routing.html) vignette, besides node indices or names, any geospatial point (as sf or sfc object) can be provided as *from* and *to* node. Internally, such points will be snapped to their nearest node before calculating the shortest path.
-
-The morpher only accepts a single *from* node. If also a single *to* node is provided, the morphed state of the network contains a single sfnetwork. However, it is also possible to provide multiple *to* nodes. Then, the morphed state of the network contains multiple sfnetworks, one for each from-to combination.
-
-```{r}
-net %>%
- activate("edges") %>%
- convert(
- to_spatial_shortest_paths,
- from = 1, to = 100,
- weights = edge_length()
- )
-```
-
-```{r, fig.width=5, fig.height=5}
-new_net = net %>%
- activate("edges") %>%
- morph(
- to_spatial_shortest_paths,
- from = 1, to = seq(10, 100, 10),
- weights = edge_length()
- ) %>%
- mutate(in_paths = TRUE) %>%
- unmorph()
-
-new_net %>%
- st_geometry() %>%
- plot(col = "grey", lwd = 2)
-
-new_net %>%
- filter(in_paths) %>%
- st_geometry() %>%
- plot(col = "red", lwd = 4, add = TRUE)
-```
-
-### to_spatial_simple
-
-The `to_spatial_simple()` morpher removes loop edges from the network and merges multiple edges into one. Loop edges are edges that start and end at the same node. Multiple edges are sets of edges that connect the same pair of nodes. The morphed state contains a single sfnetwork. See the [Network pre-processing and cleaning](https://luukvdmeer.github.io/sfnetworks/articles/sfn02_preprocess_clean.html#simplify-network) vignette for more details and examples.
-
-In the same way as `to_spatial_contracted()`, the `to_spatial_simple()` morpher gives the option to specify how attributes from merged edges should be inferred from the attributes of the set members. The geometry of a merged edge however is always equal to the geometry of the first set member.
-
-```{r, fig.show='hold', out.width = '50%'}
-# Add a flow attribute to the edges.
-# When merging multiple edges, we want the flow of the new edge to be:
-# --> The sum of the flows of the merged edges.
-new_net = net %>%
- activate("edges") %>%
- mutate(flow = sample(c(1:100), ecount(net), replace = TRUE))
-
-# Select a set of multiple edges to inspect before simplifying.
-a_multiple = new_net %>%
- filter(edge_is_multiple()) %>%
- slice(1)
-
-new_net %>%
- filter(edge_is_between(pull(a_multiple, from), pull(a_multiple, to))) %>%
- st_as_sf()
-# Simplify the network.
-# We summarise the flow attribute by taking the sum of the merged edge flows.
-# For all the other attributes we simply take the first value in the set.
-simple_net = new_net %>%
- convert(
- to_spatial_simple,
- summarise_attributes = list(flow = "sum", "first")
- )
-
-# The multiple edges are merged into one.
-# The flow is summarised by taking the sum of the merged edge flows.
-simple_net %>%
- filter(edge_is_between(pull(a_multiple, from), pull(a_multiple, to))) %>%
- st_as_sf()
-```
-
-### to_spatial_smooth
-
-The `to_spatial_smooth()` morpher creates a smoothed version of the original network by iteratively removing pseudo nodes. In the case of directed networks, pseudo nodes are those nodes that have only one incoming and one outgoing edge. In undirected networks, pseudo nodes are those nodes that have two incident edges. Connectivity of the
-network is preserved by concatenating the incident edges of each removed pseudo node. The morphed state contains a single sfnetwork. See the [Network pre-processing and cleaning](https://luukvdmeer.github.io/sfnetworks/articles/sfn02_preprocess_clean.html#smooth-pseudo-nodes) vignette for more details and examples.
-
-```{r, fig.show='hold', out.width = '50%'}
-smoothed_net = convert(net, to_spatial_smooth)
-
-plot(net, main = "Original network")
-plot(net, col = "red", cex = 0.8, lwd = 0.1, main = "Smoothed network")
-plot(smoothed_net, col = "grey", add = TRUE)
-```
-
-### to_spatial_subdivision
-
-The `to_spatial_subdivision()` morpher creates a subdivision of the original network by subdividing edges at each interior point that is equal to any other interior or boundary point of other edges. Interior points in this sense are those points that are included in a linestring geometry feature but are not endpoints of it, while boundary points are the endpoints of the linestrings. The network is reconstructed after subdivision such that connections are created at the points of subdivision. The morphed state contains a single sfnetwork. See the [Network pre-processing and cleaning](https://luukvdmeer.github.io/sfnetworks/articles/sfn02_preprocess_clean.html#subdivide-edges) vignette for more details and examples.
-
-```{r}
-subdivided_net = convert(net, to_spatial_subdivision)
-
-# Original network.
-paste("Number of edges: ", ecount(net))
-paste("Number of components: ", count_components(net))
-
-# Subdivided network.
-# The whole network is now a single connected component!
-paste("Number of edges: ", ecount(subdivided_net))
-paste("Number of components: ", count_components(subdivided_net))
-```
-
-### to_spatial_subset
-
-The `to_spatial_subset()` morpher takes a subset of the network by applying a spatial filter. A spatial filter is a filter on a geometry list column based on a spatial predicate. The morphed state contains a single sfnetwork. We can use this for example to spatially join information *only* to a spatial subset of the nodes in the network. A tiny example just to get an idea of how this would work:
-
-```{r}
-codes = net %>%
- st_make_grid(n = c(2, 2)) %>%
- st_as_sf() %>%
- mutate(post_code = seq(1000, 1000 + n() * 10 - 10, 10))
-
-points = st_geometry(net, "nodes")[c(2, 3)]
-
-net %>%
- morph(to_spatial_subset, points, .pred = st_equals) %>%
- st_join(codes, join = st_intersects) %>%
- unmorph()
-```
-
-If you want to apply the spatial filter to the edges instead of the nodes, either activate edges before morphing, or set `subset_by = "edges"`.
-
-For filters on attribute columns, use `tidygraph::to_subgraph()` instead. Again, a tiny fictional example just to get an idea of how this would work:
-
-```{r}
-net = net %>%
- activate("nodes") %>%
- mutate(building = sample(c(TRUE, FALSE), n(), replace = TRUE))
-
-net %>%
- morph(to_subgraph, building) %>%
- st_join(codes, join = st_intersects) %>%
- unmorph()
-```
-
-### to_spatial_transformed
-
-The `to_spatial_transformed()` morpher temporarily transforms the network into a different coordinate reference system. An example of a situation in which this can be helpful, is the usage of the spatial edge measure function `edge_azimuth()`. This function calculates the azimuth (or *bearing*) of edge linestrings, but requires a geographic CRS for that.
-
-```{r, error = TRUE}
-# Azimuth calculation fails with our projected CRS.
-# The function complains the coordinates are not longitude/latitude.
-net %>%
- activate("edges") %>%
- mutate(azimuth = edge_azimuth())
-```
-```{r}
-# We make it work by temporarily transforming to a different CRS.
-net %>%
- activate("edges") %>%
- morph(to_spatial_transformed, 4326) %>%
- mutate(azimuth = edge_azimuth()) %>%
- unmorph()
-```
-
-```{r, include = FALSE}
-par(oldpar)
-options(oldoptions)
-```
diff --git a/vignettes/sfn05_routing.qmd b/vignettes/sfn05_routing.qmd
new file mode 100644
index 00000000..7f25e804
--- /dev/null
+++ b/vignettes/sfn05_routing.qmd
@@ -0,0 +1,539 @@
+---
+title: "Routing on spatial networks"
+date: "`r Sys.Date()`"
+vignette: >
+ %\VignetteIndexEntry{5. Routing on spatial networks}
+ %\VignetteEncoding{UTF-8}
+ %\VignetteEngine{quarto::html}
+format:
+ html:
+ toc: true
+knitr:
+ opts_chunk:
+ collapse: true
+ comment: '#>'
+ opts_knit:
+ global.par: true
+---
+
+```{r}
+#| label: setup
+#| include: false
+current_geos = numeric_version(sf::sf_extSoftVersion()["GEOS"])
+required_geos = numeric_version("3.7.0")
+geos37 = current_geos >= required_geos
+```
+
+```{r}
+#| label: plot
+#| echo: false
+#| results: asis
+# plot margins
+oldpar = par(no.readonly = TRUE)
+par(mar = c(1, 1, 1, 1))
+# crayon needs to be explicitly activated in Rmd
+oldoptions = options()
+options(crayon.enabled = TRUE)
+# Hooks needs to be set to deal with outputs
+# thanks to fansi logic
+old_hooks = fansi::set_knit_hooks(
+ knitr::knit_hooks,
+ which = c("output", "message", "error")
+)
+```
+
+Calculating shortest paths between pairs of nodes is a core task in spatial network analysis. `{sfnetworks}` does not implement its own algorithms to do this, but wraps function from other packages and exposes them through one consistent API that fits within the design of the package. This vignette demonstrates how.
+
+```{r}
+#| message: false
+library(sfnetworks)
+library(sf)
+library(tidygraph)
+library(ggraph)
+library(dplyr)
+library(units)
+```
+
+## The basics
+
+In `{sfnetworks}` there are two core functions for routing on spatial networks. The first one, `st_network_paths()`, returns the course of the optimal paths between pairs of nodes. The second one, `st_network_cost()`, returns a matrix of optimal travel costs between pairs of nodes.
+
+Both functions need to be provided with nodes to route from (the origins), and nodes to route to (the destinations). The most basic way of doing this is through the integer node indices, which correspond to the rownumbers of the nodes table. There are many other ways, though, which are explained in detail in [this section](#specifying-origins-and-destinations). Secondly, it should be specified what the weight of each edge is. Higher edge weights result in higher travel costs. By default, `{sfnetworks}` will always use the geographic lengths of the edges. It is possible to specify different edge weights, which is explained in detail in [this section](#specifying-edge-weights).
+
+The function `st_network_paths()` returns a `sf` object with one row per requested path. Columns *from* and *to* contain the node indices of the origin and destination of each path. The column *node_path* contains the indices of the nodes on the path, in order of visit. Similarly, the column *edge_path* contains the indices of the edges on the paths, in order of visit. If your nodes and/or edges have names stored in a column named *name* and you want to use those values instead to encode them, set `use_names = TRUE`. Column *cost* stores the total travel cost of the path. The geometry column is the linestring that resulted from concatenating the individual geometries of all visited edges.
+
+```{r}
+# Create a network.
+net = as_sfnetwork(mozart, "gabriel", directed = FALSE)
+```
+
+```{r}
+# Compute the shortest path between node 1 and nodes 12 and 17.
+paths = st_network_paths(net, 1, c(12, 17))
+paths
+```
+```{r}
+# Do the same but now use node names to encode nodes in the output.
+paths = st_network_paths(net, 1, c(12, 17), use_names = TRUE)
+paths
+```
+
+```{r}
+ggraph(net, "sf") +
+ geom_edge_sf(color = "darkgrey") +
+ geom_sf(data = paths, color = "orange", linewidth = 2) +
+ geom_node_sf(color = "darkgrey", size = 4) +
+ geom_sf(data = node_data(net)[1, ], size = 6) +
+ geom_sf(data = node_data(net)[c(12, 17), ], pch = 15, size = 6) +
+ theme_void()
+```
+
+The function `st_network_cost()` returns a $n \times m$ matrix, with $n$ being the number of specified origins, and $m$ being the number of specified destinations. Element $A_{ij}$ stores the total travel cost of the shortest path between the $i$-th origin and the $j$-th destination. This matrix is usually an important starting point for further analysis. For example, it can serve as input to route optimization algorithms, spatial clustering algorithms and the calculation of statistical measures based on spatial proximity.
+
+```{r}
+# Compute the cost matrix for travel between three nodes.
+st_network_cost(net, c(1, 12, 17), c(1, 12, 17))
+```
+
+```{r}
+# Use node names to encode nodes.
+st_network_cost(net, c(1, 12, 17), c(1, 12, 17), use_names = TRUE)
+```
+
+There is also the function `st_network_distance()`, which is a synonym for `st_network_cost()` with the edge weights always being the geographic edge length. This function was added to provide an intuitive network-specific alternative to `sf::st_distance()`.
+
+Do note that in our nice little example network all nodes are connected to each other. In many real-world networks this may not always be the case, meaning that some node pairs will not have a path between them. In that case `st_network_paths()` will still return the "path", but with an empty geometry and a `FALSE` value in the *path_found* column. `st_network_cost()` will store an `Inf` value.
+
+```{r}
+divided_net = as_sfnetwork(mozart, "knn", k = 3, directed = FALSE)
+```
+
+```{r}
+#| warning: false
+paths = st_network_paths(divided_net, 12, c(1, 17))
+paths
+```
+
+```{r}
+ggraph(divided_net, "sf") +
+ geom_edge_sf(color = "darkgrey") +
+ geom_sf(data = paths, color = "orange", linewidth = 2) +
+ geom_node_sf(color = "darkgrey", size = 4) +
+ geom_sf(data = node_data(divided_net)[12, ], size = 6) +
+ geom_sf(data = node_data(divided_net)[c(1, 17), ], pch = 15, size = 6) +
+ theme_void()
+```
+
+```{r}
+#| warning: false
+st_network_cost(divided_net, c(1, 12, 17), c(1, 12, 17))
+```
+
+In many cases, it is a good idea to first extract the largest connected component from your network before computing routes. This can be done by the morpher `tidygraph::to_largest_component()`.
+
+```{r}
+# Extract only the largest connected component.
+component = convert(divided_net, to_largest_component)
+```
+
+```{r}
+#| layout-ncol: 2
+#| fig-cap:
+#| - "Full network"
+#| - "Largest component"
+ggraph(divided_net, "sf") +
+ geom_edge_sf() +
+ geom_node_sf(size = 4) +
+ theme_void()
+
+ggraph(component, "sf") +
+ geom_edge_sf() +
+ geom_node_sf(size = 4) +
+ theme_void()
+```
+
+If you do not want to extract the shortest paths as linestring geometries, but rather subset the network to only keep those nodes and edges that are in the path, the `to_spatial_shortest_paths()` morpher is your choice. It accepts the same arguments as `st_network_paths()`, but will return a filtered network for each requested path. The nodes and edges tables of each sub-network are arranged by the order in which they appear in the path.
+
+```{r}
+paths = net |>
+ morph(to_spatial_shortest_paths, 1, c(12, 16, 17)) |>
+ crystallize()
+
+paths
+```
+```{r}
+#| layout-ncol: 3
+#| fig-cap:
+#| - "Path 1 -> 12"
+#| - "Path 1 -> 16"
+#| - "Path 1 -> 17"
+purrr::walk(paths$graph, plot, cex = 4)
+```
+
+## Choosing a routing backend
+
+As mentioned, `{sfnetwork}` does not implement the routing algorithms themselves. For that, it relies on other packages, which we call "routing backends" or "routers" for short. The default routing backend is `{igraph}`. The `st_network_paths()` function wraps, depending on argument settings, `igraph::shortest_paths()`, `igraph::all_shortest_paths()` and `igraph::k_shortest_paths()`. The `st_network_cost()` function wraps `igraph::distances()`. The benefits of this routing backend are: 1) it can compute all shortest paths, if there exists more than one; 2) it can compute the $k$ shortest paths with Yen's algorithm; 3) no internal conversion between data structures is needed. However, routing is only supported from a single origin. Hence, many-to-many routing is not possible.
+
+The second routing backend that is currently supported is `{dodgr}`. This package implements a very fast routing algorithm in C++. This is exposed in `{sfnetworks}` by wrapping `dodgr::dodgr_paths()` in `st_network_paths()` and `dodgr::dodgr_dists()` in `st_network_cost()`. The benefits of this routing backend are: 1) it can route from multiple origins to multiple destinations, i.e. many-to-many routing; 2) it supports dual-weighted routing (see [here](#dual-weighted-routing)); 3) it is very fast also on large networks. However, there is currently no support for $k$ shortest paths routing, and some internal conversions are needed to bridge between the different data structures.
+
+You can specify which routing backend to use through the `router` argument. If you want to change the default routing backend, update the global options: `options(sfn_default_router = "dodgr")`.
+
+## Specifying origins and destinations
+
+The nodes to route from (i.e. the origins) and the nodes to route to (i.e. the destinations) should be referenced by their integer index, which corresponds to their rownumber in the nodes table of the network. However, `{sfnetworks}` allows you to reference nodes also in different ways, and it will internally find the corresponding node indices for you. This is called a *sfnetwork node query*, and it is evaluated by `evaluate_node_query()`. However, you will normally never call that function directly as a user. Instead, you simply specify your node query as argument value of the `from` and `to` arguments. The query can be formatted as follows:
+
+- As **spatial features**: Spatial features can be given as object of class `sf` or `sfc`. The nearest node to each feature is found by calling `nearest_nodes()`.
+- As **node type query function**: A [node type query](https://tidygraph.data-imaginist.com/reference/node_types.html) is a specific type of a node measure function that defines for each node if it is of a given type or not. Nodes that meet the criterium are queried.
+- As **node predicate query function**: A [node predicate query](https://luukvdmeer.github.io/sfnetworks/reference/spatial_node_predicates.html) is a specific type of node measure function that defines for each node if a given spatial predicate applies to the spatial relation between that node and other spatial features. Nodes that meet the criterium are queried.
+- As **column name**: The referenced column is expected to have logical values defining for each node if it should be queried or not. Tidy evaluation is used and hence the column name should be unquoted.
+- As **integers**: Integers are interpreted as node indices. A node index corresponds to a row-number in the nodes table of the network.
+- As **characters**: Characters are interpreted as node names. A node name corresponds to a value in a column named *name* in the the nodes table of the network. Note that this column is expected to store unique names without any duplicated values.
+- As **logicals**: Logicals should define for each node if it should be queried or not.
+
+```{r}
+pt = st_centroid(st_combine(mozart))
+pl = st_buffer(mozart[15, ], 350)
+
+paths_A = st_network_paths(
+ net,
+ from = pt,
+ to = centrality_degree() < 3
+)
+
+paths_B = st_network_paths(
+ net,
+ from = "Mozartkino",
+ to = node_is_within(pl)
+)
+```
+
+```{r}
+#| layout-ncol: 2
+ggraph(net, "sf") +
+ geom_edge_sf(color = "darkgrey") +
+ geom_sf(data = paths_A, color = "orange", linewidth = 2) +
+ geom_node_sf(color = "darkgrey", size = 4) +
+ geom_sf(data = node_data(net)[unique(paths_A$from), ], size = 6) +
+ geom_sf(data = node_data(net)[unique(paths_A$to), ], pch = 15, size = 6) +
+ geom_sf(data = pt, color = "skyblue", size = 6) +
+ theme_void()
+
+ggraph(net, "sf") +
+ geom_sf(data = pl, fill = "skyblue", alpha = 0.8) +
+ geom_edge_sf(color = "darkgrey") +
+ geom_sf(data = paths_B, color = "orange", linewidth = 2) +
+ geom_node_sf(color = "darkgrey", size = 4) +
+ geom_sf(data = node_data(net)[unique(paths_B$from), ], size = 6) +
+ geom_sf(data = node_data(net)[unique(paths_B$to), ], pch = 15, size = 6) +
+ theme_void()
+```
+
+## Specifying edge weights
+
+What the shortest path is, depends on the weight of each edge. The higher the weight, the longer the edge. In spatial network analysis, it often makes sense to use geographic length as edge weights, such that the shortest path is the path with the shortest geographic distance. Therefore, `{sfnetworks}` uses this as a default. It is possible to specify different edge weights through the `weights` argument. Weights should be specified as a numeric vector of the same length as the number of edges in the network. However, `{sfnetworks}` allows you to specify them also in different ways, and it will internally compute the numeric vector for you. This is called a *sfnetwork edge specification*, and it is evaluated by `evaluate_weight_spec()`. However, you will normally never call that function directly as a user. Instead, you simply provide your specification as argument value of the `weights` argument. The specification can be formatted as follows:
+
+- As **edge measure function**: A [spatial edge measure function](https://luukvdmeer.github.io/sfnetworks/reference/spatial_edge_measures.html) computes a given measure for each edge, which will then be used as edge weights. By default the `edge_length()` measure is used in all routing functions.
+- As **column name**: A column in the edges table of the network that contains the edge weights. Tidy evaluation is used and hence the column name should be unquoted.
+- As **numeric vector**: This vector should be of the same length as the number of edges in the network, specifying for each edge what its weight is.
+- As **dual weights**: Dual weights can be specified by the `dual_weights()` function. This allows to use a different set of weights for shortest paths computation and for reporting the total cost of those paths. Only the `{dodgr}` routing backend supports dual-weighted routing. See [here](#dual-weighted-routing) for an example.
+
+```{r}
+# Assign each edge a speed.
+# Some edges will have a higher speed.
+# Based on this, compute the travel time of each edge.
+speeds = set_units(rep(15, n_edges(net)), "km/h")
+speeds[c(17, 25)] = set_units(20, "km/h")
+
+net = net |>
+ activate(edges) |>
+ mutate(speed = speeds) |>
+ mutate(time = edge_length() / speed)
+
+# Now compute the shortest and the fastest routes.
+shortest = st_network_paths(net, 1, 17, weights = edge_length()) # The default.
+fastest = st_network_paths(net, 1, 17, weights = time)
+```
+
+```{r}
+#| layout-ncol: 2
+#| layout-nrow: 2
+#| fig-cap:
+#| - "Edge length"
+#| - "Edge speed"
+#| - "Shortest route"
+#| - "Fastest route"
+ggraph(net, "sf") +
+ geom_edge_sf(aes(color = drop_units(edge_length())), linewidth = 2) +
+ geom_node_sf(size = 4) +
+ scale_edge_color_continuous("length [m]") +
+ theme_void()
+
+ggraph(net, "sf") +
+ geom_edge_sf(aes(color = drop_units(speed)), linewidth = 2) +
+ geom_node_sf(size = 4) +
+ scale_edge_color_continuous("speed [km/h]") +
+ theme_void()
+
+ggraph(net, "sf") +
+ geom_edge_sf(color = "darkgrey") +
+ geom_sf(data = shortest, color = "orange", linewidth = 2) +
+ geom_node_sf(color = "darkgrey", size = 4) +
+ geom_sf(data = node_data(net)[1, ], size = 6) +
+ geom_sf(data = node_data(net)[17, ], pch = 15, size = 6) +
+ theme_void()
+
+ggraph(net, "sf") +
+ geom_edge_sf(color = "darkgrey") +
+ geom_sf(data = fastest, color = "orange", linewidth = 2) +
+ geom_node_sf(color = "darkgrey", size = 4) +
+ geom_sf(data = node_data(net)[1, ], size = 6) +
+ geom_sf(data = node_data(net)[17, ], pch = 15, size = 6) +
+ theme_void()
+```
+
+It is important to note that while all functions in `{sfnetworks}` that consider edge weights will use `edge_length()` as the default, this is **not** the case in `{tidygraph}`. Here you should always explicitly specify the weights, for example when computing betweenness centrality using `tidygraph::centrality_betweenness()`. Otherwise, each edge will be weighted by 1, and the shortest path is the path with the fewest number of edges. Furthermore, it is important to know that `{igraph}` has a different behavior regarding edge weights. When `weights = NULL`, they will look for an edge attribute named *weight* and use that whenever present. `{tidygraph}`, and therefore also `{sfnetworks}`, do not do this, and always default to the edge weights of 1 whenever `weights = NULL`.
+
+## Applications
+
+In this section, we will show a small set of applications of the routing related functions. It is not meant to be an overview that covers everything. Also, remember that `{sfnetworks}` is a general-purpose spatial network analysis package not optimized for a specific application. However, especially in combination with other packages it can address a wide variety of use-cases.
+
+### K shortest paths
+
+Thanks to the `{igraph}` routing backend, we can compute not only the shortest path between two nodes, but also the next $k-1$ shortest paths. For this, set the `k` argument of `st_network_paths()` to any integer higher than 1. This is only supported for one-to-one routing, i.e. a single origin and a single destination.
+
+```{r}
+paths = st_network_paths(net, 1, 17, k = 3)
+paths
+```
+
+```{r}
+paths = paths |>
+ mutate(k = c(1:n())) |>
+ arrange(desc(k))
+
+ggraph(net, "sf") +
+ geom_edge_sf(color = "darkgrey") +
+ geom_sf(data = paths, aes(color = as.factor(k)), linewidth = 2) +
+ geom_node_sf(color = "darkgrey", size = 4) +
+ geom_sf(data = node_data(net)[1, ], size = 6) +
+ geom_sf(data = node_data(net)[17, ], pch = 15, size = 6) +
+ scale_color_discrete("k") +
+ theme_void()
+```
+
+### Closest facility analysis
+
+The purpose of closest facility analysis is, given a set of destination locations (also referred to as the *facilities*) and origin locations (also referred to as the *sites*), to find the closest $n$ facilities to each site. For example, you might want to find the nearest transit hub for each address in a city, or the nearest hospital to high-risk road intersections.
+
+To solve this problem, you can calculate the cost matrix with the sites as origins, and the facilities as destinations points. Then, for each row (i.e. each site) you find the column(s) with the lowest cost value. Note that each facility and each site is represented by its nearest node in the network. First blending them into the network using `st_network_blend()` gives the most accurate results.
+
+```{r}
+#| warning: false
+# Create a network.
+net = as_sfnetwork(roxel, directed = FALSE) |>
+ st_transform(3035)
+
+# Create some facility locations spread over the area.
+facilities = st_sfc(
+ st_point(c(4151100, 3207700)),
+ st_point(c(4151040, 3206660)),
+ st_point(c(4151800, 3207200)),
+ st_point(c(4152000, 3207600)),
+ crs = 3035
+)
+
+# Select a random set of sites.
+sites = st_sample(st_convex_hull(st_combine(net)), 50)
+
+# Blend the sites and facilities into the network to get better results.
+# Also select only the largest connected component.
+# Such that we avoid points being blended to a small disconnected component.
+net = net |>
+ convert(to_largest_component, .clean = TRUE) |>
+ st_network_blend(c(sites, facilities))
+
+# Calculate the cost matrix.
+mat = net |>
+ st_network_cost(from = sites, to = facilities)
+
+# Find for each site which facility is closest.
+closest = facilities[apply(mat, 1, function(x) which(x == min(x))[1])]
+```
+
+```{r}
+# Create a line between each site and its closest facility, for visualization.
+draw_lines = function(sources, targets) {
+ f = function(a, b) st_sfc(st_cast(c(a, b), "LINESTRING"))
+ lines = do.call("c", mapply(f, sources, targets, SIMPLIFY = FALSE))
+ st_crs(lines) = st_crs(sources)
+ lines
+}
+
+connections = draw_lines(sites, closest)
+
+ggraph(net, "sf") +
+ geom_edge_sf(color = "grey") +
+ geom_node_sf(color = "grey") +
+ geom_sf(data = connections, color = "orange") +
+ geom_sf(data = sites, color = "orange", size = 4) +
+ geom_sf(data = facilities, color = "skyblue", size = 6) +
+ theme_void()
+```
+
+### Traveling salesman problem
+
+The traveling salesman problem aims to find the shortest tour that visits every location in a set exactly once. To solve this, `{sfnetworks}` provides an interface to the `TSP::TSP()` function. This requires the `{TSP}` package to be installed.
+
+The `st_network_travel()` function returns a `sf` object similar to the output of `st_network_paths()`. Each row represent one leg of the route, from one location to the next. Note that each location to visit is represented by its nearest node in the network. First blending them into the network using `st_network_blend()` gives the most accurate results.
+
+```{r}
+# We will use the facilities from the previous section as visiting locations.
+# They are already blended into the largest component of the network.
+route = st_network_travel(net, facilities)
+route
+```
+
+```{r}
+ggraph(net, "sf") +
+ geom_edge_sf(color = "darkgrey") +
+ geom_node_sf(color = "darkgrey") +
+ geom_sf(data = route, color = "orange", linewidth = 2) +
+ geom_sf(data = facilities, size = 6) +
+ theme_void()
+```
+
+### Isodistance polygons
+
+With respect to a given point $p$ and a given distance $d$, an isodistance is the line for which it holds that the distance from any point on the line from $p$ is equal to $d$. In spatial network analysis, it is common to find all nodes that fall within an isodistance line computed for a given source node $p$. By enclosing this nodes with a concave hull, we obtain a *isodistance polygon*.
+
+This workflow is implemented in the function `st_network_iso()`. It will first compute the distance matrix from a given source node $p$ (which can be specified as explained [here](#specifying-origins-and-destinations)) to all other nodes in the network using `st_network_cost()`, and then filter those nodes that are within a given distance $d$. Using `sf::st_concave_hull()`, it will compute the concave hull around those nodes to obtain an isodistance polygon. Multiple distance thresholds can be given, resulting in multiple isodistance polygons being drawn. The output of the function is an `sf` object with one row per requested polygon.
+
+By setting the `ratio` argument (a value between 0 and 1, with 1 being the convex hull), we can increase or decrease the level of detail of the polygons. By default, no holes are allowed in the polygons, but this can be changed setting `allow_holes = TRUE`. Do note that changing the ratio requires a GEOS version of at least 3.11. GEOS is a C/C++ library for computational geometry used by `{sf}`.
+
+```{r}
+# Define the source.
+# This point will be snapped to its nearest node.
+pt = st_centroid(st_transform(st_as_sfc(st_bbox(roxel)), 3035))
+
+# Compute isodistance polygons for four different thresholds.
+polys = st_network_iso(net, pt, rev(c(100, 250, 500, 1000)))
+polys
+```
+
+```{r}
+ggraph(net, "sf") +
+ geom_edge_sf(color = "darkgrey") +
+ geom_node_sf(color = "darkgrey") +
+ geom_sf(data = polys, aes(fill = drop_units(cost)), alpha = 0.5) +
+ scale_fill_continuous("distance [m]") +
+ theme_void()
+```
+
+A different approach can be taken with the morpher `to_spatial_neighborhood()`. Instead of drawing polygons, this function will subset the network to contain only those nodes inside the isodistance line, and the edges that connect them.
+
+```{r}
+sub_net = convert(net, to_spatial_neighborhood, pt, 500)
+```
+
+```{r}
+plot(net, col = "darkgrey")
+plot(sub_net, col = "orange", lwd = 2, cex = 1, add = TRUE)
+```
+
+Both `st_network_iso()` and `to_spatial_neighborhood()` allow to specify other edge weights than geographic distance. This can be done through the `weights` argument, as explained [here](#specifying-edge-weights). When using time as edge weight, we talk about *isochrones* instead of *isodistances*.
+
+### Custom routing
+
+In many cases the shortest path based geographical distances or travel time is not necessarily the optimal path. The most appropriate route may depend on many factors, for example staying away from large and potentially unpleasant roads for motor traffic. All networks have different characteristics. In OpenStreetMap networks the *highway* attribute is often a good (albeit crude) approximation of road type. The `roxel` demo dataset is derived from OpenStreetMap and stores the *highway* attribute in the *type* column. We can see Roxel has a largely residential road network:
+
+```{r}
+roxel |>
+ count(type)
+```
+```{r}
+ggraph(net, "sf") +
+ geom_edge_sf(aes(color = as.factor(type)), linewidth = 2) +
+ geom_node_sf() +
+ scale_edge_color_discrete("type") +
+ theme_void()
+```
+
+Building on the shortest paths calculated in a previous section we can try an alternative routing profile. For the purposes of illustration, lets imagine we're routing for a vehicle that we want to keep away from residential roads.
+
+```{r}
+weighting_profile = c(
+ cycleway = Inf,
+ footway = Inf,
+ path = Inf,
+ residential = 3,
+ secondary = 1,
+ service = 3,
+ track = 10,
+ unclassified = 10
+)
+
+net = net |>
+ activate(edges) |>
+ mutate(multiplier = ifelse(is.na(type), 1, weighting_profile[type])) |>
+ mutate(weight = drop_units(edge_length() * multiplier))
+```
+
+We can now compare the shortest path with the optimal path according to our weighting profile.
+
+```{r}
+shortest = st_network_paths(net, 452, 212)
+optimal = st_network_paths(net, 452, 212, weights = weight)
+```
+
+```{r}
+#| layout-ncol: 2
+#| fig-cap:
+#| - "Shortest route"
+#| - "Optimal route"
+ggraph(net, "sf") +
+ geom_edge_sf(color = "darkgrey") +
+ geom_node_sf(color = "darkgrey") +
+ geom_sf(data = shortest, color = "orange", linewidth = 2) +
+ geom_sf(data = node_data(net)[452, ], size = 6) +
+ geom_sf(data = node_data(net)[212, ], pch = 15, size = 6) +
+ theme_void()
+
+ggraph(net, "sf") +
+ geom_edge_sf(color = "darkgrey") +
+ geom_node_sf(color = "darkgrey") +
+ geom_sf(data = optimal, color = "orange", linewidth = 2) +
+ geom_sf(data = node_data(net)[452, ], size = 6) +
+ geom_sf(data = node_data(net)[212, ], pch = 15, size = 6) +
+ theme_void()
+```
+
+Note that developing more sophisticated routing profiles is beyond the scope of this package.
+If you need complex mode-specific routing profiles, we recommend looking at routing profiles associated with open source routing engines such as OSRM, or the `{dodgr}` R package. Another direction of travel could be to extend on the approach illustrated here, but this work could be well-suited to a separate package that builds on `{sfnetworks}`, possibly integrating the routing profiles of `{dodgr}`. If you'd like to work on such a project to improve mode-specific routing in R by building on this package, please let us know in the [discussion room](https://github.com/luukvdmeer/sfnetworks/discussions)!
+
+### Dual-weighted routing
+
+If many cases we may be interested in obtaining a cost matrix that stores geographic distances of the most optimal routes according to some custom set of edge weights. That is, we want to use the custom weights to define what the "shortest" path is, but then report the actual geographic distance of that path. This is called *dual-weighted routing*. The `{dodgr}` routing backend supports it. `{sfnetworks}` offers the `dual_weights()` function to allow you to specify dual weights to the `weights` argument of the routing functions. It accepts two sets of weights, which can both be specified by any of the ways explained in [this section](#specifying-edge-weights). The first set of weights are the *reported* weights, and the second set of weights are the *actual* weights used to define the optimal path.
+
+```{r}
+# Cost matrix reporting the custom weights.
+st_network_cost(
+ net, seq(1, 901, by = 150), seq(1, 901, by = 150),
+ weights = weight,
+ router = "dodgr"
+) |> round()
+```
+
+```{r}
+# Cost matrix using custom weights but reporting real distances.
+st_network_cost(
+ net, seq(1, 901, by = 150), seq(1, 901, by = 150),
+ weights = dual_weights(edge_length(), weight),
+ router = "dodgr"
+) |> round()
+```
+
+```{r}
+#| include: false
+par(oldpar)
+options(oldoptions)
+```