From a010ad4e4982c97602768c3c3d49922b24cf3a89 Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Wed, 17 Jul 2024 16:35:28 +0200 Subject: [PATCH 001/246] refactor: Simplify construction after tidygraph updates :construction: --- R/sfnetwork.R | 56 +++++++++++++++++---------------------------------- 1 file changed, 18 insertions(+), 38 deletions(-) diff --git a/R/sfnetwork.R b/R/sfnetwork.R index 889797de..e956dcda 100644 --- a/R/sfnetwork.R +++ b/R/sfnetwork.R @@ -123,20 +123,8 @@ sfnetwork = function(nodes, edges = NULL, directed = TRUE, node_key = "name", } ) } - # 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_tbg = tbl_graph(nodes, edges, 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. @@ -147,27 +135,24 @@ sfnetwork = function(nodes, edges = NULL, directed = TRUE, node_key = "name", 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) { + 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 = implicitize_edges(x_sfn) } # Run validity check after implicitizing edges. if (! force) require_valid_network_structure(x_sfn, message = message) + } else { + # Run validity check before explicitizing edges. + if (! force) require_valid_network_structure(x_sfn, message = message) + # Add edge geometries if requested. + if (isTRUE(edges_as_lines)) { + x_sfn = explicitize_edges(x_sfn) + } } if (length_as_weight) { edges = edges_as_sf(x_sfn) @@ -186,15 +171,10 @@ sfnetwork = function(nodes, edges = NULL, directed = TRUE, node_key = "name", #' @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) + x_tbg = tbl_graph(nodes, edges, directed) if (! is.null(edges)) { - edge_geom_colname = attr(edges, "sf_column") - edge_agr = attr(edges, "agr") + 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))) } From 900ad712fe35d5b890d655cf8c9066730fe40731 Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Wed, 17 Jul 2024 16:43:14 +0200 Subject: [PATCH 002/246] feat: Export network validation function. Refs #93 :gift: --- DESCRIPTION | 1 + NAMESPACE | 3 + R/require.R | 148 ------------------------------------- R/sf.R | 2 +- R/sfnetwork.R | 6 +- R/validate.R | 160 ++++++++++++++++++++++++++++++++++++++++ man/validate_network.Rd | 27 +++++++ 7 files changed, 195 insertions(+), 152 deletions(-) delete mode 100644 R/require.R create mode 100644 R/validate.R create mode 100644 man/validate_network.Rd diff --git a/DESCRIPTION b/DESCRIPTION index 549742f2..d041262b 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -34,6 +34,7 @@ BugReports: https://github.com/luukvdmeer/sfnetworks/issues/ Depends: R (>= 3.6) Imports: + cli, crayon, dplyr, graphics, diff --git a/NAMESPACE b/NAMESPACE index 551dc8eb..9f4d1191 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -107,6 +107,9 @@ export(to_spatial_smooth) export(to_spatial_subdivision) export(to_spatial_subset) export(to_spatial_transformed) +export(validate_network) +importFrom(cli,cli_alert) +importFrom(cli,cli_alert_success) importFrom(crayon,silver) importFrom(dplyr,across) importFrom(dplyr,bind_rows) diff --git a/R/require.R b/R/require.R deleted file mode 100644 index d5a0fdd4..00000000 --- a/R/require.R +++ /dev/null @@ -1,148 +0,0 @@ -#' Proceed only when a given network element is active -#' -#' @details These function are meant to be called in the context of an -#' operation in which the network that is currently being worked on is known -#' and thus not needed as an argument to the function. -#' -#' @return Nothing when the expected network element is active, an error -#' message otherwise. -#' -#' @name require_active -#' @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 - ) - } -} - -#' @name require_active -#' @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 - ) - } -} - -#' Proceed only when edges are spatially explicit -#' -#' @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. -#' -#' @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 - ) - } - } -} diff --git a/R/sf.R b/R/sf.R index a83b7192..d16ce2e9 100644 --- a/R/sf.R +++ b/R/sf.R @@ -129,7 +129,7 @@ st_geometry.sfnetwork = function(obj, active = NULL, ...) { x_new = drop_geom(x) } else { x_new = mutate_geom(x, value) - require_valid_network_structure(x_new) + validate_network(x_new, message = FALSE) } x_new } diff --git a/R/sfnetwork.R b/R/sfnetwork.R index e956dcda..7d3792dd 100644 --- a/R/sfnetwork.R +++ b/R/sfnetwork.R @@ -132,7 +132,7 @@ sfnetwork = function(nodes, edges = NULL, directed = TRUE, node_key = "name", # --> 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) + if (! force) validate_network(x_sfn, message = message) return (x_sfn) } if (is.sf(edges)) { @@ -145,10 +145,10 @@ sfnetwork = function(nodes, edges = NULL, directed = TRUE, node_key = "name", x_sfn = implicitize_edges(x_sfn) } # Run validity check after implicitizing edges. - if (! force) require_valid_network_structure(x_sfn, message = message) + if (! force) validate_network(x_sfn, message = message) } else { # Run validity check before explicitizing edges. - if (! force) require_valid_network_structure(x_sfn, message = message) + if (! force) validate_network(x_sfn, message = message) # Add edge geometries if requested. if (isTRUE(edges_as_lines)) { x_sfn = explicitize_edges(x_sfn) diff --git a/R/validate.R b/R/validate.R new file mode 100644 index 00000000..26a0a8a1 --- /dev/null +++ b/R/validate.R @@ -0,0 +1,160 @@ +#' Validate the structure of a sfnetwork object +#' +#' @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_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 (! has_single_geom_type(nodes, "POINT")) { + stop( + "Not all nodes have geometry type POINT", + call. = FALSE + ) + } + 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 (! has_single_geom_type(edges, "LINESTRING")) { + stop( + "Not all edges have geometry type LINESTRING", + call. = FALSE + ) + } + 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)) { + stop( + "Nodes and edges do not have the same coordinate reference system", + call. = FALSE + ) + } + 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)) { + stop( + "Nodes and edges do not have the same coordinate precision", + call. = FALSE + ) + } + 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 match start node. + # End point should match end node. + if (! all(nodes_match_edge_boundaries(x))) { + stop( + "Node locations do not match edge boundaries", + 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( + "Node locations do not match edge boundaries", + call. = FALSE + ) + } + } + if (message) cli_alert_success("Node locations match edge boundaries") + } + if (message) cli_alert_success("Spatial network structure is valid") +} + +#' Proceed only when a given network element is active +#' +#' @details These function are meant to be called in the context of an +#' operation in which the network that is currently being worked on is known +#' and thus not needed as an argument to the function. +#' +#' @return Nothing when the expected network element is active, an error +#' message otherwise. +#' +#' @name require_active +#' @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 + ) + } +} + +#' @name require_active +#' @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 + ) + } +} + +#' Proceed only when edges are spatially explicit +#' +#' @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. +#' +#' @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 messages be printed during validation? Defaults to +#' \code{TRUE}. +#' +#' @return Nothing when the network has a valid sfnetwork structure, an error +#' message otherwise. +#' +#' @noRd +require_valid_network_structure = function(x, message = FALSE) { + validate_network(x, message) +} diff --git a/man/validate_network.Rd b/man/validate_network.Rd new file mode 100644 index 00000000..f2928c62 --- /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 object} +\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 object +} +\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. +} From 52127cd6bb238537608ffc382af1d9fa04ab483c Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Wed, 17 Jul 2024 16:53:12 +0200 Subject: [PATCH 003/246] refactor: Merge internal attribute names functions :construction: --- R/agr.R | 9 +++--- R/attrs.R | 90 ++++++++++++++++++------------------------------------- 2 files changed, 33 insertions(+), 66 deletions(-) diff --git a/R/agr.R b/R/agr.R index b11c1e8b..7f9a0d27 100644 --- a/R/agr.R +++ b/R/agr.R @@ -30,7 +30,8 @@ agr = function(x, active = NULL) { #' @noRd node_agr = function(x) { agr = attr(vertex_attr(x), "agr") - make_agr_valid(agr, names = node_feature_attribute_names(x)) + colnames = node_attribute_names(x, geom = FALSE) + make_agr_valid(agr, names = colnames) } #' @name agr @@ -38,10 +39,8 @@ node_agr = function(x) { #' @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 + colnames = edge_attribute_names(x, idxs = TRUE, geom = FALSE) + make_agr_valid(agr, names = colnames) } #' @name agr diff --git a/R/attrs.R b/R/attrs.R index 3d0518ec..ceff6459 100644 --- a/R/attrs.R +++ b/R/attrs.R @@ -112,38 +112,25 @@ sf_attr = function(x, name, active = NULL) { #' @param active Either 'nodes' or 'edges'. If \code{NULL}, the currently #' active element of x will be used. #' -#' @return A character vector. +#' @param idxs Should the columns storing indices of start and end nodes in the +#' edges table (i.e. the from and to columns) be considered attribute columns? +#' Defaults to \code{FALSE}. +#' +#' @param geom Should the geometry column be considered an attribute column? +#' Defaults to \code{TRUE}. #' -#' @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. +#' @return A character vector. #' #' @name attr_names #' @noRd -attribute_names = function(x, active = NULL) { +attribute_names = function(x, active = NULL, idxs = FALSE, geom = TRUE) { if (is.null(active)) { active = attr(x, "active") } switch( active, - nodes = node_attribute_names(x), - edges = edge_attribute_names(x), + nodes = node_attribute_names(x, geom = geom), + edges = edge_attribute_names(x, idxs = idxs, geom = geom), raise_unknown_input(active) ) } @@ -151,48 +138,29 @@ attribute_names = function(x, active = NULL) { #' @name attr_names #' @noRd #' @importFrom igraph vertex_attr_names -node_attribute_names = function(x) { - vertex_attr_names(x) +node_attribute_names = function(x, geom = TRUE) { + attrs = vertex_attr_names(x) + if (! geom) { + attrs = attrs[attrs != node_geom_colname(x)] + } + attrs } #' @name attr_names #' @noRd #' @importFrom igraph edge_attr_names -edge_attribute_names = function(x) { - edge_attr_names(x) -} - -#' @name attr_names -#' @noRd -feature_attribute_names = function(x, active = NULL) { - if (is.null(active)) { - active = attr(x, "active") +edge_attribute_names = function(x, idxs = FALSE, geom = TRUE) { + attrs = edge_attr_names(x) + if (idxs) { + attrs = c("from", "to", attrs) } - switch( - active, - nodes = node_feature_attribute_names(x), - edges = edge_feature_attribute_names(x), - raise_unknown_input(active) - ) -} - -#' @name attr_names -#' @noRd -node_feature_attribute_names = function(x) { - g_attrs = node_attribute_names(x) - g_attrs[g_attrs != node_geom_colname(x)] -} - -#' @name attr_names -#' @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]) + if (! geom) { + geom_colname = edge_geom_colname(x) + if (! is.null(geom_colname)) { + attrs = attrs[attrs != geom_colname] + } } + attrs } #' Set or replace attribute column values of the active element of a sfnetwork @@ -209,9 +177,9 @@ edge_feature_attribute_names = function(x) { #' #' @return An object of class \code{\link{sfnetwork}} with updated attributes. #' -#' @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. +#' @details For these functions, 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. #' #' @name attr_values #' @noRd From 7e6d0f588b576a3301cf388d9f4fc11c0e29f459 Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Wed, 17 Jul 2024 16:54:44 +0200 Subject: [PATCH 004/246] refactor: Keep agr when implicitizing edges :construction: --- R/geom.R | 1 - 1 file changed, 1 deletion(-) diff --git a/R/geom.R b/R/geom.R index 0702eb6a..b081e0da 100644 --- a/R/geom.R +++ b/R/geom.R @@ -213,6 +213,5 @@ drop_edge_geom = function(x) { } x_new = delete_edge_attr(x, edge_geom_colname(x)) edge_geom_colname(x_new) = NULL - edge_agr(x_new) = NULL x_new } From 63003ba65b1bee0d9c636b0741ef1f65596a0ab8 Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Wed, 17 Jul 2024 19:20:59 +0200 Subject: [PATCH 005/246] refactor: Setup structure for network creation from points. Refs #52 :construction: --- NAMESPACE | 3 +- R/create.R | 111 ++++++++++++++++++++++++++++++ R/sfnetwork.R | 102 ++++++++++++++------------- R/utils.R | 81 ---------------------- man/as_sfnetwork.Rd | 79 +++++++++++++-------- man/create_from_spatial_lines.Rd | 23 +++++++ man/create_from_spatial_points.Rd | 25 +++++++ 7 files changed, 264 insertions(+), 160 deletions(-) create mode 100644 R/create.R create mode 100644 man/create_from_spatial_lines.Rd create mode 100644 man/create_from_spatial_points.Rd diff --git a/NAMESPACE b/NAMESPACE index 9f4d1191..b47f6ff3 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -9,7 +9,6 @@ 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) @@ -62,6 +61,8 @@ export("%>%") export(activate) export(active) export(as_sfnetwork) +export(create_from_spatial_lines) +export(create_from_spatial_points) export(edge_azimuth) export(edge_circuity) export(edge_contains) diff --git a/R/create.R b/R/create.R new file mode 100644 index 00000000..25b85230 --- /dev/null +++ b/R/create.R @@ -0,0 +1,111 @@ +#' 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. +#' +#' @details It is assumed that the given lines geometries form the edges in the +#' network. Nodes are created at the boundary points of the edges. Boundary +#' points at equal locations become the same node. +#' +#' @return An object of class \code{\link{sfnetwork}}. +#' +#' @importFrom sf st_as_sf st_sf +#' @export +create_from_spatial_lines = function(x, ...) { + # The provided lines will form the edges of the network. + edges = st_as_sf(x) + # 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 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 = st_sf(geometry = nodes) + # Use the same sf column name in the nodes as in the edges. + geom_colname = attr(edges, "sf_column") + if (geom_colname != "geometry") { + names(nodes)[1] = geom_colname + attr(nodes, "sf_column") = geom_colname + } + # Use the same class for the nodes as for the edges. + # This mainly affects the "lower level" classes. + # For example an sf tibble instead of a sf data frame. + class(nodes) = class(edges) + # Create a network out of the created nodes and the provided edges. + # The ... arguments are forwarded to the sfnetwork construction function. + # Force to skip network validity tests because we already know they pass. + sfnetwork(nodes, edges, 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 method The method used to connect the given point geometries to each +#' other. +#' +#' @details It is assumed that the given points form the nodes in the network. +#' How those nodes are connected by edges depends on the selected method. +#' +#' @return An object of class \code{\link{sfnetwork}}. +#' +#' @export +create_from_spatial_points = function(x, method = "sequence", ...) { + switch( + method, + sequence = create_spatial_sequence(x, ...), + raise_unknown_input(method) + ) +} + +#' Create as spatial network as sequentially connected nodes +#' +#' @param x An object of class \code{\link[sf]{sf}} or \\code{\link[sf]{sfc}} +#' with \code{POINT} geometries. +#' +#' @details It is assumed that the given points form the nodes in the network. +#' The nodes are sequentially connected by edges. +#' +#' @return An object of class \code{\link{sfnetwork}}. +#' +#' @importFrom sf st_as_sf st_geometry st_sf +#' @noRd +create_spatial_sequence = function(x, ...) { + # The provided points will form the nodes of the network. + nodes = st_as_sf(x) + # 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 in the edges as in the nodes. + geom_colname = attr(nodes, "sf_column") + if (geom_colname != "geometry") { + names(edges)[3] = geom_colname + attr(edges, "sf_column") = geom_colname + } + # Use the same class for the edges as for the nodes. + # This mainly affects the "lower level" classes. + # For example an sf tibble instead of a sf data frame. + class(nodes) = class(edges) + # Create a network out of the created nodes and the provided edges. + # The ... arguments are forwarded to the sfnetwork construction function. + # Force to skip network validity tests because we already know they pass. + sfnetwork(nodes, edges, force = TRUE, ...) +} \ No newline at end of file diff --git a/R/sfnetwork.R b/R/sfnetwork.R index 7d3792dd..1f99e3a8 100644 --- a/R/sfnetwork.R +++ b/R/sfnetwork.R @@ -191,14 +191,11 @@ tbg_to_sfn = function(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. +#' function, unless specified otherwise. #' #' @return An object of class \code{\link{sfnetwork}}. #' @@ -207,25 +204,32 @@ as_sfnetwork = function(x, ...) { UseMethod("as_sfnetwork") } -#' @name 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}}. +#' #' @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. +#' @describeIn as_sfnetwork Convert spatial features of class +#' \code{\link[sf]{sf}} directly into an \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 boundary points. All 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. All arguments are forwarded to +#' \code{\link{create_from_spatial_points}}. +#' #' @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) @@ -234,43 +238,25 @@ as_sfnetwork.default = function(x, ...) { #' 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) + create_from_spatial_lines(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) + create_from_spatial_points(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 +#' @describeIn as_sfnetwork Convert spatial linear networks of class +#' \code{\link[spatstat.linnet]{linnet}} directly into an +#' \code{\link{sfnetwork}}. This requires the +#' \code{\link[spatstat.geom]{spatstat.geom-package}} to be installed. +#' #' @examples #' # From a linnet object. #' if (require(spatstat.geom, quietly = TRUE)) { @@ -286,7 +272,11 @@ as_sfnetwork.linnet = function(x, ...) { as_sfnetwork(x_psp, ...) } -#' @name as_sfnetwork +#' @describeIn as_sfnetwork Convert spatial line segments of class +#' \code{\link[spatstat.geom]{psp}} directly into an \code{\link{sfnetwork}}. +#' The lines become the edges in the network, and nodes are placed at their +#' boundary points. +#' #' @examples #' # From a psp object. #' if (require(spatstat.geom, quietly = TRUE)) { @@ -311,14 +301,28 @@ as_sfnetwork.psp = function(x, ...) { as_sfnetwork(x_linestring, ...) } -#' @name as_sfnetwork +#' @describeIn as_sfnetwork Convert spatial geometries of class +#' \code{\link[sf]{sfc}} directly into an \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 boundary points. All 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. All 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), ...) } -#' @name as_sfnetwork +#' @describeIn as_sfnetwork Convert spatial networks of class +#' \code{\link[stplanr]{sfNetwork}} directly into an \code{\link{sfnetwork}}. +#' This will extract the edges as an \code{\link[sf]{sf}} object and re-create +#' the network structure. The directness of the provided object is preserved +#' unless specified otherwise through the \code{directed} argument. +#' #' @importFrom igraph is_directed #' @export as_sfnetwork.sfNetwork = function(x, ...) { @@ -332,13 +336,13 @@ as_sfnetwork.sfNetwork = function(x, ...) { do.call("as_sfnetwork.sf", args) } -#' @name as_sfnetwork -#' @export -as_sfnetwork.sfnetwork = function(x, ...) { - as_sfnetwork(as_tbl_graph(x), ...) -} - -#' @name as_sfnetwork +#' @describeIn as_sfnetwork Convert graph objects of class +#' \code{\link[tidygraph]{tbl_graph}} directly into an \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}}. The +#' directness of the provided object is preserved unless specified otherwise +#' through the \code{directed} argument. +#' #' @importFrom igraph is_directed #' @export as_sfnetwork.tbl_graph = function(x, ...) { diff --git a/R/utils.R b/R/utils.R index 2152342a..fa45f7d1 100644 --- a/R/utils.R +++ b/R/utils.R @@ -28,87 +28,6 @@ cat_subtle = function(...) { # nocov start cat(silver(...)) } # nocov end -#' Create edges from nodes -#' -#' @param nodes An object of class \code{\link[sf]{sf}} with \code{POINT} -#' geometries. -#' -#' @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. -#' -#' @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. -#' -#' @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) -} - -#' Create nodes from edges -#' -#' @param edges An object of class \code{\link[sf]{sf}} with \code{LINESTRING} -#' geometries. -#' -#' @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 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. -#' -#' @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) -} - #' Draw lines between two sets of points, row-wise #' #' @param x An object of class \code{\link[sf]{sfc}} with \code{POINT} diff --git a/man/as_sfnetwork.Rd b/man/as_sfnetwork.Rd index 714e4bff..068c1a04 100644 --- a/man/as_sfnetwork.Rd +++ b/man/as_sfnetwork.Rd @@ -8,7 +8,6 @@ \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{ @@ -26,41 +25,76 @@ as_sfnetwork(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{...}{Arguments passed on to the \code{\link{sfnetwork}} construction -function.} +function, unless specified otherwise.} } \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}}. + +\item \code{as_sfnetwork(sf)}: Convert spatial features of class +\code{\link[sf]{sf}} directly into an \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 boundary points. All 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. All arguments are forwarded to +\code{\link{create_from_spatial_points}}. + +\item \code{as_sfnetwork(linnet)}: Convert spatial linear networks of class +\code{\link[spatstat.linnet]{linnet}} directly into an +\code{\link{sfnetwork}}. This requires the +\code{\link[spatstat.geom]{spatstat.geom-package}} to be installed. + +\item \code{as_sfnetwork(psp)}: Convert spatial line segments of class +\code{\link[spatstat.geom]{psp}} directly into an \code{\link{sfnetwork}}. +The lines become the edges in the network, and nodes are placed at their +boundary points. + +\item \code{as_sfnetwork(sfc)}: Convert spatial geometries of class +\code{\link[sf]{sfc}} directly into an \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 boundary points. All 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. All arguments are forwarded to +\code{\link{create_from_spatial_points}}. + +\item \code{as_sfnetwork(sfNetwork)}: Convert spatial networks of class +\code{\link[stplanr]{sfNetwork}} directly into an \code{\link{sfnetwork}}. +This will extract the edges as an \code{\link[sf]{sf}} object and re-create +the network structure. The directness of the provided object 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 an \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}}. The +directness of the provided object 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) @@ -69,19 +103,6 @@ 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) - # From a linnet object. if (require(spatstat.geom, quietly = TRUE)) { as_sfnetwork(simplenet) diff --git a/man/create_from_spatial_lines.Rd b/man/create_from_spatial_lines.Rd new file mode 100644 index 00000000..f9122b3e --- /dev/null +++ b/man/create_from_spatial_lines.Rd @@ -0,0 +1,23 @@ +% 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, ...) +} +\arguments{ +\item{x}{An object of class \code{\link[sf]{sf}} or \\code{\link[sf]{sfc}} +with \code{LINESTRING} geometries.} +} +\value{ +An object of class \code{\link{sfnetwork}}. +} +\description{ +Create a spatial network from linestring geometries +} +\details{ +It is assumed that the given lines geometries form the edges in the +network. Nodes are created at the boundary points of the edges. Boundary +points at equal locations become the same node. +} diff --git a/man/create_from_spatial_points.Rd b/man/create_from_spatial_points.Rd new file mode 100644 index 00000000..59bb0cb0 --- /dev/null +++ b/man/create_from_spatial_points.Rd @@ -0,0 +1,25 @@ +% 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, method = "sequence", ...) +} +\arguments{ +\item{x}{An object of class \code{\link[sf]{sf}} or \\code{\link[sf]{sfc}} +with \code{POINT} geometries.} + +\item{method}{The method used to connect the given point geometries to each +other.} +} +\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 selected method. +} From 2a9e3282c1c68a0c531b15bbde3e6cd812cce3f3 Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Wed, 17 Jul 2024 21:51:32 +0200 Subject: [PATCH 006/246] docs: Tidy function documentation :books: --- R/create.R | 4 ++-- R/sfnetwork.R | 28 +++++++++++++++------------- man/as_sfnetwork.Rd | 24 +++++++++++++----------- man/create_from_spatial_lines.Rd | 2 +- man/create_from_spatial_points.Rd | 2 +- man/sfnetwork.Rd | 4 ++-- 6 files changed, 34 insertions(+), 30 deletions(-) diff --git a/R/create.R b/R/create.R index 25b85230..afa8b5ea 100644 --- a/R/create.R +++ b/R/create.R @@ -1,6 +1,6 @@ #' Create a spatial network from linestring geometries #' -#' @param x An object of class \code{\link[sf]{sf}} or \\code{\link[sf]{sfc}} +#' @param x An object of class \code{\link[sf]{sf}} or \code{\link[sf]{sfc}} #' with \code{LINESTRING} geometries. #' #' @details It is assumed that the given lines geometries form the edges in the @@ -47,7 +47,7 @@ create_from_spatial_lines = function(x, ...) { #' Create a spatial network from point geometries #' -#' @param x An object of class \code{\link[sf]{sf}} or \\code{\link[sf]{sfc}} +#' @param x An object of class \code{\link[sf]{sf}} or \code{\link[sf]{sfc}} #' with \code{POINT} geometries. #' #' @param method The method used to connect the given point geometries to each diff --git a/R/sfnetwork.R b/R/sfnetwork.R index 1f99e3a8..d0fa780f 100644 --- a/R/sfnetwork.R +++ b/R/sfnetwork.R @@ -15,9 +15,9 @@ #' \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. #' @@ -192,7 +192,7 @@ tbg_to_sfn = function(x) { #' #' Convert a given object into an object of class \code{\link{sfnetwork}}. #' -#' @param x Object to be converted into an \code{\link{sfnetwork}}. +#' @param x Object to be converted into a \code{\link{sfnetwork}}. #' #' @param ... Arguments passed on to the \code{\link{sfnetwork}} construction #' function, unless specified otherwise. @@ -217,7 +217,7 @@ as_sfnetwork.default = function(x, ...) { } #' @describeIn as_sfnetwork Convert spatial features of class -#' \code{\link[sf]{sf}} directly into an \code{\link{sfnetwork}}. +#' \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 boundary points. All arguments are forwarded to @@ -255,7 +255,8 @@ as_sfnetwork.sf = function(x, ...) { #' @describeIn as_sfnetwork Convert spatial linear networks of class #' \code{\link[spatstat.linnet]{linnet}} directly into an #' \code{\link{sfnetwork}}. This requires the -#' \code{\link[spatstat.geom]{spatstat.geom-package}} to be installed. +#' \code{\link[spatstat.geom:spatstat.geom-package]{spatstat.geom}} package +#' to be installed. #' #' @examples #' # From a linnet object. @@ -273,7 +274,7 @@ as_sfnetwork.linnet = function(x, ...) { } #' @describeIn as_sfnetwork Convert spatial line segments of class -#' \code{\link[spatstat.geom]{psp}} directly into an \code{\link{sfnetwork}}. +#' \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. #' @@ -302,7 +303,7 @@ as_sfnetwork.psp = function(x, ...) { } #' @describeIn as_sfnetwork Convert spatial geometries of class -#' \code{\link[sf]{sfc}} directly into an \code{\link{sfnetwork}}. +#' \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 boundary points. All arguments are forwarded to @@ -318,10 +319,11 @@ as_sfnetwork.sfc = function(x, ...) { } #' @describeIn as_sfnetwork Convert spatial networks of class -#' \code{\link[stplanr]{sfNetwork}} directly into an \code{\link{sfnetwork}}. -#' This will extract the edges as an \code{\link[sf]{sf}} object and re-create -#' the network structure. The directness of the provided object is preserved -#' unless specified otherwise through the \code{directed} argument. +#' \code{\link[stplanr:sfNetwork-class]{sfNetwork}} directly into a +#' \code{\link{sfnetwork}}. This will extract the edges as an +#' \code{\link[sf]{sf}} object and re-create the network structure. The +#' directness of the original network is preserved unless specified otherwise +#' through the \code{directed} argument. #' #' @importFrom igraph is_directed #' @export @@ -337,10 +339,10 @@ as_sfnetwork.sfNetwork = function(x, ...) { } #' @describeIn as_sfnetwork Convert graph objects of class -#' \code{\link[tidygraph]{tbl_graph}} directly into an \code{\link{sfnetwork}}. +#' \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}}. The -#' directness of the provided object is preserved unless specified otherwise +#' directness of the original graph is preserved unless specified otherwise #' through the \code{directed} argument. #' #' @importFrom igraph is_directed diff --git a/man/as_sfnetwork.Rd b/man/as_sfnetwork.Rd index 068c1a04..87ce3398 100644 --- a/man/as_sfnetwork.Rd +++ b/man/as_sfnetwork.Rd @@ -28,7 +28,7 @@ as_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, unless specified otherwise.} @@ -48,7 +48,7 @@ into a \code{\link[tidygraph]{tbl_graph}} using an \code{\link[sf]{sf}} object through \code{\link[sf]{st_as_sf}}. \item \code{as_sfnetwork(sf)}: Convert spatial features of class -\code{\link[sf]{sf}} directly into an \code{\link{sfnetwork}}. +\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 boundary points. All arguments are forwarded to @@ -60,15 +60,16 @@ specified method. All arguments are forwarded to \item \code{as_sfnetwork(linnet)}: Convert spatial linear networks of class \code{\link[spatstat.linnet]{linnet}} directly into an \code{\link{sfnetwork}}. This requires the -\code{\link[spatstat.geom]{spatstat.geom-package}} to be installed. +\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 an \code{\link{sfnetwork}}. +\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. \item \code{as_sfnetwork(sfc)}: Convert spatial geometries of class -\code{\link[sf]{sfc}} directly into an \code{\link{sfnetwork}}. +\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 boundary points. All arguments are forwarded to @@ -78,16 +79,17 @@ specified method. All arguments are forwarded to \code{\link{create_from_spatial_points}}. \item \code{as_sfnetwork(sfNetwork)}: Convert spatial networks of class -\code{\link[stplanr]{sfNetwork}} directly into an \code{\link{sfnetwork}}. -This will extract the edges as an \code{\link[sf]{sf}} object and re-create -the network structure. The directness of the provided object is preserved -unless specified otherwise through the \code{directed} argument. +\code{\link[stplanr:sfNetwork-class]{sfNetwork}} directly into a +\code{\link{sfnetwork}}. This will extract the edges as an +\code{\link[sf]{sf}} object and re-create the network structure. 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 an \code{\link{sfnetwork}}. +\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}}. The -directness of the provided object is preserved unless specified otherwise +directness of the original graph is preserved unless specified otherwise through the \code{directed} argument. }} diff --git a/man/create_from_spatial_lines.Rd b/man/create_from_spatial_lines.Rd index f9122b3e..2b5aac32 100644 --- a/man/create_from_spatial_lines.Rd +++ b/man/create_from_spatial_lines.Rd @@ -7,7 +7,7 @@ create_from_spatial_lines(x, ...) } \arguments{ -\item{x}{An object of class \code{\link[sf]{sf}} or \\code{\link[sf]{sfc}} +\item{x}{An object of class \code{\link[sf]{sf}} or \code{\link[sf]{sfc}} with \code{LINESTRING} geometries.} } \value{ diff --git a/man/create_from_spatial_points.Rd b/man/create_from_spatial_points.Rd index 59bb0cb0..d8420e4c 100644 --- a/man/create_from_spatial_points.Rd +++ b/man/create_from_spatial_points.Rd @@ -7,7 +7,7 @@ create_from_spatial_points(x, method = "sequence", ...) } \arguments{ -\item{x}{An object of class \code{\link[sf]{sf}} or \\code{\link[sf]{sfc}} +\item{x}{An object of class \code{\link[sf]{sf}} or \code{\link[sf]{sfc}} with \code{POINT} geometries.} \item{method}{The method used to connect the given point geometries to each diff --git a/man/sfnetwork.Rd b/man/sfnetwork.Rd index 39cd8fd5..6b837c86 100644 --- a/man/sfnetwork.Rd +++ b/man/sfnetwork.Rd @@ -26,9 +26,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.} From 1c394d1b8031ff89b7dd173d5c43bb1b7564b0cb Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Tue, 23 Jul 2024 13:09:54 +0200 Subject: [PATCH 007/246] feat: Add creation functions for networks from spatial points. Refs #52 :gift: --- NAMESPACE | 2 + R/checks.R | 11 ++ R/create.R | 229 +++++++++++++++++++++++------- man/create_from_spatial_lines.Rd | 5 +- man/create_from_spatial_points.Rd | 74 +++++++++- 5 files changed, 264 insertions(+), 57 deletions(-) diff --git a/NAMESPACE b/NAMESPACE index b47f6ff3..e732ab31 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -156,6 +156,7 @@ importFrom(igraph,is_connected) importFrom(igraph,is_dag) importFrom(igraph,is_directed) importFrom(igraph,is_simple) +importFrom(igraph,mst) importFrom(igraph,shortest_paths) importFrom(igraph,simplify) importFrom(igraph,vcount) @@ -230,6 +231,7 @@ importFrom(sfheaders,sfc_point) importFrom(sfheaders,sfc_to_df) importFrom(stats,median) importFrom(tibble,as_tibble) +importFrom(tibble,tibble) importFrom(tibble,trunc_mat) importFrom(tidygraph,"%>%") importFrom(tidygraph,.G) diff --git a/R/checks.R b/R/checks.R index e6d086a1..796dfb69 100644 --- a/R/checks.R +++ b/R/checks.R @@ -121,6 +121,17 @@ have_equal_geometries = function(x, y) { diag(st_equals(x, y, sparse = FALSE)) } +#' 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 #' #' @param x An object of class \code{\link{sfnetwork}}. diff --git a/R/create.R b/R/create.R index afa8b5ea..f297f642 100644 --- a/R/create.R +++ b/R/create.R @@ -3,6 +3,9 @@ #' @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}. +#' #' @details It is assumed that the given lines geometries form the edges in the #' network. Nodes are created at the boundary points of the edges. Boundary #' points at equal locations become the same node. @@ -11,7 +14,7 @@ #' #' @importFrom sf st_as_sf st_sf #' @export -create_from_spatial_lines = function(x, ...) { +create_from_spatial_lines = function(x, directed = TRUE) { # The provided lines will form the edges of the network. edges = st_as_sf(x) # Get the boundary points of the edges. @@ -42,7 +45,7 @@ create_from_spatial_lines = function(x, ...) { # Create a network out of the created nodes and the provided edges. # The ... arguments are forwarded to the sfnetwork construction function. # Force to skip network validity tests because we already know they pass. - sfnetwork(nodes, edges, force = TRUE, ...) + sfnetwork_(nodes, edges, directed = directed) } #' Create a spatial network from point geometries @@ -50,62 +53,184 @@ create_from_spatial_lines = function(x, ...) { #' @param x An object of class \code{\link[sf]{sf}} or \code{\link[sf]{sfc}} #' with \code{POINT} geometries. #' -#' @param method The method used to connect the given point geometries to each -#' other. +#' @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. #' -#' @details It is assumed that the given points form the nodes in the network. -#' How those nodes are connected by edges depends on the selected method. +#' @param directed Should the constructed network be directed? Defaults to +#' \code{TRUE}. #' -#' @return 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}. #' -#' @export -create_from_spatial_points = function(x, method = "sequence", ...) { - switch( - method, - sequence = create_spatial_sequence(x, ...), - raise_unknown_input(method) - ) -} - -#' Create as spatial network as sequentially connected nodes -#' -#' @param x An object of class \code{\link[sf]{sf}} or \\code{\link[sf]{sfc}} -#' with \code{POINT} geometries. +#' @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. -#' The nodes are sequentially connected by edges. +#' 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}. +#' +#' 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. 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. +#' } #' #' @return An object of class \code{\link{sfnetwork}}. #' -#' @importFrom sf st_as_sf st_geometry st_sf -#' @noRd -create_spatial_sequence = function(x, ...) { - # The provided points will form the nodes of the network. - nodes = st_as_sf(x) - # 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 in the edges as in the nodes. - geom_colname = attr(nodes, "sf_column") - if (geom_colname != "geometry") { - names(edges)[3] = geom_colname - attr(edges, "sf_column") = geom_colname +#' @export +create_from_spatial_points = function(x, connections = "complete", + directed = TRUE, edges_as_lines = TRUE, + k = 1) { + if (is_single_string(connections)) { + switch( + connections, + complete = create_spatial_complete(x, directed, edges_as_lines), + sequence = create_spatial_sequence(x, directed, edges_as_lines), + mst = create_spatial_mst(x, directed, edges_as_lines), + delaunay = create_spatial_delaunay(x, directed, edges_as_lines), + gabriel = create_spatial_gabriel(x, directed, edges_as_lines), + rn = create_spatial_rn(x, directed, edges_as_lines), + knn = create_spatial_knn(x, k, directed, edges_as_lines), + minimum_spanning_tree = create_spatial_mst(x, directed, edges_as_lines), + relative_neighborhood = create_spatial_rn(x, directed, edges_as_lines), + relative_neighbourhood = create_spatial_rn(x, directed, edges_as_lines), + nearest_neighbors = create_spatial_knn(x, k, directed, edges_as_lines), + nearest_neighbours = create_spatial_knn(x, k, directed, edges_as_lines), + raise_unknown_input(connections) + ) + } else { + create_spatial_custom(x, connections, directed, edges_as_lines) } - # Use the same class for the edges as for the nodes. - # This mainly affects the "lower level" classes. - # For example an sf tibble instead of a sf data frame. - class(nodes) = class(edges) - # Create a network out of the created nodes and the provided edges. - # The ... arguments are forwarded to the sfnetwork construction function. - # Force to skip network validity tests because we already know they pass. - sfnetwork(nodes, edges, force = TRUE, ...) -} \ No newline at end of file +} + +create_spatial_custom = function(x, connections, directed = TRUE, + edges_as_lines = TRUE) { + nblist = adj2nb(connections) + nb2net(nblist, x, directed, edges_as_lines) +} + +#' @importFrom sf st_geometry +create_spatial_complete = function(x, directed = TRUE, edges_as_lines = TRUE) { + 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. + # Create the network from the adjacency matrix. + create_spatial_custom(x, connections, directed, edges_as_lines) +} + +#' @importFrom sf st_geometry +create_spatial_sequence = function(x, directed = TRUE, edges_as_lines = TRUE) { + n_nodes = length(st_geometry(x)) + # Create the adjacency matrix. + # Each node is connected to the next node. + connections = matrix(FALSE, ncol = n_nodes - 1, nrow = n_nodes - 1) + diag(connections) = TRUE + connections = cbind(rep(FALSE, nrow(connections)), connections) + connections = rbind(connections, rep(FALSE, ncol(connections))) + # Create the network from the adjacency matrix. + create_spatial_custom(x, connections, directed, edges_as_lines) +} + +#' @importFrom igraph mst +#' @importFrom tidygraph with_graph +create_spatial_mst = function(x, directed = TRUE, edges_as_lines = TRUE) { + complete_net = create_spatial_complete(x, directed, edges_as_lines) + edge_lengths = with_graph(complete_net, edge_length()) + mst_net = mst(complete_net, weights = edge_lengths) + tbg_to_sfn(as_tbl_graph(mst_net)) +} + +#' @importFrom sf st_geometry +#' @importFrom tibble tibble +create_spatial_delaunay = function(x, directed = TRUE, edges_as_lines = TRUE) { + requireNamespace("spdep") # Package spdep is required for this function. + nblist = tri2nb(st_geometry(x)) + nb2net(nblist, x, directed, edges_as_lines) +} + +#' @importFrom sf st_geometry +#' @importFrom tibble tibble +create_spatial_gabriel = function(x, directed = TRUE, edges_as_lines = TRUE) { + requireNamespace("spdep") # Package spdep is required for this function. + nbgraph = spdep::gabrielneigh(st_geometry(x)) + nblist = spdep::graph2nb(nbgraph, sym = TRUE) + nb2net(nblist, x, directed, edges_as_lines) +} + +#' @importFrom sf st_geometry +create_spatial_rn = function(x, directed = TRUE, edges_as_lines = TRUE) { + requireNamespace("spdep") # Package spdep is required for this function. + nbgraph = spdep::relativeneigh(st_geometry(x)) + nblist = spdep::graph2nb(nbgraph, sym = TRUE) + nb2net(nblist, x, directed, edges_as_lines) +} + +#' @importFrom sf st_geometry +create_spatial_knn = function(x, k = 1, directed = TRUE, edges_as_lines = TRUE) { + requireNamespace("spdep") # Package spdep is required for this function. + nbmat = spdep::knearneigh(st_geometry(x), k = k) + nblist = spdep::knn2nb(nbmat, sym = FALSE) + nb2net(nblist, x, directed, edges_as_lines) +} + +adj2nb = function(x) { + apply(x, 1, which, simplify = FALSE) +} + +#' @importFrom tibble tibble +nb2net = function(neighbors, nodes, directed = TRUE, edges_as_lines = TRUE) { + from_ids = rep(c(1:length(neighbors)), lengths(neighbors)) + to_ids = do.call("c", neighbors) + sfnetwork( + nodes = nodes, + edges = tibble(from = from_ids, to = to_ids), + directed = directed, + edges_as_lines = edges_as_lines, + force = TRUE + ) +} diff --git a/man/create_from_spatial_lines.Rd b/man/create_from_spatial_lines.Rd index 2b5aac32..a551e813 100644 --- a/man/create_from_spatial_lines.Rd +++ b/man/create_from_spatial_lines.Rd @@ -4,11 +4,14 @@ \alias{create_from_spatial_lines} \title{Create a spatial network from linestring geometries} \usage{ -create_from_spatial_lines(x, ...) +create_from_spatial_lines(x, directed = TRUE) } \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}.} } \value{ An object of class \code{\link{sfnetwork}}. diff --git a/man/create_from_spatial_points.Rd b/man/create_from_spatial_points.Rd index d8420e4c..b2a75da4 100644 --- a/man/create_from_spatial_points.Rd +++ b/man/create_from_spatial_points.Rd @@ -4,14 +4,33 @@ \alias{create_from_spatial_points} \title{Create a spatial network from point geometries} \usage{ -create_from_spatial_points(x, method = "sequence", ...) +create_from_spatial_points( + x, + connections = "complete", + directed = TRUE, + edges_as_lines = TRUE, + k = 1 +) } \arguments{ \item{x}{An object of class \code{\link[sf]{sf}} or \code{\link[sf]{sfc}} with \code{POINT} geometries.} -\item{method}{The method used to connect the given point geometries to each -other.} +\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.} + +\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{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}}. @@ -21,5 +40,52 @@ 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 selected method. +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}. + +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. 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. +} } From 8e7e5af7b7f44a439007f1b4be67abf9fa3d0472 Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Tue, 23 Jul 2024 14:54:58 +0200 Subject: [PATCH 008/246] docs: Add examples to creation functions :books: --- R/create.R | 60 +++++++++++++++++++++++++++++++ R/sfnetwork.R | 27 ++++++++++++++ man/as_sfnetwork.Rd | 26 ++++++++++++++ man/create_from_spatial_lines.Rd | 14 ++++++++ man/create_from_spatial_points.Rd | 48 +++++++++++++++++++++++++ 5 files changed, 175 insertions(+) diff --git a/R/create.R b/R/create.R index f297f642..ba0c4af0 100644 --- a/R/create.R +++ b/R/create.R @@ -12,6 +12,19 @@ #' #' @return An object of class \code{\link{sfnetwork}}. #' +#' @examples +#' library(sf, quietly = TRUE) +#' +#' 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) +#' #' @importFrom sf st_as_sf st_sf #' @export create_from_spatial_lines = function(x, directed = TRUE) { @@ -121,6 +134,53 @@ create_from_spatial_lines = function(x, directed = TRUE) { #' #' @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 = roxel[seq(1, 100, by = 10),]) |> +#' st_geometry() |> +#' st_centroid() |> +#' st_transform(3035) +#' +#' # Using an adjacency matrix +#' adj = matrix(c(rep(TRUE, 10), rep(FALSE, 90)), nrow = 10) +#' net = as_sfnetwork(pts, connections = adj) +#' +#' plot(net) +#' +#' # Using a adjacency matrix from a spatial predicate +#' dst = units::set_units(500, "m") +#' adj = st_is_within_distance(pts, dist = dst, sparse = FALSE) +#' net = as_sfnetwork(pts, connections = adj) +#' +#' 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, diff --git a/R/sfnetwork.R b/R/sfnetwork.R index d0fa780f..2d9c4961 100644 --- a/R/sfnetwork.R +++ b/R/sfnetwork.R @@ -234,8 +234,26 @@ as_sfnetwork.default = function(x, ...) { #' #' 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) +#' +#' # From an sf object with POINT geometries. +#' # For more examples see create_from_spatial_points. +#' library(sf, quietly = TRUE) +#' +#' pts = st_centroid(roxel[10:15, ]) +#' +#' as_sfnetwork(pts) +#' +#' oldpar = par(no.readonly = TRUE) +#' par(mar = c(1,1,1,1), mfrow = c(1,2)) +#' +#' plot(st_geometry(pts)) +#' plot(as_sfnetwork(pts)) +#' #' par(oldpar) #' #' @export @@ -345,6 +363,15 @@ as_sfnetwork.sfNetwork = function(x, ...) { #' 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 #' @export as_sfnetwork.tbl_graph = function(x, ...) { diff --git a/man/as_sfnetwork.Rd b/man/as_sfnetwork.Rd index 87ce3398..5a0617e7 100644 --- a/man/as_sfnetwork.Rd +++ b/man/as_sfnetwork.Rd @@ -101,8 +101,26 @@ 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) + +# From an sf object with POINT geometries. +# For more examples see create_from_spatial_points. +library(sf, quietly = TRUE) + +pts = st_centroid(roxel[10:15, ]) + +as_sfnetwork(pts) + +oldpar = par(no.readonly = TRUE) +par(mar = c(1,1,1,1), mfrow = c(1,2)) + +plot(st_geometry(pts)) +plot(as_sfnetwork(pts)) + par(oldpar) # From a linnet object. @@ -117,4 +135,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/create_from_spatial_lines.Rd b/man/create_from_spatial_lines.Rd index a551e813..13e1fb0e 100644 --- a/man/create_from_spatial_lines.Rd +++ b/man/create_from_spatial_lines.Rd @@ -24,3 +24,17 @@ It is assumed that the given lines geometries form the edges in the network. Nodes are created at the boundary points of the edges. Boundary points at equal locations become the same node. } +\examples{ +library(sf, quietly = TRUE) + +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) + +} diff --git a/man/create_from_spatial_points.Rd b/man/create_from_spatial_points.Rd index b2a75da4..18d61df1 100644 --- a/man/create_from_spatial_points.Rd +++ b/man/create_from_spatial_points.Rd @@ -89,3 +89,51 @@ options are: package to be installed. } } +\examples{ +library(sf, quietly = TRUE) + +oldpar = par(no.readonly = TRUE) +par(mar = c(1,1,1,1)) + +pts = roxel[seq(1, 100, by = 10),]) |> + st_geometry() |> + st_centroid() |> + st_transform(3035) + +# Using an adjacency matrix +adj = matrix(c(rep(TRUE, 10), rep(FALSE, 90)), nrow = 10) +net = as_sfnetwork(pts, connections = adj) + +plot(net) + +# Using a adjacency matrix from a spatial predicate +dst = units::set_units(500, "m") +adj = st_is_within_distance(pts, dist = dst, sparse = FALSE) +net = as_sfnetwork(pts, connections = adj) + +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) + +} From a8494211cb32cd30bbad0a60c14c68a19f0d0a0c Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Wed, 24 Jul 2024 14:09:34 +0200 Subject: [PATCH 009/246] feat: Add argument compute_length to constructor. Refs #192 :gift: --- R/sfnetwork.R | 35 ++++++++++++++++++----------------- man/sfnetwork.Rd | 18 ++++++++++-------- 2 files changed, 28 insertions(+), 25 deletions(-) diff --git a/R/sfnetwork.R b/R/sfnetwork.R index 2d9c4961..ed2c8621 100644 --- a/R/sfnetwork.R +++ b/R/sfnetwork.R @@ -35,6 +35,13 @@ #' \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}? 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{length}, it will be overwritten. Defaults to \code{FALSE}. +#' #' @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 @@ -65,7 +72,6 @@ #' @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)) @@ -93,19 +99,16 @@ #' # 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) #' -#' @importFrom sf st_as_sf st_length -#' @importFrom tidygraph tbl_graph +#' @importFrom igraph edge_attr<- +#' @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, length_as_weight = FALSE, + edges_as_lines = NULL, compute_length = FALSE, + length_as_weight = FALSE, force = FALSE, message = TRUE, ...) { # Prepare nodes. # If nodes is not an sf object: @@ -154,13 +157,11 @@ sfnetwork = function(nodes, edges = NULL, directed = TRUE, node_key = "name", x_sfn = explicitize_edges(x_sfn) } } - if (length_as_weight) { - edges = edges_as_sf(x_sfn) - if ("weight" %in% names(edges)) { - raise_overwrite("weight") + if (compute_length) { + if ("length" %in% names(edges)) { + raise_overwrite("length") } - edges$weight = st_length(edges) - edge_attribute_values(x_sfn) = edges + edge_attr(x_sfn, "length") = with_graph(x_sfn, edge_length()) } x_sfn } diff --git a/man/sfnetwork.Rd b/man/sfnetwork.Rd index 6b837c86..251f9fc0 100644 --- a/man/sfnetwork.Rd +++ b/man/sfnetwork.Rd @@ -10,6 +10,7 @@ sfnetwork( directed = TRUE, node_key = "name", edges_as_lines = NULL, + compute_length = FALSE, length_as_weight = FALSE, force = FALSE, message = TRUE, @@ -46,6 +47,13 @@ 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{compute_length}{Should the geographic length of the edges be stored in +a column named \code{length}? 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{length}, it will be overwritten. Defaults to \code{FALSE}.} + \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 @@ -84,7 +92,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 +119,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) } From a52ed8f14fd1265a5e33f094baa3fb43d8903fd7 Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Wed, 24 Jul 2024 14:27:24 +0200 Subject: [PATCH 010/246] refactor: Update spatial creation functions. Refs #52 :construction: --- NAMESPACE | 3 + R/create.R | 208 ++++++++++++++++++++---------- man/create_from_spatial_lines.Rd | 12 +- man/create_from_spatial_points.Rd | 14 +- 4 files changed, 161 insertions(+), 76 deletions(-) diff --git a/NAMESPACE b/NAMESPACE index e732ab31..3b246176 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -129,6 +129,8 @@ 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) @@ -146,6 +148,7 @@ importFrom(igraph,ends) 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) diff --git a/R/create.R b/R/create.R index ba0c4af0..99db3e71 100644 --- a/R/create.R +++ b/R/create.R @@ -6,9 +6,13 @@ #' @param directed Should the constructed network be directed? Defaults to #' \code{TRUE}. #' -#' @details It is assumed that the given lines geometries form the edges in the -#' network. Nodes are created at the boundary points of the edges. Boundary -#' points at equal locations become the same node. +#' @param compute_length Should the geographic length of the edges be stored in +#' a column named \code{length}? If there is already a column named +#' \code{length}, it will be overwritten. Defaults to \code{FALSE}. +#' +#' @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. #' #' @return An object of class \code{\link{sfnetwork}}. #' @@ -27,7 +31,8 @@ #' #' @importFrom sf st_as_sf st_sf #' @export -create_from_spatial_lines = function(x, directed = TRUE) { +create_from_spatial_lines = function(x, directed = TRUE, + compute_length = FALSE) { # The provided lines will form the edges of the network. edges = st_as_sf(x) # Get the boundary points of the edges. @@ -56,9 +61,13 @@ create_from_spatial_lines = function(x, directed = TRUE) { # For example an sf tibble instead of a sf data frame. class(nodes) = class(edges) # Create a network out of the created nodes and the provided edges. - # The ... arguments are forwarded to the sfnetwork construction function. # Force to skip network validity tests because we already know they pass. - sfnetwork_(nodes, edges, directed = directed) + sfnetwork(nodes, edges, + directed = directed, + edges_as_lines = TRUE, + compute_length = compute_length, + force = TRUE + ) } #' Create a spatial network from point geometries @@ -77,6 +86,9 @@ create_from_spatial_lines = function(x, directed = TRUE) { #' 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 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 @@ -91,7 +103,8 @@ create_from_spatial_lines = function(x, directed = TRUE) { #' \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}. +#' if either element Aij or element Aji is \code{TRUE}. Non-logical matrices +#' are first converted into logical matrices using \code{\link{as.logical}}. #' #' Alternatively, the connections can be specified by providing the name of a #' specific method that will create the adjacency matrix internally. Valid @@ -105,7 +118,10 @@ create_from_spatial_lines = function(x, directed = TRUE) { #' \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. Can also be specified as +#' 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 @@ -140,7 +156,7 @@ create_from_spatial_lines = function(x, directed = TRUE) { #' oldpar = par(no.readonly = TRUE) #' par(mar = c(1,1,1,1)) #' -#' pts = roxel[seq(1, 100, by = 10),]) |> +#' pts = roxel[seq(1, 100, by = 10),] |> #' st_geometry() |> #' st_centroid() |> #' st_transform(3035) @@ -184,113 +200,167 @@ create_from_spatial_lines = function(x, directed = TRUE) { #' @export create_from_spatial_points = function(x, connections = "complete", directed = TRUE, edges_as_lines = TRUE, - k = 1) { + compute_length = FALSE, k = 1) { if (is_single_string(connections)) { - switch( + nblist = switch( connections, - complete = create_spatial_complete(x, directed, edges_as_lines), - sequence = create_spatial_sequence(x, directed, edges_as_lines), - mst = create_spatial_mst(x, directed, edges_as_lines), - delaunay = create_spatial_delaunay(x, directed, edges_as_lines), - gabriel = create_spatial_gabriel(x, directed, edges_as_lines), - rn = create_spatial_rn(x, directed, edges_as_lines), - knn = create_spatial_knn(x, k, directed, edges_as_lines), - minimum_spanning_tree = create_spatial_mst(x, directed, edges_as_lines), - relative_neighborhood = create_spatial_rn(x, directed, edges_as_lines), - relative_neighbourhood = create_spatial_rn(x, directed, edges_as_lines), - nearest_neighbors = create_spatial_knn(x, k, directed, edges_as_lines), - nearest_neighbours = create_spatial_knn(x, k, directed, edges_as_lines), + 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) ) } else { - create_spatial_custom(x, connections, directed, edges_as_lines) + nblist = custom_neighbors(x, connections) } + nb2net(nblist, x, directed, edges_as_lines, compute_length) } -create_spatial_custom = function(x, connections, directed = TRUE, - edges_as_lines = TRUE) { - nblist = adj2nb(connections) - nb2net(nblist, x, directed, edges_as_lines) +custom_neighbors = function(x, connections) { + adj2nb(connections) } #' @importFrom sf st_geometry -create_spatial_complete = function(x, directed = TRUE, edges_as_lines = TRUE) { +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. - # Create the network from the adjacency matrix. - create_spatial_custom(x, connections, directed, edges_as_lines) + # Return as neighbor list. + adj2nb(connections) } #' @importFrom sf st_geometry -create_spatial_sequence = function(x, directed = TRUE, edges_as_lines = TRUE) { +sequential_neighbors = function(x) { + # Each node in x is connected to the next node in x. n_nodes = length(st_geometry(x)) - # Create the adjacency matrix. - # Each node is connected to the next node. - connections = matrix(FALSE, ncol = n_nodes - 1, nrow = n_nodes - 1) - diag(connections) = TRUE - connections = cbind(rep(FALSE, nrow(connections)), connections) - connections = rbind(connections, rep(FALSE, ncol(connections))) - # Create the network from the adjacency matrix. - create_spatial_custom(x, connections, directed, edges_as_lines) + lapply(c(1:(n_nodes - 1)), \(x) x + 1) } -#' @importFrom igraph mst -#' @importFrom tidygraph with_graph -create_spatial_mst = function(x, directed = TRUE, edges_as_lines = TRUE) { - complete_net = create_spatial_complete(x, directed, edges_as_lines) - edge_lengths = with_graph(complete_net, edge_length()) - mst_net = mst(complete_net, weights = edge_lengths) - tbg_to_sfn(as_tbl_graph(mst_net)) +#' @importFrom igraph as_edgelist graph_from_adjacency_matrix igraph_opt +#' igraph_options mst as_adj_list +#' @importFrom sf st_distance st_geometry +mst_neighbors = function(x, directed = TRUE, edges_as_lines = TRUE) { + # 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)) + # 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. + as_adj_list(mst) } #' @importFrom sf st_geometry -#' @importFrom tibble tibble -create_spatial_delaunay = function(x, directed = TRUE, edges_as_lines = TRUE) { +delaunay_neighbors = function(x) { requireNamespace("spdep") # Package spdep is required for this function. - nblist = tri2nb(st_geometry(x)) - nb2net(nblist, x, directed, edges_as_lines) + tri2nb(st_geometry(x)) } #' @importFrom sf st_geometry -#' @importFrom tibble tibble -create_spatial_gabriel = function(x, directed = TRUE, edges_as_lines = TRUE) { +gabriel_neighbors = function(x) { requireNamespace("spdep") # Package spdep is required for this function. - nbgraph = spdep::gabrielneigh(st_geometry(x)) - nblist = spdep::graph2nb(nbgraph, sym = TRUE) - nb2net(nblist, x, directed, edges_as_lines) + spdep::graph2nb(spdep::gabrielneigh(st_geometry(x)), sym = TRUE) } #' @importFrom sf st_geometry -create_spatial_rn = function(x, directed = TRUE, edges_as_lines = TRUE) { +relative_neighbors = function(x) { requireNamespace("spdep") # Package spdep is required for this function. - nbgraph = spdep::relativeneigh(st_geometry(x)) - nblist = spdep::graph2nb(nbgraph, sym = TRUE) - nb2net(nblist, x, directed, edges_as_lines) + spdep::graph2nb(spdep::relativeneigh(st_geometry(x)), sym = TRUE) } #' @importFrom sf st_geometry -create_spatial_knn = function(x, k = 1, directed = TRUE, edges_as_lines = TRUE) { +nearest_neighbors = function(x, k = 1) { requireNamespace("spdep") # Package spdep is required for this function. - nbmat = spdep::knearneigh(st_geometry(x), k = k) - nblist = spdep::knn2nb(nbmat, sym = FALSE) - nb2net(nblist, x, directed, edges_as_lines) + spdep::knn2nb(spdep::knearneigh(st_geometry(x), k = k), sym = FALSE) } +#' 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 adj2nb = function(x) { - apply(x, 1, which, simplify = FALSE) + if (! is.logical(x)) { + apply(x, 1, \(x) which(as.logical(x)), simplify = FALSE) + } else { + apply(x, 1, which, simplify = FALSE) + } } +#' Convert a neighbor list into a sfnetwork +#' +#' Neighbor lists are sparse adjacency matrices that specify for each node to +#' which other nodes it is adjacent. +#' +#' @param neighbors A list with one element per node, holding the indices of +#' the nodes it is adjacent to. +#' +#' @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}. +#' +#' @return An object of class \code{\link{sfnetwork}}. +#' #' @importFrom tibble tibble -nb2net = function(neighbors, nodes, directed = TRUE, edges_as_lines = TRUE) { - from_ids = rep(c(1:length(neighbors)), lengths(neighbors)) - to_ids = do.call("c", neighbors) +#' @noRd +nb2net = function(neighbors, nodes, directed = TRUE, edges_as_lines = TRUE, + compute_length = FALSE) { + # 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(neighbors)), lengths(neighbors)), + do.call("c", neighbors) + ) + if (! directed) { + # 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 = from_ids, to = to_ids), + edges = tibble(from = edges[1, ], to = edges[2, ]), directed = directed, edges_as_lines = edges_as_lines, + compute_length = compute_length, force = TRUE ) } diff --git a/man/create_from_spatial_lines.Rd b/man/create_from_spatial_lines.Rd index 13e1fb0e..33e8da54 100644 --- a/man/create_from_spatial_lines.Rd +++ b/man/create_from_spatial_lines.Rd @@ -4,7 +4,7 @@ \alias{create_from_spatial_lines} \title{Create a spatial network from linestring geometries} \usage{ -create_from_spatial_lines(x, directed = TRUE) +create_from_spatial_lines(x, directed = TRUE, compute_length = FALSE) } \arguments{ \item{x}{An object of class \code{\link[sf]{sf}} or \code{\link[sf]{sfc}} @@ -12,6 +12,10 @@ 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}? If there is already a column named +\code{length}, it will be overwritten. Defaults to \code{FALSE}.} } \value{ An object of class \code{\link{sfnetwork}}. @@ -20,9 +24,9 @@ An object of class \code{\link{sfnetwork}}. Create a spatial network from linestring geometries } \details{ -It is assumed that the given lines geometries form the edges in the -network. Nodes are created at the boundary points of the edges. Boundary -points at equal locations become the same node. +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. } \examples{ library(sf, quietly = TRUE) diff --git a/man/create_from_spatial_points.Rd b/man/create_from_spatial_points.Rd index 18d61df1..7ebcd241 100644 --- a/man/create_from_spatial_points.Rd +++ b/man/create_from_spatial_points.Rd @@ -9,6 +9,7 @@ create_from_spatial_points( connections = "complete", directed = TRUE, edges_as_lines = TRUE, + compute_length = FALSE, k = 1 ) } @@ -27,6 +28,9 @@ describing a specific method to define the connections.} 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{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 @@ -48,7 +52,8 @@ 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}. +if either element Aij or element Aji is \code{TRUE}. Non-logical matrices +are first converted into logical matrices using \code{\link{as.logical}}. Alternatively, the connections can be specified by providing the name of a specific method that will create the adjacency matrix internally. Valid @@ -62,7 +67,10 @@ options are: \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. Can also be specified as + 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 @@ -95,7 +103,7 @@ library(sf, quietly = TRUE) oldpar = par(no.readonly = TRUE) par(mar = c(1,1,1,1)) -pts = roxel[seq(1, 100, by = 10),]) |> +pts = roxel[seq(1, 100, by = 10),] |> st_geometry() |> st_centroid() |> st_transform(3035) From 26ca711dfecec019740a2309170ef17c9bc5bd3b Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Wed, 24 Jul 2024 19:12:43 +0200 Subject: [PATCH 011/246] refactor: Deprecate length_as_weight in favor of compute_length. Refs #192 :construction: --- DESCRIPTION | 1 + NAMESPACE | 2 + R/create.R | 17 ++++- R/sfnetwork.R | 118 +++++++++++++++++++++--------- man/as_sfnetwork.Rd | 39 +++++----- man/create_from_spatial_lines.Rd | 8 +- man/create_from_spatial_points.Rd | 9 ++- man/sfnetwork.Rd | 24 +++--- 8 files changed, 147 insertions(+), 71 deletions(-) diff --git a/DESCRIPTION b/DESCRIPTION index d041262b..2465ac3f 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -39,6 +39,7 @@ Imports: dplyr, graphics, igraph, + lifecycle, lwgeom, rlang, sf, diff --git a/NAMESPACE b/NAMESPACE index 3b246176..52147e3c 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -167,6 +167,8 @@ importFrom(igraph,vertex_attr) importFrom(igraph,vertex_attr_names) importFrom(igraph,which_loop) importFrom(igraph,which_multiple) +importFrom(lifecycle,deprecate_stop) +importFrom(lifecycle,deprecated) importFrom(lwgeom,st_geod_azimuth) importFrom(rlang,"!!") importFrom(rlang,":=") diff --git a/R/create.R b/R/create.R index 99db3e71..43d7940e 100644 --- a/R/create.R +++ b/R/create.R @@ -7,8 +7,12 @@ #' \code{TRUE}. #' #' @param compute_length Should the geographic length of the edges be stored in -#' a column named \code{length}? If there is already a column named -#' \code{length}, it will be overwritten. Defaults to \code{FALSE}. +#' 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}. #' #' @details It is assumed that the given linestring geometries form the edges #' in the network. Nodes are created at the line boundaries. Shared boundaries @@ -87,7 +91,14 @@ create_from_spatial_lines = function(x, directed = TRUE, #' 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}. +#' 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 k The amount of neighbors to connect to if #' \code{connections = 'knn'}. Defaults to \code{1}, meaning that nodes are diff --git a/R/sfnetwork.R b/R/sfnetwork.R index ed2c8621..e7cb80f5 100644 --- a/R/sfnetwork.R +++ b/R/sfnetwork.R @@ -36,18 +36,16 @@ #' \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}? 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{length}, it will be overwritten. Defaults to \code{FALSE}. -#' -#' @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}. +#' 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 @@ -103,12 +101,13 @@ #' sfnetwork(nodes, edges, compute_length = TRUE) #' #' @importFrom igraph edge_attr<- +#' @importFrom lifecycle deprecated deprecate_stop #' @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 = FALSE, + length_as_weight = deprecated(), force = FALSE, message = TRUE, ...) { # Prepare nodes. # If nodes is not an sf object: @@ -157,6 +156,15 @@ sfnetwork = function(nodes, edges = NULL, directed = TRUE, node_key = "name", x_sfn = explicitize_edges(x_sfn) } } + ## DEPRECATION INFO ## + if (isTRUE(length_as_weight)) { + deprecate_stop( + when = "v1.0", + what = "sfnetwork(length_as_weight)", + with = "sfnetwork(compute_length)" + ) + } + ## END OF DEPRECATION INFO ## if (compute_length) { if ("length" %in% names(edges)) { raise_overwrite("length") @@ -195,8 +203,8 @@ tbg_to_sfn = function(x) { #' #' @param x Object to be converted into a \code{\link{sfnetwork}}. #' -#' @param ... Arguments passed on to the \code{\link{sfnetwork}} construction -#' function, unless specified otherwise. +#' @param ... Additional arguments passed on to the \code{\link{sfnetwork}} +#' construction function, unless specified otherwise. #' #' @return An object of class \code{\link{sfnetwork}}. #' @@ -209,7 +217,10 @@ as_sfnetwork = function(x, ...) { #' 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}}. +#' 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 @@ -221,10 +232,10 @@ as_sfnetwork.default = function(x, ...) { #' \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 boundary points. All arguments are forwarded to +#' 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. All arguments are forwarded to +#' specified method. Additional arguments are forwarded to #' \code{\link{create_from_spatial_points}}. #' #' @examples @@ -257,9 +268,48 @@ as_sfnetwork.default = function(x, ...) { #' #' par(oldpar) #' +#' @importFrom lifecycle deprecate_stop #' @export as_sfnetwork.sf = function(x, ...) { + ## DEPRECATION INFO ## + dots = list(...) + if (isTRUE(dots$length_as_weight)) { + 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." + ) + ) + ) + } + ## END OF DEPRECATION INFO ## if (has_single_geom_type(x, "LINESTRING")) { + ## DEPRECATION INFO ## + if (isFALSE(dots$edges_as_lines)) { + 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." + ) + ) + ) + } + ## END OF DEPRECATION INFO ## create_from_spatial_lines(x, ...) } else if (has_single_geom_type(x, "POINT")) { create_from_spatial_points(x, ...) @@ -271,6 +321,22 @@ as_sfnetwork.sf = function(x, ...) { } } +#' @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 spatial linear networks of class #' \code{\link[spatstat.linnet]{linnet}} directly into an #' \code{\link{sfnetwork}}. This requires the @@ -321,22 +387,6 @@ as_sfnetwork.psp = function(x, ...) { as_sfnetwork(x_linestring, ...) } -#' @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 boundary points. All 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. All 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 spatial networks of class #' \code{\link[stplanr:sfNetwork-class]{sfNetwork}} directly into a #' \code{\link{sfnetwork}}. This will extract the edges as an diff --git a/man/as_sfnetwork.Rd b/man/as_sfnetwork.Rd index 5a0617e7..93ffb7dc 100644 --- a/man/as_sfnetwork.Rd +++ b/man/as_sfnetwork.Rd @@ -4,9 +4,9 @@ \alias{as_sfnetwork} \alias{as_sfnetwork.default} \alias{as_sfnetwork.sf} +\alias{as_sfnetwork.sfc} \alias{as_sfnetwork.linnet} \alias{as_sfnetwork.psp} -\alias{as_sfnetwork.sfc} \alias{as_sfnetwork.sfNetwork} \alias{as_sfnetwork.tbl_graph} \title{Convert a foreign object to a sfnetwork} @@ -17,12 +17,12 @@ as_sfnetwork(x, ...) \method{as_sfnetwork}{sf}(x, ...) +\method{as_sfnetwork}{sfc}(x, ...) + \method{as_sfnetwork}{linnet}(x, ...) \method{as_sfnetwork}{psp}(x, ...) -\method{as_sfnetwork}{sfc}(x, ...) - \method{as_sfnetwork}{sfNetwork}(x, ...) \method{as_sfnetwork}{tbl_graph}(x, ...) @@ -30,8 +30,8 @@ as_sfnetwork(x, ...) \arguments{ \item{x}{Object to be converted into a \code{\link{sfnetwork}}.} -\item{...}{Arguments passed on to the \code{\link{sfnetwork}} construction -function, unless specified otherwise.} +\item{...}{Additional arguments passed on to the \code{\link{sfnetwork}} +construction function, unless specified otherwise.} } \value{ An object of class \code{\link{sfnetwork}}. @@ -45,16 +45,29 @@ Convert a given object into an object of class \code{\link{sfnetwork}}. 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}}. +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 boundary points. All arguments are forwarded to +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. All arguments are forwarded to +specified method. Additional arguments are forwarded to \code{\link{create_from_spatial_points}}. \item \code{as_sfnetwork(linnet)}: Convert spatial linear networks of class @@ -68,16 +81,6 @@ to be installed. The lines become the edges in the network, and nodes are placed at their boundary 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 boundary points. All 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. All arguments are forwarded to -\code{\link{create_from_spatial_points}}. - \item \code{as_sfnetwork(sfNetwork)}: Convert spatial networks of class \code{\link[stplanr:sfNetwork-class]{sfNetwork}} directly into a \code{\link{sfnetwork}}. This will extract the edges as an diff --git a/man/create_from_spatial_lines.Rd b/man/create_from_spatial_lines.Rd index 33e8da54..022bcf25 100644 --- a/man/create_from_spatial_lines.Rd +++ b/man/create_from_spatial_lines.Rd @@ -14,8 +14,12 @@ with \code{LINESTRING} geometries.} \code{TRUE}.} \item{compute_length}{Should the geographic length of the edges be stored in -a column named \code{length}? If there is already a column named -\code{length}, it will be overwritten. Defaults to \code{FALSE}.} +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}.} } \value{ An object of class \code{\link{sfnetwork}}. diff --git a/man/create_from_spatial_points.Rd b/man/create_from_spatial_points.Rd index 7ebcd241..19d4881c 100644 --- a/man/create_from_spatial_points.Rd +++ b/man/create_from_spatial_points.Rd @@ -29,7 +29,14 @@ 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}.} +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{k}{The amount of neighbors to connect to if \code{connections = 'knn'}. Defaults to \code{1}, meaning that nodes are diff --git a/man/sfnetwork.Rd b/man/sfnetwork.Rd index 251f9fc0..9de3a0a2 100644 --- a/man/sfnetwork.Rd +++ b/man/sfnetwork.Rd @@ -11,7 +11,7 @@ sfnetwork( node_key = "name", edges_as_lines = NULL, compute_length = FALSE, - length_as_weight = FALSE, + length_as_weight = deprecated(), force = FALSE, message = TRUE, ... @@ -48,18 +48,16 @@ represented \code{to} and \code{from} columns should be matched against. If \code{\link[sf]{sf}}, and \code{FALSE} otherwise. Defaults to \code{NULL}.} \item{compute_length}{Should the geographic length of the edges be stored in -a column named \code{length}? 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{length}, it will be overwritten. Defaults to \code{FALSE}.} - -\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}.} +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 From fd912fc1a0c596452d17675fc2b2c9ac6dfa99f9 Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Thu, 25 Jul 2024 14:13:23 +0200 Subject: [PATCH 012/246] refactor: Update weights parsing in path and cost functions. Refs #192 :construction: --- NAMESPACE | 6 + R/paths.R | 332 ++++++++++++++++++++++++---------------- man/st_network_cost.Rd | 52 ++++--- man/st_network_paths.Rd | 92 ++++++----- 4 files changed, 287 insertions(+), 195 deletions(-) diff --git a/NAMESPACE b/NAMESPACE index 52147e3c..69ef9d43 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -168,10 +168,14 @@ 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(lwgeom,st_geod_azimuth) importFrom(rlang,"!!") importFrom(rlang,":=") +importFrom(rlang,enquo) +importFrom(rlang,eval_tidy) +importFrom(rlang,expr) importFrom(sf,"st_agr<-") importFrom(sf,"st_crs<-") importFrom(sf,"st_geometry<-") @@ -239,8 +243,10 @@ importFrom(tibble,as_tibble) importFrom(tibble,tibble) importFrom(tibble,trunc_mat) importFrom(tidygraph,"%>%") +importFrom(tidygraph,.E) importFrom(tidygraph,.G) importFrom(tidygraph,.graph_context) +importFrom(tidygraph,.register_graph_context) importFrom(tidygraph,activate) importFrom(tidygraph,active) importFrom(tidygraph,as_tbl_graph) diff --git a/R/paths.R b/R/paths.R index d7b1db42..7eadfe81 100644 --- a/R/paths.R +++ b/R/paths.R @@ -28,13 +28,15 @@ #' calculated. 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'}. +#' Can be a numeric vector of the same length as the number of edges, a +#' \link[=spatial_edge_measures]{spatial edge measure function}, or a column in +#' the edges table of the network. Tidy evaluation is used such that column +#' names can be specified as if they were variables in the environment (e.g. +#' simply \code{length} instead of \code{igraph::edge_attr(x, "length")}). +#' If set to \code{NULL} or \code{NA} no edge weights are used, and the +#' shortest path is the path with the fewest number of edges, ignoring space. +#' The default is \code{\link{edge_length}}, which computes the geographic +#' lengths of the edges. #' #' @param type Character defining which type of path calculation should be #' performed. If set to \code{'shortest'} paths are calculated using @@ -86,31 +88,34 @@ #' 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 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) %>% +#' 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(slice(activate(net, "nodes"), node_path), col = "red", add = TRUE) +#' plot(slice(net, node_path), col = "red", add = TRUE) #' par(oldpar) #' -#' # Providing nodes as spatial points. -#' # Points that don't equal a node will be snapped to their nearest node. +#' # Compute the shortest paths from one to multiple nodes. +#' # This will return a tibble with one row per path. +#' st_network_paths(net, from = 495, to = c(121, 131, 141)) +#' +#' # 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,60 +124,111 @@ #' paths = st_network_paths(net, from = p1, to = p2) #' paths #' -#' node_path = paths %>% -#' slice(1) %>% -#' pull(node_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(slice(net, 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") +#' # 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) #' -#' # 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") +#' # 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 = NULL) #' -#' # 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") +#' # Compute all shortest paths between two nodes. +#' # If there is more than one shortest path, this returns one path per row. +#' st_network_paths(net, from = 5, to = 1, 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 = igraph::V(x), + weights = edge_length(), type = "shortest", + use_names = TRUE, ...) { UseMethod("st_network_paths") } #' @importFrom igraph V +#' @importFrom lifecycle deprecate_warn +#' @importFrom rlang enquo eval_tidy expr #' @importFrom sf st_geometry +#' @importFrom tidygraph .E .register_graph_context #' @export st_network_paths.sfnetwork = function(x, from, to = igraph::V(x), - weights = NULL, type = "shortest", + weights = edge_length(), + type = "shortest", use_names = TRUE, ...) { - # If 'from' points are given as simple feature geometries: - # --> Convert them to node indices. + # Parse from and to arguments. + # --> Convert geometries to node indices. + # --> Raise warnings when igraph requirements are not met. 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") + # Parse weights argument using tidy evaluation on the network edges. + .register_graph_context(x, free = TRUE) + weights = enquo(weights) + weights = eval_tidy(weights, .E()) + if (is_single_string(weights)) { + # Allow character values for backward compatibility and non-tidyversers. + ## DEPRECATION INFO ## + deprecate_warn( + when = "v1.0", + what = "st_network_paths(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." + ) + ) + ) + ## END OF DEPRECATION INFO ## + weights = eval_tidy(expr(.data[[weights]]), .E()) + } + if (is.null(weights)) { + # Convert NULL to NA to align with tidygraph instead of igraph. + ## DEPRECATION INFO ## + deprecate_warn( + when = "v1.0", + what = paste( + "st_network_paths(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`." + ) + ) + ) + ## END OF DEPRECATION INFO ## + weights = NA + } # Call paths calculation function according to type argument. switch( type, @@ -186,8 +242,6 @@ st_network_paths.sfnetwork = function(x, from, to = igraph::V(x), #' @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. @@ -205,8 +259,6 @@ get_shortest_paths = function(x, from, to, weights, use_names = TRUE, ...) { #' @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. @@ -262,13 +314,15 @@ get_all_simple_paths = function(x, from, to, use_names = TRUE, ...) { #' 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. +#' Can be a numeric vector of the same length as the number of edges, a +#' \link[=spatial_edge_measures]{spatial edge measure function}, or a column in +#' the edges table of the network. Tidy evaluation is used such that column +#' names can be specified as if they were variables in the environment (e.g. +#' simply \code{length} instead of \code{igraph::edge_attr(x, "length")}). +#' If set to \code{NULL} or \code{NA} no edge weights are used, and the +#' shortest path is the path with the fewest number of edges, ignoring space. +#' 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 calculated @@ -310,18 +364,15 @@ get_all_simple_paths = function(x, from, to, use_names = TRUE, ...) { #' 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. +#' # 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))) @@ -329,11 +380,20 @@ get_all_simple_paths = function(x, from, to, use_names = TRUE, ...) { #' #' 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 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 = NULL) #' #' # Not providing any from or to points includes all nodes by default. #' with_graph(net, graph_order()) # Our network has 701 nodes. @@ -343,95 +403,105 @@ get_all_simple_paths = function(x, from, to, use_names = TRUE, ...) { #' @importFrom igraph V #' @export st_network_cost = function(x, from = igraph::V(x), to = igraph::V(x), - weights = NULL, direction = "out", + weights = edge_length(), direction = "out", Inf_as_NaN = FALSE, ...) { UseMethod("st_network_cost") } #' @importFrom igraph distances V -#' @importFrom units deparse_unit as_units +#' @importFrom lifecycle deprecate_warn +#' @importFrom rlang enquo eval_tidy expr +#' @importFrom tidygraph .E .register_graph_context +#' @importFrom units as_units deparse_unit #' @export st_network_cost.sfnetwork = function(x, from = igraph::V(x), to = igraph::V(x), - weights = NULL, direction = "out", + weights = edge_length(), + direction = "out", Inf_as_NaN = FALSE, ...) { - # If 'from' and/or 'to' points are given as simple feature geometries: - # --> Convert them to node indices. + # Parse from and to arguments. + # --> Convert geometries to node indices. + # --> Raise warnings when igraph requirements are not met. 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 ... + # Parse weights argument using tidy evaluation on the network edges. + .register_graph_context(x, free = TRUE) + weights = enquo(weights) + weights = eval_tidy(weights, .E()) + if (is_single_string(weights)) { + # Allow character values for backward compatibility and non-tidyversers. + ## DEPRECATION INFO ## + deprecate_warn( + when = "v1.0", + what = "st_network_paths(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." + ) + ) + ) + ## END OF DEPRECATION INFO ## + weights = eval_tidy(expr(.data[[weights]]), .E()) + } + if (is.null(weights)) { + # Convert NULL to NA to align with tidygraph instead of igraph. + ## DEPRECATION INFO ## + deprecate_warn( + when = "v1.0", + what = paste( + "st_network_paths(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`." + ) + ) + ) + ## END OF DEPRECATION INFO ## + weights = NA + } + # Parse other arguments. + # --> The mode argument in ... is ignored in favor of the direction argument. 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", + "Argument `mode` is ignored, use `direction` instead.", call. = FALSE ) } - # Igraph does not support duplicated 'to' nodes. + # Call the 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))) { - # --> 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. + # Post-process and return. + # --> Convert Inf to NaN if requested. + # --> Attach units if the provided weights had units. 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) + as_units(matrix, deparse_unit(weights)) } else { - # Return the matrix. matrix } } - -#' @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 - } - } else { - # All other cases: igraph will handle the given weights. - # No need for pre-processing. - weights - } -} diff --git a/man/st_network_cost.Rd b/man/st_network_cost.Rd index 0b4cc5e9..9c9b51f8 100644 --- a/man/st_network_cost.Rd +++ b/man/st_network_cost.Rd @@ -8,7 +8,7 @@ st_network_cost( x, from = igraph::V(x), to = igraph::V(x), - weights = NULL, + weights = edge_length(), direction = "out", Inf_as_NaN = FALSE, ... @@ -35,13 +35,15 @@ calculated. Duplicated values will be removed before calculating the cost matrix. 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.} +Can be a numeric vector of the same length as the number of edges, a +\link[=spatial_edge_measures]{spatial edge measure function}, or a column in +the edges table of the network. Tidy evaluation is used such that column +names can be specified as if they were variables in the environment (e.g. +simply \code{length} instead of \code{igraph::edge_attr(x, "length")}). +If set to \code{NULL} or \code{NA} no edge weights are used, and the +shortest path is the path with the fewest number of edges, ignoring space. +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 @@ -90,18 +92,15 @@ see the \code{\link[igraph]{distances}} documentation page. 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. +# 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 +108,20 @@ 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 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 = NULL) # Not providing any from or to points includes all nodes by default. with_graph(net, graph_order()) # Our network has 701 nodes. diff --git a/man/st_network_paths.Rd b/man/st_network_paths.Rd index b2842570..b6f788cf 100644 --- a/man/st_network_paths.Rd +++ b/man/st_network_paths.Rd @@ -8,7 +8,7 @@ st_network_paths( x, from, to = igraph::V(x), - weights = NULL, + weights = edge_length(), type = "shortest", use_names = TRUE, ... @@ -35,13 +35,15 @@ containing the names of the nodes to which the paths will be calculated. 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'}.} +Can be a numeric vector of the same length as the number of edges, a +\link[=spatial_edge_measures]{spatial edge measure function}, or a column in +the edges table of the network. Tidy evaluation is used such that column +names can be specified as if they were variables in the environment (e.g. +simply \code{length} instead of \code{igraph::edge_attr(x, "length")}). +If set to \code{NULL} or \code{NA} no edge weights are used, and the +shortest path is the path with the fewest number of edges, ignoring space. +The default is \code{\link{edge_length}}, which computes the geographic +lengths of the edges.} \item{type}{Character defining which type of path calculation should be performed. If set to \code{'shortest'} paths are calculated using @@ -102,31 +104,34 @@ see the \code{\link[igraph]{shortest_paths}} or 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 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) \%>\% +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(slice(activate(net, "nodes"), node_path), col = "red", add = TRUE) +plot(slice(net, node_path), col = "red", add = TRUE) par(oldpar) -# Providing nodes as spatial points. -# Points that don't equal a node will be snapped to their nearest node. +# Compute the shortest paths from one to multiple nodes. +# This will return a tibble with one row per path. +st_network_paths(net, from = 495, to = c(121, 131, 141)) + +# 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,36 +140,39 @@ st_crs(p2) = st_crs(net) paths = st_network_paths(net, from = p1, to = p2) paths -node_path = paths \%>\% - slice(1) \%>\% - pull(node_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(slice(net, 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") - -# 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") +# 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 = NULL) + +# Compute all shortest paths between two nodes. +# If there is more than one shortest path, this returns one path per row. +st_network_paths(net, from = 5, to = 1, type = "all_shortest") } \seealso{ From 1febabd80bda20efd0852b408f9b83f14796e5fa Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Thu, 25 Jul 2024 14:58:15 +0200 Subject: [PATCH 013/246] refactor: Reorder modules :construction: --- R/checks.R | 133 ++++++++++ R/convert.R | 162 ++++++++++++ R/create.R | 437 ++++++++++++++++++++++++++++++++ R/print.R | 157 ++++++++++++ R/s2.R | 9 - R/sf.R | 12 - R/sfnetwork.R | 551 ----------------------------------------- R/spatstat.R | 79 ------ R/tibble.R | 76 ------ R/tidygraph.R | 62 ----- R/utils.R | 18 ++ R/validate.R | 78 ------ man/as.linnet.Rd | 4 +- man/as_s2_geography.Rd | 22 ++ man/as_sfnetwork.Rd | 2 +- man/as_tibble.Rd | 6 +- man/is.sfnetwork.Rd | 2 +- man/s2.Rd | 17 -- man/sfnetwork.Rd | 2 +- 19 files changed, 938 insertions(+), 891 deletions(-) create mode 100644 R/convert.R create mode 100644 R/print.R delete mode 100644 R/s2.R delete mode 100644 R/sfnetwork.R delete mode 100644 R/spatstat.R delete mode 100644 R/tibble.R create mode 100644 man/as_s2_geography.Rd delete mode 100644 man/s2.Rd diff --git a/R/checks.R b/R/checks.R index 796dfb69..7f0e5624 100644 --- a/R/checks.R +++ b/R/checks.R @@ -1,3 +1,58 @@ +#' 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") +} + +#' Check if an object is an sf object +#' +#' @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 sfg object +#' +#' @param x Object to be checked. +#' +#' @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 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. @@ -195,3 +250,81 @@ will_assume_constant = function(x) { will_assume_planar = function(x) { (!is.na(st_crs(x)) && st_is_longlat(x)) && !sf_use_s2() } + +#' Proceed only when a given network element is active +#' +#' @details These function are meant to be called in the context of an +#' operation in which the network that is currently being worked on is known +#' and thus not needed as an argument to the function. +#' +#' @return Nothing when the expected network element is active, an error +#' message otherwise. +#' +#' @name require_active +#' @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 + ) + } +} + +#' @name require_active +#' @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 + ) + } +} + +#' Proceed only when edges are spatially explicit +#' +#' @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. +#' +#' @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 messages be printed during validation? Defaults to +#' \code{TRUE}. +#' +#' @return Nothing when the network has a valid sfnetwork structure, an error +#' message otherwise. +#' +#' @noRd +require_valid_network_structure = function(x, message = FALSE) { + validate_network(x, message) +} diff --git a/R/convert.R b/R/convert.R new file mode 100644 index 00000000..97ee49d3 --- /dev/null +++ b/R/convert.R @@ -0,0 +1,162 @@ +#' Extract the active element of a sfnetwork as spatial tibble +#' +#' The sfnetwork method for \code{\link[tibble]{as_tibble}} is conceptually +#' different. Whenever a geometry list column is present, it will by default +#' return what we call a 'spatial tibble'. With that we mean an object of +#' class \code{c('sf', 'tbl_df')} instead of an object of class +#' \code{'tbl_df'}. This little conceptual trick is essential for how +#' tidyverse functions handle \code{\link{sfnetwork}} objects, i.e. always +#' using the corresponding \code{\link[sf]{sf}} method if present. When using +#' \code{\link[tibble]{as_tibble}} on \code{\link{sfnetwork}} objects directly +#' as a user, you can disable this behaviour by setting \code{spatial = FALSE}. +#' +#' @param x An object of class \code{\link{sfnetwork}}. +#' +#' @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}. +#' +#' @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}. +#' +#' @param ... Arguments passed on to \code{\link[tibble]{as_tibble}}. +#' +#' @return The active element of the network as an object of class +#' \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 +#' +#' @examples +#' library(tibble, quietly = TRUE) +#' +#' net = as_sfnetwork(roxel) +#' +#' # Extract the active network element as a spatial tibble. +#' as_tibble(net) +#' +#' # Extract any network element as a spatial tibble. +#' as_tibble(net, "edges") +#' +#' # Extract the active network element as a regular tibble. +#' as_tibble(net, spatial = FALSE) +#' +#' @importFrom tibble as_tibble +#' @importFrom tidygraph as_tbl_graph +#' @export +as_tibble.sfnetwork = function(x, active = NULL, 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) + ) + } else { + switch( + active, + nodes = as_tibble(as_tbl_graph(x), "nodes"), + edges = as_tibble(as_tbl_graph(x), "edges"), + raise_unknown_input(active) + ) + } +} + +#' Convert a sfnetwork into 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 ... Arguments passed on the corresponding \code{s2} function. +#' +#' @return An object of class \code{\link[s2]{s2_geography}}. +#' +#' @name as_s2_geography +#' +#' @importFrom sf st_geometry +as_s2_geography.sfnetwork = function(x, ...) { + s2::as_s2_geography(st_geometry(x)) +} + +#' 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 +#' \pkg{spatstat} package. +#' +#' @param X An object of class \code{\link{sfnetwork}} with a projected CRS. +#' +#' @param ... Arguments passed to \code{\link[spatstat.linnet]{linnet}}. +#' +#' @return An object of class \code{\link[spatstat.linnet]{linnet}}. +#' +#' @seealso \code{\link{as_sfnetwork}} to convert objects of class +#' \code{\link[spatstat.linnet]{linnet}} into objects of class +#' \code{\link{sfnetwork}}. +#' +#' @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)) + # Extract the edge list. + X_edge_list = as.matrix( + (as.data.frame(activate(X, "edges")))[, c("from", "to")] + ) + # Build linnet. + spatstat.linnet::linnet( + vertices = X_vertices_ppp, + edges = X_edge_list, + ... + ) +} + +#' @importFrom utils packageVersion +check_spatstat = function(pkg) { + # Auxiliary function which is used to test that: + # --> The relevant spatstat packages are installed. + # --> The spatstat version is 2.0.0 or greater. + 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() +} + +#' @importFrom utils packageVersion +check_spatstat_sf = function() { + # Auxiliary function which is used to test that: + # --> The sf version is compatible with the new spatstat structure + if (packageVersion("sf") < "0.9.8") { + stop( + "spatstat code requires sf >= 0.9.8; please update sf", + call. = FALSE + ) + } +} \ No newline at end of file diff --git a/R/create.R b/R/create.R index 43d7940e..5367a391 100644 --- a/R/create.R +++ b/R/create.R @@ -1,3 +1,440 @@ +#' 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 igraph edge_attr<- +#' @importFrom lifecycle deprecated deprecate_stop +#' @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, ...) { + # 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 + ) + } + ) + } + # Create network. + x_tbg = tbl_graph(nodes, edges, 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) validate_network(x_sfn, message = message) + return (x_sfn) + } + 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 = implicitize_edges(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 = explicitize_edges(x_sfn) + } + } + ## DEPRECATION INFO ## + if (isTRUE(length_as_weight)) { + deprecate_stop( + when = "v1.0", + what = "sfnetwork(length_as_weight)", + with = "sfnetwork(compute_length)" + ) + } + ## END OF DEPRECATION INFO ## + 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) + 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 the \code{\link{sfnetwork}} +#' construction function, unless specified otherwise. +#' +#' @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) +#' +#' 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) +#' +#' # From an sf object with POINT geometries. +#' # For more examples see create_from_spatial_points. +#' library(sf, quietly = TRUE) +#' +#' pts = st_centroid(roxel[10:15, ]) +#' +#' as_sfnetwork(pts) +#' +#' oldpar = par(no.readonly = TRUE) +#' par(mar = c(1,1,1,1), mfrow = c(1,2)) +#' +#' plot(st_geometry(pts)) +#' plot(as_sfnetwork(pts)) +#' +#' par(oldpar) +#' +#' @importFrom lifecycle deprecate_stop +#' @export +as_sfnetwork.sf = function(x, ...) { + ## DEPRECATION INFO ## + dots = list(...) + if (isTRUE(dots$length_as_weight)) { + 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." + ) + ) + ) + } + ## END OF DEPRECATION INFO ## + if (has_single_geom_type(x, "LINESTRING")) { + ## DEPRECATION INFO ## + if (isFALSE(dots$edges_as_lines)) { + 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." + ) + ) + ) + } + ## END OF DEPRECATION INFO ## + create_from_spatial_lines(x, ...) + } else if (has_single_geom_type(x, "POINT")) { + create_from_spatial_points(x, ...) + } else { + stop( + "Geometries are not all of type LINESTRING, or all of type POINT", + call. = FALSE + ) + } +} + +#' @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 spatial linear networks of class +#' \code{\link[spatstat.linnet]{linnet}} directly into an +#' \code{\link{sfnetwork}}. 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) +#' } +#' +#' @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, ...) +} + +#' @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. +#' +#' @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, ...) +} + +#' @describeIn as_sfnetwork Convert spatial networks of class +#' \code{\link[stplanr:sfNetwork-class]{sfNetwork}} directly into a +#' \code{\link{sfnetwork}}. This will extract the edges as an +#' \code{\link[sf]{sf}} object and re-create the network structure. The +#' directness of the original network is preserved unless specified otherwise +#' through the \code{directed} argument. +#' +#' @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) +} + +#' @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}}. 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 +#' @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) +} + #' Create a spatial network from linestring geometries #' #' @param x An object of class \code{\link[sf]{sf}} or \code{\link[sf]{sfc}} diff --git a/R/print.R b/R/print.R new file mode 100644 index 00000000..5a6b6804 --- /dev/null +++ b/R/print.R @@ -0,0 +1,157 @@ +#' @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) +} + +# 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) +} + +# nocov end \ No newline at end of file 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 d16ce2e9..50f854dd 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. diff --git a/R/sfnetwork.R b/R/sfnetwork.R deleted file mode 100644 index e7cb80f5..00000000 --- a/R/sfnetwork.R +++ /dev/null @@ -1,551 +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 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 igraph edge_attr<- -#' @importFrom lifecycle deprecated deprecate_stop -#' @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, ...) { - # 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 - ) - } - ) - } - # Create network. - x_tbg = tbl_graph(nodes, edges, 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) validate_network(x_sfn, message = message) - return (x_sfn) - } - 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 = implicitize_edges(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 = explicitize_edges(x_sfn) - } - } - ## DEPRECATION INFO ## - if (isTRUE(length_as_weight)) { - deprecate_stop( - when = "v1.0", - what = "sfnetwork(length_as_weight)", - with = "sfnetwork(compute_length)" - ) - } - ## END OF DEPRECATION INFO ## - 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) - 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 the \code{\link{sfnetwork}} -#' construction function, unless specified otherwise. -#' -#' @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) -#' -#' 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) -#' -#' # From an sf object with POINT geometries. -#' # For more examples see create_from_spatial_points. -#' library(sf, quietly = TRUE) -#' -#' pts = st_centroid(roxel[10:15, ]) -#' -#' as_sfnetwork(pts) -#' -#' oldpar = par(no.readonly = TRUE) -#' par(mar = c(1,1,1,1), mfrow = c(1,2)) -#' -#' plot(st_geometry(pts)) -#' plot(as_sfnetwork(pts)) -#' -#' par(oldpar) -#' -#' @importFrom lifecycle deprecate_stop -#' @export -as_sfnetwork.sf = function(x, ...) { - ## DEPRECATION INFO ## - dots = list(...) - if (isTRUE(dots$length_as_weight)) { - 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." - ) - ) - ) - } - ## END OF DEPRECATION INFO ## - if (has_single_geom_type(x, "LINESTRING")) { - ## DEPRECATION INFO ## - if (isFALSE(dots$edges_as_lines)) { - 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." - ) - ) - ) - } - ## END OF DEPRECATION INFO ## - create_from_spatial_lines(x, ...) - } else if (has_single_geom_type(x, "POINT")) { - create_from_spatial_points(x, ...) - } else { - stop( - "Geometries are not all of type LINESTRING, or all of type POINT", - call. = FALSE - ) - } -} - -#' @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 spatial linear networks of class -#' \code{\link[spatstat.linnet]{linnet}} directly into an -#' \code{\link{sfnetwork}}. 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) -#' } -#' -#' @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, ...) -} - -#' @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. -#' -#' @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, ...) -} - -#' @describeIn as_sfnetwork Convert spatial networks of class -#' \code{\link[stplanr:sfNetwork-class]{sfNetwork}} directly into a -#' \code{\link{sfnetwork}}. This will extract the edges as an -#' \code{\link[sf]{sf}} object and re-create the network structure. The -#' directness of the original network is preserved unless specified otherwise -#' through the \code{directed} argument. -#' -#' @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) -} - -#' @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}}. 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 -#' @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/spatstat.R b/R/spatstat.R deleted file mode 100644 index bee92db2..00000000 --- a/R/spatstat.R +++ /dev/null @@ -1,79 +0,0 @@ -# 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. -#' -#' @param X An object of class \code{\link{sfnetwork}} with a projected CRS. -#' -#' @param ... Arguments passed to \code{\link[spatstat.linnet]{linnet}}. -#' -#' @return An object of class \code{\link[spatstat.linnet]{linnet}}. -#' -#' @seealso \code{\link{as_sfnetwork}} to convert objects of class -#' \code{\link[spatstat.linnet]{linnet}} into objects of class -#' \code{\link{sfnetwork}}. -#' -#' @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)) - # Extract the edge list. - X_edge_list = as.matrix( - (as.data.frame(activate(X, "edges")))[, c("from", "to")] - ) - # Build linnet. - spatstat.linnet::linnet( - vertices = X_vertices_ppp, - edges = X_edge_list, - ... - ) -} diff --git a/R/tibble.R b/R/tibble.R deleted file mode 100644 index 32ebb302..00000000 --- a/R/tibble.R +++ /dev/null @@ -1,76 +0,0 @@ -#' Extract the active element of a sfnetwork as spatial tibble -#' -#' The sfnetwork method for \code{\link[tibble]{as_tibble}} is conceptually -#' different. Whenever a geometry list column is present, it will by default -#' return what we call a 'spatial tibble'. With that we mean an object of -#' class \code{c('sf', 'tbl_df')} instead of an object of class -#' \code{'tbl_df'}. This little conceptual trick is essential for how -#' tidyverse functions handle \code{\link{sfnetwork}} objects, i.e. always -#' using the corresponding \code{\link[sf]{sf}} method if present. When using -#' \code{\link[tibble]{as_tibble}} on \code{\link{sfnetwork}} objects directly -#' as a user, you can disable this behaviour by setting \code{spatial = FALSE}. -#' -#' @param x An object of class \code{\link{sfnetwork}}. -#' -#' @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}. -#' -#' @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}. -#' -#' @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}}. -#' -#' @name as_tibble -#' -#' @examples -#' library(tibble, quietly = TRUE) -#' -#' net = as_sfnetwork(roxel) -#' -#' # Extract the active network element as a spatial tibble. -#' as_tibble(net) -#' -#' # Extract any network element as a spatial tibble. -#' as_tibble(net, "edges") -#' -#' # Extract the active network element as a regular tibble. -#' as_tibble(net, spatial = FALSE) -#' -#' @importFrom tibble as_tibble -#' @importFrom tidygraph as_tbl_graph -#' @export -as_tibble.sfnetwork = function(x, active = NULL, 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) - ) - } else { - switch( - active, - nodes = as_tibble(as_tbl_graph(x), "nodes"), - edges = as_tibble(as_tbl_graph(x), "edges"), - raise_unknown_input(active) - ) - } -} - -#' @importFrom tibble as_tibble -#' @importFrom tidygraph as_tbl_graph -edges_as_table = function(x) { - if (has_explicit_edges(x)) { - edges_as_sf(x) - } else { - as_tibble(as_tbl_graph(x), "edges") - } -} diff --git a/R/tidygraph.R b/R/tidygraph.R index dc82e475..64de98a5 100644 --- a/R/tidygraph.R +++ b/R/tidygraph.R @@ -93,65 +93,3 @@ unmorph.morphed_sfnetwork = function(.data, ...) { # 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) -} - -# nocov end diff --git a/R/utils.R b/R/utils.R index fa45f7d1..5d54b161 100644 --- a/R/utils.R +++ b/R/utils.R @@ -167,6 +167,24 @@ edge_boundary_point_indices = function(x, matrix = FALSE) { if (matrix) t(matrix(idxs_vct, nrow = 2)) else idxs_vct } +#' Extract the edges as a table +#' +#' @param x An object of class \code{\link{sfnetwork}}. +#' +#' @return An object of class \code{\link[sf]{sf}} if the edges are spatially +#' explicit, and object of class \code{\link[tibble]{tibble}}. +#' +#' @importFrom tibble as_tibble +#' @importFrom tidygraph as_tbl_graph +#' @noRd +edges_as_table = function(x) { + if (has_explicit_edges(x)) { + edges_as_sf(x) + } else { + as_tibble(as_tbl_graph(x), "edges") + } +} + #' Make edges spatially explicit #' #' @param x An object of class \code{\link{sfnetwork}}. diff --git a/R/validate.R b/R/validate.R index 26a0a8a1..4f579290 100644 --- a/R/validate.R +++ b/R/validate.R @@ -80,81 +80,3 @@ validate_network = function(x, message = TRUE) { } if (message) cli_alert_success("Spatial network structure is valid") } - -#' Proceed only when a given network element is active -#' -#' @details These function are meant to be called in the context of an -#' operation in which the network that is currently being worked on is known -#' and thus not needed as an argument to the function. -#' -#' @return Nothing when the expected network element is active, an error -#' message otherwise. -#' -#' @name require_active -#' @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 - ) - } -} - -#' @name require_active -#' @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 - ) - } -} - -#' Proceed only when edges are spatially explicit -#' -#' @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. -#' -#' @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 messages be printed during validation? Defaults to -#' \code{TRUE}. -#' -#' @return Nothing when the network has a valid sfnetwork structure, an error -#' message otherwise. -#' -#' @noRd -require_valid_network_structure = function(x, message = FALSE) { - validate_network(x, message) -} diff --git a/man/as.linnet.Rd b/man/as.linnet.Rd index a4c61239..2832a967 100644 --- a/man/as.linnet.Rd +++ b/man/as.linnet.Rd @@ -1,5 +1,5 @@ % Generated by roxygen2: do not edit by hand -% Please edit documentation in R/spatstat.R +% Please edit documentation in R/convert.R \name{as.linnet} \alias{as.linnet} \alias{as.linnet.sfnetwork} @@ -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..3a16dbed --- /dev/null +++ b/man/as_s2_geography.Rd @@ -0,0 +1,22 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/convert.R +\name{as_s2_geography} +\alias{as_s2_geography} +\alias{as_s2_geography.sfnetwork} +\title{Convert a sfnetwork into a S2 geography vector} +\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.} +} +\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 93ffb7dc..48f0d422 100644 --- a/man/as_sfnetwork.Rd +++ b/man/as_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{as_sfnetwork} \alias{as_sfnetwork} \alias{as_sfnetwork.default} diff --git a/man/as_tibble.Rd b/man/as_tibble.Rd index 2a319972..f27895af 100644 --- a/man/as_tibble.Rd +++ b/man/as_tibble.Rd @@ -1,5 +1,5 @@ % Generated by roxygen2: do not edit by hand -% Please edit documentation in R/tibble.R +% Please edit documentation in R/convert.R \name{as_tibble} \alias{as_tibble} \alias{as_tibble.sfnetwork} @@ -22,7 +22,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/is.sfnetwork.Rd b/man/is.sfnetwork.Rd index ee2d6d57..046008f5 100644 --- a/man/is.sfnetwork.Rd +++ b/man/is.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/checks.R \name{is.sfnetwork} \alias{is.sfnetwork} \title{Check if an object is a sfnetwork} 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/sfnetwork.Rd b/man/sfnetwork.Rd index 9de3a0a2..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} From 2eb286b23c06717fdb7262e1e6e64e3cb40f75c0 Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Thu, 25 Jul 2024 15:05:46 +0200 Subject: [PATCH 014/246] refactor: Replace . with _ in is functions :construction: --- NAMESPACE | 1 + R/blend.R | 2 +- R/checks.R | 24 +++++++++++++++--------- R/create.R | 4 ++-- R/geom.R | 8 ++++---- R/join.R | 2 +- R/morphers.R | 6 +++--- R/paths.R | 8 ++++---- R/tidygraph.R | 2 +- man/{is.sfnetwork.Rd => is_sfnetwork.Rd} | 9 ++++++--- 10 files changed, 38 insertions(+), 28 deletions(-) rename man/{is.sfnetwork.Rd => is_sfnetwork.Rd} (81%) diff --git a/NAMESPACE b/NAMESPACE index 69ef9d43..f739c30e 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -80,6 +80,7 @@ export(edge_length) export(edge_overlaps) export(edge_touches) export(is.sfnetwork) +export(is_sfnetwork) export(node_M) export(node_X) export(node_Y) diff --git a/R/blend.R b/R/blend.R index d2d70071..f2d7dea2 100644 --- a/R/blend.R +++ b/R/blend.R @@ -445,7 +445,7 @@ blend_ = function(x, y, tolerance) { 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) { + 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. diff --git a/R/checks.R b/R/checks.R index 7f0e5624..a1013f0a 100644 --- a/R/checks.R +++ b/R/checks.R @@ -9,14 +9,20 @@ #' 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)) #' #' @export -is.sfnetwork = function(x) { +is_sfnetwork = function(x) { inherits(x, "sfnetwork") } +#' @name is_sfnetwork +#' @export +is.sfnetwork = function(x) { + is_sfnetwork(x) +} + #' Check if an object is an sf object #' #' @param x Object to be checked. @@ -25,7 +31,7 @@ is.sfnetwork = function(x) { #' \code{\link[sf]{sf}}, \code{FALSE} otherwise. #' #' @noRd -is.sf = function(x) { +is_sf = function(x) { inherits(x, "sf") } @@ -37,7 +43,7 @@ is.sf = function(x) { #' \code{\link[sf]{sfc}}, \code{FALSE} otherwise. #' #' @noRd -is.sfc = function(x) { +is_sfc = function(x) { inherits(x, "sfc") } @@ -49,7 +55,7 @@ is.sfc = function(x) { #' \code{\link[sf:st]{sfg}}, \code{FALSE} otherwise. #' #' @noRd -is.sfg = function(x) { +is_sfg = function(x) { inherits(x, "sfg") } @@ -62,7 +68,7 @@ is.sfg = function(x) { #' #' @noRd has_sfc = function(x) { - any(vapply(x, is.sfc, FUN.VALUE = logical(1)), na.rm = TRUE) + any(vapply(x, is_sfc, FUN.VALUE = logical(1)), na.rm = TRUE) } #' Check if geometries are all of a specific type @@ -89,7 +95,7 @@ has_single_geom_type = function(x, type) { #' #' @noRd has_spatial_nodes = function(x) { - any(vapply(vertex_attr(x), is.sfc, FUN.VALUE = logical(1)), na.rm = TRUE) + any(vapply(vertex_attr(x), is_sfc, FUN.VALUE = logical(1)), na.rm = TRUE) } #' Check if a sfnetwork has spatially explicit edges @@ -102,7 +108,7 @@ 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) + any(vapply(edge_attr(x), is_sfc, FUN.VALUE = logical(1)), na.rm = TRUE) } #' Check if the CRS of two objects are the same diff --git a/R/create.R b/R/create.R index 5367a391..ac9f2a85 100644 --- a/R/create.R +++ b/R/create.R @@ -113,7 +113,7 @@ sfnetwork = function(nodes, edges = NULL, directed = TRUE, node_key = "name", # 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)) { + if (! is_sf(nodes)) { nodes = tryCatch( st_as_sf(nodes, ...), error = function(e) { @@ -137,7 +137,7 @@ sfnetwork = function(nodes, edges = NULL, directed = TRUE, node_key = "name", if (! force) validate_network(x_sfn, message = message) return (x_sfn) } - if (is.sf(edges)) { + 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") diff --git a/R/geom.R b/R/geom.R index b081e0da..70ac425b 100644 --- a/R/geom.R +++ b/R/geom.R @@ -29,7 +29,7 @@ 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] + sfc_idx = which(vapply(vertex_attr(x), is_sfc, FUN.VALUE = logical(1)))[1] col = vertex_attr_names(x)[sfc_idx] } col @@ -42,7 +42,7 @@ 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] + sfc_idx = which(vapply(edge_attr(x), is_sfc, FUN.VALUE = logical(1)))[1] col = edge_attr_names(x)[sfc_idx] } col @@ -105,7 +105,7 @@ pull_geom = function(x, active = NULL) { #' @noRd pull_node_geom = function(x) { geom = vertex_attr(x, node_geom_colname(x)) - if (! is.sfc(geom)) raise_invalid_sf_column() + if (! is_sfc(geom)) raise_invalid_sf_column() geom } @@ -115,7 +115,7 @@ pull_node_geom = function(x) { 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() + if (! is_sfc(geom)) raise_invalid_sf_column() geom } diff --git a/R/join.R b/R/join.R index b19d10c2..a1f16f02 100644 --- a/R/join.R +++ b/R/join.R @@ -49,7 +49,7 @@ st_network_join = function(x, y, ...) { #' @export st_network_join.sfnetwork = function(x, y, ...) { - if (! is.sfnetwork(y)) y = as_sfnetwork(y) + if (! is_sfnetwork(y)) y = as_sfnetwork(y) stopifnot(have_equal_crs(x, y)) stopifnot(have_equal_edge_type(x, y)) spatial_join_network(x, y, ...) diff --git a/R/morphers.R b/R/morphers.R index 86f52376..73088e8f 100644 --- a/R/morphers.R +++ b/R/morphers.R @@ -425,7 +425,7 @@ to_spatial_neighborhood = function(x, node, threshold, weights = NULL, # 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 (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. @@ -602,7 +602,7 @@ to_spatial_smooth = function(x, # --> 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) + spatial = is_sf(edges) geom_colname = attr(edges, "sf_column") ## ========================== # STEP I: DETECT PSEUDO NODES @@ -655,7 +655,7 @@ to_spatial_smooth = function(x, ) } protect = matched_names - } else if (is.sf(protect) | is.sfc(protect)) { + } 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. diff --git a/R/paths.R b/R/paths.R index 7eadfe81..9fc87359 100644 --- a/R/paths.R +++ b/R/paths.R @@ -179,8 +179,8 @@ st_network_paths.sfnetwork = function(x, from, to = igraph::V(x), # Parse from and to arguments. # --> Convert geometries to node indices. # --> Raise warnings when igraph requirements are not met. - 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) + 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) if (length(from) > 1) raise_multiple_elements("from") if (any(is.na(c(from, to)))) raise_na_values("from and/or to") # Parse weights argument using tidy evaluation on the network edges. @@ -421,8 +421,8 @@ st_network_cost.sfnetwork = function(x, from = igraph::V(x), to = igraph::V(x), # Parse from and to arguments. # --> Convert geometries to node indices. # --> Raise warnings when igraph requirements are not met. - 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) + 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) if (any(is.na(c(from, to)))) raise_na_values("from and/or to") # Parse weights argument using tidy evaluation on the network edges. .register_graph_context(x, free = TRUE) diff --git a/R/tidygraph.R b/R/tidygraph.R index 64de98a5..4c24ec5f 100644 --- a/R/tidygraph.R +++ b/R/tidygraph.R @@ -36,7 +36,7 @@ morph.sfnetwork = function(.data, ...) { # 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_data[[1]])) { structure( morphed_data, class = c("morphed_sfnetwork", class(morphed_data)) diff --git a/man/is.sfnetwork.Rd b/man/is_sfnetwork.Rd similarity index 81% rename from man/is.sfnetwork.Rd rename to man/is_sfnetwork.Rd index 046008f5..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/checks.R -\name{is.sfnetwork} +\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)) } From ac9af52146af1235ec53df2abe19e672037a763a Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Thu, 25 Jul 2024 17:16:27 +0200 Subject: [PATCH 015/246] feat: Allow sparse matrix lists in points creation function. Refs #52 :gift: --- R/checks.R | 61 ++++++++++++++++++++ R/create.R | 95 +++++++------------------------ R/utils.R | 73 ++++++++++++++++++++++++ man/create_from_spatial_points.Rd | 8 ++- 4 files changed, 159 insertions(+), 78 deletions(-) diff --git a/R/checks.R b/R/checks.R index a1013f0a..21739f6b 100644 --- a/R/checks.R +++ b/R/checks.R @@ -334,3 +334,64 @@ require_explicit_edges = function(x, hard = FALSE) { require_valid_network_structure = function(x, message = FALSE) { validate_network(x, message) } + +#' Proceed only if the given object is a valid adjacency matrix +#' +#' 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. +#' +#' @param x Object to be checked. +#' +#' @param nodes The nodes that are referenced in the matrix 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 adjacency matrix +#' referencing the given nodes, an error message otherwise. +#' +#' @importFrom sf st_geometry +#' @noRd +require_valid_adjacency_matrix = function(x, nodes) { + n_nodes = length(st_geometry(nodes)) + if (! (nrow(x) == n_nodes && ncol(x) == n_nodes)) { + stop( + "The dimensions of the adjacency matrix should match the ", + " number of nodes (", n_nodes, ").", + call. = FALSE + ) + } +} + +#' Proceed only if the given object is a valid 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 checked. +#' +#' @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, and error message afterwards. +#' +#' @importFrom sf st_geometry +#' @noRd +require_valid_neighbor_list = function(x, nodes) { + n_nodes = length(st_geometry(nodes)) + if (! length(x) == n_nodes) { + stop( + "The length of the sparse adjacency matrix should match the ", + " number of nodes (", n_nodes, ").", + call. = FALSE + ) + } + if (! all(vapply(x, is.integer, FUN.VALUE = logical(1)))) { + stop( + "The sparse adjacency matrix should contain integer node indices.", + call. = FALSE + ) + } +} diff --git a/R/create.R b/R/create.R index ac9f2a85..9add22a6 100644 --- a/R/create.R +++ b/R/create.R @@ -554,6 +554,10 @@ create_from_spatial_lines = function(x, directed = TRUE, #' if either element Aij or element Aji is \code{TRUE}. Non-logical matrices #' are first converted into logical matrices using \code{\link{as.logical}}. #' +#' The provided adjacency matrix may also be 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. +#' #' Alternatively, the connections can be specified by providing the name of a #' specific method that will create the adjacency matrix internally. Valid #' options are: @@ -615,9 +619,9 @@ create_from_spatial_lines = function(x, directed = TRUE, #' #' plot(net) #' -#' # Using a adjacency matrix from a spatial predicate +#' # Using a sparse adjacency matrix from a spatial predicate #' dst = units::set_units(500, "m") -#' adj = st_is_within_distance(pts, dist = dst, sparse = FALSE) +#' adj = st_is_within_distance(pts, dist = dst) #' net = as_sfnetwork(pts, connections = adj) #' #' plot(net) @@ -673,7 +677,19 @@ create_from_spatial_points = function(x, connections = "complete", } custom_neighbors = function(x, connections) { - adj2nb(connections) + if (is.matrix(connections)) { + require_valid_adjacency_matrix(connections, x) + adj2nb(connections) + } else if (inherits(connections, c("sgbp", "nb", "list"))) { + require_valid_neighbor_list(connections, x) + connections + } else { + stop( + "Connections should be specified as a matrix, a list-formatted sparse", + " matrix, or a single character.", + call. = FALSE + ) + } } #' @importFrom sf st_geometry @@ -739,76 +755,3 @@ nearest_neighbors = function(x, k = 1) { requireNamespace("spdep") # Package spdep is required for this function. spdep::knn2nb(spdep::knearneigh(st_geometry(x), k = k), sym = FALSE) } - -#' 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 -adj2nb = function(x) { - if (! is.logical(x)) { - apply(x, 1, \(x) which(as.logical(x)), simplify = FALSE) - } else { - apply(x, 1, which, simplify = FALSE) - } -} - -#' Convert a neighbor list into a sfnetwork -#' -#' Neighbor lists are sparse adjacency matrices that specify for each node to -#' which other nodes it is adjacent. -#' -#' @param neighbors A list with one element per node, holding the indices of -#' the nodes it is adjacent to. -#' -#' @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}. -#' -#' @return An object of class \code{\link{sfnetwork}}. -#' -#' @importFrom tibble tibble -#' @noRd -nb2net = function(neighbors, nodes, directed = TRUE, edges_as_lines = TRUE, - compute_length = FALSE) { - # 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(neighbors)), lengths(neighbors)), - do.call("c", neighbors) - ) - if (! directed) { - # 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 - ) -} diff --git a/R/utils.R b/R/utils.R index 5d54b161..5418557a 100644 --- a/R/utils.R +++ b/R/utils.R @@ -1,3 +1,76 @@ +#' 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 +adj2nb = function(x) { + if (! is.logical(x)) { + apply(x, 1, \(x) which(as.logical(x)), simplify = FALSE) + } else { + apply(x, 1, which, simplify = FALSE) + } +} + +#' Convert a neighbor list into a sfnetwork +#' +#' Neighbor lists are sparse adjacency matrices in list format that specify for +#' each node to which other nodes it is adjacent. +#' +#' @param neighbors A list with one element per node, holding the indices of +#' the nodes it is adjacent to. +#' +#' @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}. +#' +#' @return An object of class \code{\link{sfnetwork}}. +#' +#' @importFrom tibble tibble +#' @noRd +nb2net = function(neighbors, nodes, directed = TRUE, edges_as_lines = TRUE, + compute_length = FALSE) { + # 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(neighbors)), lengths(neighbors)), + do.call("c", neighbors) + ) + if (! directed) { + # 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 + ) +} + #' List-column friendly version of bind_rows #' #' @param ... Tables to be row-binded. diff --git a/man/create_from_spatial_points.Rd b/man/create_from_spatial_points.Rd index 19d4881c..bca4e65f 100644 --- a/man/create_from_spatial_points.Rd +++ b/man/create_from_spatial_points.Rd @@ -62,6 +62,10 @@ 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}}. +The provided adjacency matrix may also be 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. + Alternatively, the connections can be specified by providing the name of a specific method that will create the adjacency matrix internally. Valid options are: @@ -121,9 +125,9 @@ net = as_sfnetwork(pts, connections = adj) plot(net) -# Using a adjacency matrix from a spatial predicate +# Using a sparse adjacency matrix from a spatial predicate dst = units::set_units(500, "m") -adj = st_is_within_distance(pts, dist = dst, sparse = FALSE) +adj = st_is_within_distance(pts, dist = dst) net = as_sfnetwork(pts, connections = adj) plot(net) From 029447e4fb82d118c392145ef5117006d51f01ae Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Mon, 29 Jul 2024 12:54:49 +0200 Subject: [PATCH 016/246] fix: Fix edge creation from neighbor list with only empty elements :wrench: --- R/utils.R | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/R/utils.R b/R/utils.R index 5418557a..d0047d49 100644 --- a/R/utils.R +++ b/R/utils.R @@ -54,7 +54,7 @@ nb2net = function(neighbors, nodes, directed = TRUE, edges_as_lines = TRUE, rep(c(1:length(neighbors)), lengths(neighbors)), do.call("c", neighbors) ) - if (! directed) { + 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. From a34e89fabfb75a7ba0791ea6b61a0f71928d9802 Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Mon, 29 Jul 2024 13:04:56 +0200 Subject: [PATCH 017/246] fix: Fix edge geometry creation when there are no edges :wrench: --- R/utils.R | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/R/utils.R b/R/utils.R index d0047d49..e738348b 100644 --- a/R/utils.R +++ b/R/utils.R @@ -265,14 +265,18 @@ edges_as_table = function(x) { #' @return An object of class \code{\link{sfnetwork}} with spatially explicit #' edges. #' -#' @importFrom rlang !! := -#' @importFrom sf st_geometry +#' @importFrom igraph ecount +#' @importFrom sf st_crs st_geometry st_sfc #' @importFrom tidygraph mutate #' @noRd explicitize_edges = function(x) { if (has_explicit_edges(x)) { x } else { + # Add empty geometry column if there are no edges. + if (ecount(x) == 0) { + return(mutate_edge_geom(x, st_sfc(crs = st_crs(x)))) + } # Extract the node geometries from the network. nodes = pull_node_geom(x) # Get the indices of the boundary nodes of each edge. From b2553c0c561c29396312d6e47c9ea62b82bfd7e4 Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Mon, 29 Jul 2024 13:35:49 +0200 Subject: [PATCH 018/246] feat: Add function to create network with sampled nodes. Refs #171 :gift: --- NAMESPACE | 4 +- R/create.R | 99 ++++++++++++++++++++++++++++++- man/create_from_spatial_points.Rd | 3 +- man/play_spatial.Rd | 77 ++++++++++++++++++++++++ 4 files changed, 177 insertions(+), 6 deletions(-) create mode 100644 man/play_spatial.Rd diff --git a/NAMESPACE b/NAMESPACE index f739c30e..1af1414b 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -92,6 +92,7 @@ export(node_is_disjoint) export(node_is_within) export(node_is_within_distance) export(node_touches) +export(play_spatial) export(sf_attr) export(sfnetwork) export(st_network_bbox) @@ -172,8 +173,6 @@ importFrom(lifecycle,deprecate_stop) importFrom(lifecycle,deprecate_warn) importFrom(lifecycle,deprecated) importFrom(lwgeom,st_geod_azimuth) -importFrom(rlang,"!!") -importFrom(rlang,":=") importFrom(rlang,enquo) importFrom(rlang,eval_tidy) importFrom(rlang,expr) @@ -256,6 +255,7 @@ 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,unmorph) diff --git a/R/create.R b/R/create.R index 9add22a6..37471579 100644 --- a/R/create.R +++ b/R/create.R @@ -531,8 +531,7 @@ create_from_spatial_lines = function(x, directed = TRUE, #' 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 +#' 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}. @@ -755,3 +754,99 @@ nearest_neighbors = function(x, k = 1) { requireNamespace("spdep") # Package spdep is required for this function. spdep::knn2nb(spdep::knearneigh(st_geometry(x), k = k), sym = FALSE) } + +#' Create a spatial network with sampled nodes +#' +#' @param n The number of nodes to be sampled. +#' +#' @param radius The radius within which nodes will be connected by an edge. +#' See Details. +#' +#' @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}. +#' +#' @details 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_spatial(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_spatial(10, units::set_units(1, "km"), bounds = st_bbox(roxel)) +#' net +#' +#' plot(net) +#' +#' par(oldpar) +#' +#' @importFrom sf st_is_within_distance st_sample +#' @importFrom tidygraph play_geometry +#' @export +play_spatial = 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( + pts, + connections = conns, + directed = FALSE, + edges_as_lines = edges_as_lines, + compute_length = compute_length + ) + } + x_sfn +} diff --git a/man/create_from_spatial_points.Rd b/man/create_from_spatial_points.Rd index bca4e65f..300fc373 100644 --- a/man/create_from_spatial_points.Rd +++ b/man/create_from_spatial_points.Rd @@ -32,8 +32,7 @@ to \code{TRUE}.} 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 +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}.} diff --git a/man/play_spatial.Rd b/man/play_spatial.Rd new file mode 100644 index 00000000..25fc3d56 --- /dev/null +++ b/man/play_spatial.Rd @@ -0,0 +1,77 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/create.R +\name{play_spatial} +\alias{play_spatial} +\title{Create a spatial network with sampled nodes} +\usage{ +play_spatial( + 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. +See Details.} + +\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{ +Create a spatial network with sampled nodes +} +\details{ +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_spatial(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_spatial(10, units::set_units(1, "km"), bounds = st_bbox(roxel)) +net + +plot(net) + +par(oldpar) + +} From 4f8619430773821609aca224e42ce4169736f89e Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Mon, 29 Jul 2024 14:13:36 +0200 Subject: [PATCH 019/246] refactor: Tidy deprecation messages :construction: --- R/create.R | 52 +++----------------------------- R/messages.R | 84 ++++++++++++++++++++++++++++++++++++++++++++++++++++ R/paths.R | 78 ++++-------------------------------------------- 3 files changed, 94 insertions(+), 120 deletions(-) diff --git a/R/create.R b/R/create.R index 37471579..627319d0 100644 --- a/R/create.R +++ b/R/create.R @@ -101,7 +101,7 @@ #' sfnetwork(nodes, edges, compute_length = TRUE) #' #' @importFrom igraph edge_attr<- -#' @importFrom lifecycle deprecated deprecate_stop +#' @importFrom lifecycle deprecated #' @importFrom sf st_as_sf #' @importFrom tidygraph tbl_graph with_graph #' @export @@ -109,6 +109,7 @@ 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. @@ -156,15 +157,6 @@ sfnetwork = function(nodes, edges = NULL, directed = TRUE, node_key = "name", x_sfn = explicitize_edges(x_sfn) } } - ## DEPRECATION INFO ## - if (isTRUE(length_as_weight)) { - deprecate_stop( - when = "v1.0", - what = "sfnetwork(length_as_weight)", - with = "sfnetwork(compute_length)" - ) - } - ## END OF DEPRECATION INFO ## if (compute_length) { if ("length" %in% names(edges)) { raise_overwrite("length") @@ -268,48 +260,12 @@ as_sfnetwork.default = function(x, ...) { #' #' par(oldpar) #' -#' @importFrom lifecycle deprecate_stop #' @export as_sfnetwork.sf = function(x, ...) { - ## DEPRECATION INFO ## dots = list(...) - if (isTRUE(dots$length_as_weight)) { - 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." - ) - ) - ) - } - ## END OF DEPRECATION INFO ## + if (! is.null(dots$length_as_weight)) deprecate_length_as_weight("as_sfnetwork.sf") if (has_single_geom_type(x, "LINESTRING")) { - ## DEPRECATION INFO ## - if (isFALSE(dots$edges_as_lines)) { - 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." - ) - ) - ) - } - ## END OF DEPRECATION INFO ## + if (! is.null(dots$edges_as_lines)) deprecate_edges_as_lines() create_from_spatial_lines(x, ...) } else if (has_single_geom_type(x, "POINT")) { create_from_spatial_points(x, ...) diff --git a/R/messages.R b/R/messages.R index a65e7790..50d96870 100644 --- a/R/messages.R +++ b/R/messages.R @@ -66,3 +66,87 @@ raise_invalid_sf_column = function() { call. = FALSE ) } + +#' @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) + ) +} + +#' @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." + ) + ) + ) +} + +#' @importFrom lifecycle deprecate_warn +deprecate_weights_is_string = function(caller) { + deprecate_warn( + when = "v1.0", + what = paste(caller, "(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(caller) { + deprecate_warn( + when = "v1.0", + what = paste( + caller, + "(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`." + ) + ) + ) +} \ No newline at end of file diff --git a/R/paths.R b/R/paths.R index 9fc87359..c5a4d387 100644 --- a/R/paths.R +++ b/R/paths.R @@ -167,7 +167,6 @@ st_network_paths = function(x, from, to = igraph::V(x), } #' @importFrom igraph V -#' @importFrom lifecycle deprecate_warn #' @importFrom rlang enquo eval_tidy expr #' @importFrom sf st_geometry #' @importFrom tidygraph .E .register_graph_context @@ -188,45 +187,13 @@ st_network_paths.sfnetwork = function(x, from, to = igraph::V(x), weights = enquo(weights) weights = eval_tidy(weights, .E()) if (is_single_string(weights)) { - # Allow character values for backward compatibility and non-tidyversers. - ## DEPRECATION INFO ## - deprecate_warn( - when = "v1.0", - what = "st_network_paths(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." - ) - ) - ) - ## END OF DEPRECATION INFO ## + # Allow character values for backward compatibility. + deprecate_weights_is_string("st_network_paths") weights = eval_tidy(expr(.data[[weights]]), .E()) } if (is.null(weights)) { # Convert NULL to NA to align with tidygraph instead of igraph. - ## DEPRECATION INFO ## - deprecate_warn( - when = "v1.0", - what = paste( - "st_network_paths(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`." - ) - ) - ) - ## END OF DEPRECATION INFO ## + deprecate_weights_is_null("st_network_paths") weights = NA } # Call paths calculation function according to type argument. @@ -409,7 +376,6 @@ st_network_cost = function(x, from = igraph::V(x), to = igraph::V(x), } #' @importFrom igraph distances V -#' @importFrom lifecycle deprecate_warn #' @importFrom rlang enquo eval_tidy expr #' @importFrom tidygraph .E .register_graph_context #' @importFrom units as_units deparse_unit @@ -429,45 +395,13 @@ st_network_cost.sfnetwork = function(x, from = igraph::V(x), to = igraph::V(x), weights = enquo(weights) weights = eval_tidy(weights, .E()) if (is_single_string(weights)) { - # Allow character values for backward compatibility and non-tidyversers. - ## DEPRECATION INFO ## - deprecate_warn( - when = "v1.0", - what = "st_network_paths(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." - ) - ) - ) - ## END OF DEPRECATION INFO ## + # Allow character values for backward compatibility. + deprecate_weights_is_string("st_network_cost") weights = eval_tidy(expr(.data[[weights]]), .E()) } if (is.null(weights)) { # Convert NULL to NA to align with tidygraph instead of igraph. - ## DEPRECATION INFO ## - deprecate_warn( - when = "v1.0", - what = paste( - "st_network_paths(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`." - ) - ) - ) - ## END OF DEPRECATION INFO ## + deprecate_weights_is_null("st_network_cost") weights = NA } # Parse other arguments. From 1fecd8d88561f2b7c068001801dbbb652b89a4c3 Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Mon, 29 Jul 2024 14:14:59 +0200 Subject: [PATCH 020/246] refactor: Change handling of NULL edges in creation function :construction: --- R/create.R | 5 ----- 1 file changed, 5 deletions(-) diff --git a/R/create.R b/R/create.R index 627319d0..9caa11ac 100644 --- a/R/create.R +++ b/R/create.R @@ -133,11 +133,6 @@ sfnetwork = function(nodes, edges = NULL, directed = TRUE, node_key = "name", # --> 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) validate_network(x_sfn, message = message) - return (x_sfn) - } if (is_sf(edges)) { # Add sf attributes to the edges table. # They were removed when creating the tbl_graph. From 0ddcb79976719293aa02d7e009c1e52686b87716 Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Mon, 29 Jul 2024 14:39:32 +0200 Subject: [PATCH 021/246] fix: Implement new weights argument handling also in morphers :wrench: --- R/morphers.R | 38 +++++++++++++++++++++++++++++--------- man/spatial_morphers.Rd | 25 ++++++++++++++++++------- 2 files changed, 47 insertions(+), 16 deletions(-) diff --git a/R/morphers.R b/R/morphers.R index 73088e8f..8cc756aa 100644 --- a/R/morphers.R +++ b/R/morphers.R @@ -406,30 +406,50 @@ to_spatial_explicit = function(x, ...) { #' neighborhood. Should be a numeric value in the same units as the weight #' values used for distance calculation. #' -#' @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 weights The edge weights to be used in the shortest path calculation. +#' Can be a numeric vector of the same length as the number of edges, a +#' \link[=spatial_edge_measures]{spatial edge measure function}, or a column in +#' the edges table of the network. Tidy evaluation is used such that column +#' names can be specified as if they were variables in the environment (e.g. +#' simply \code{length} instead of \code{igraph::edge_attr(x, "length")}). +#' If set to \code{NULL} or \code{NA} no edge weights are used, and the +#' shortest path is the path with the fewest number of edges, ignoring space. +#' The default is \code{\link{edge_length}}, which computes the geographic +#' lengths of the edges. #' #' @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. #' #' @importFrom igraph induced_subgraph +#' @importFrom rlang enquo eval_tidy expr #' @importFrom tidygraph node_distance_from node_distance_to with_graph +#' .register_graph_context #' @export -to_spatial_neighborhood = function(x, node, threshold, weights = NULL, +to_spatial_neighborhood = function(x, node, threshold, weights = edge_length(), 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. + # Parse weights argument using tidy evaluation on the network edges. # This can be done equal to setting weights for path calculations. - weights = set_path_weights(x, weights) + # Note that once deprecation is settled we can just remove this. + # In that case tidygraph will take care of parsing the weights argument. + .register_graph_context(x, free = TRUE) + weights = enquo(weights) + weights = eval_tidy(weights, .E()) + if (is_single_string(weights)) { + # Allow character values for backward compatibility. + deprecate_weights_is_string("to_spatial_neighborhood") + 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("to_spatial_neighborhood") + weights = NA + } # 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) { diff --git a/man/spatial_morphers.Rd b/man/spatial_morphers.Rd index 2ad55187..e80b37ef 100644 --- a/man/spatial_morphers.Rd +++ b/man/spatial_morphers.Rd @@ -26,7 +26,14 @@ to_spatial_directed(x) to_spatial_explicit(x, ...) -to_spatial_neighborhood(x, node, threshold, weights = NULL, from = TRUE, ...) +to_spatial_neighborhood( + x, + node, + threshold, + weights = edge_length(), + from = TRUE, + ... +) to_spatial_shortest_paths(x, ...) @@ -90,12 +97,16 @@ 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{weights}{The edge weights to be used in the shortest path calculation. +Can be a numeric vector of the same length as the number of edges, a +\link[=spatial_edge_measures]{spatial edge measure function}, or a column in +the edges table of the network. Tidy evaluation is used such that column +names can be specified as if they were variables in the environment (e.g. +simply \code{length} instead of \code{igraph::edge_attr(x, "length")}). +If set to \code{NULL} or \code{NA} no edge weights are used, and the +shortest path is the path with the fewest number of edges, ignoring space. +The default is \code{\link{edge_length}}, which computes the geographic +lengths of the edges.} \item{from}{Should distances be calculated from the reference node towards the other nodes? Defaults to \code{TRUE}. If set to \code{FALSE}, distances From d149e667ed45a37a14220b4f4ccac5bf78c05fb1 Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Mon, 29 Jul 2024 17:10:47 +0200 Subject: [PATCH 022/246] refactor: Call st_network_cost from to_spatial_neighborhood :construction: --- NAMESPACE | 2 - R/messages.R | 18 ++++++++- R/morphers.R | 85 ++++++++++++++--------------------------- man/spatial_morphers.Rd | 49 +++++++----------------- 4 files changed, 57 insertions(+), 97 deletions(-) diff --git a/NAMESPACE b/NAMESPACE index 1af1414b..7559f1e9 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -253,8 +253,6 @@ importFrom(tidygraph,as_tbl_graph) 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) diff --git a/R/messages.R b/R/messages.R index 50d96870..0fec4326 100644 --- a/R/messages.R +++ b/R/messages.R @@ -117,7 +117,7 @@ deprecate_edges_as_lines = function() { deprecate_weights_is_string = function(caller) { deprecate_warn( when = "v1.0", - what = paste(caller, "(weights = 'uses tidy evaluation')"), + what = paste0(caller, "(weights = 'uses tidy evaluation')"), details = c( i = paste( "This means you can forward column names without quotations, e.g.", @@ -133,7 +133,7 @@ deprecate_weights_is_string = function(caller) { deprecate_weights_is_null = function(caller) { deprecate_warn( when = "v1.0", - what = paste( + what = paste0( caller, "(weights = 'if set to NULL means no edge weights are used')" ), @@ -149,4 +149,18 @@ deprecate_weights_is_null = function(caller) { ) ) ) +} + +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." + ) + ) + ) } \ No newline at end of file diff --git a/R/morphers.R b/R/morphers.R index 8cc756aa..1616e629 100644 --- a/R/morphers.R +++ b/R/morphers.R @@ -387,78 +387,49 @@ to_spatial_explicit = function(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}}. +#' a specific node. \code{...} is forwarded to \code{\link{st_network_cost}} to +#' compute the travel cost from the source node to all other nodes in the +#' network. 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 node The node for which the neighborhood will be calculated. Can be +#' an integer specifying its index. Can also be an object of class +#' \code{\link[sf]{sf}} or \code{\link[sf]{sfc}} containing a single spatial +#' feature. In that case, this feature will be snapped to its nearest node +#' before calculating the neighborhood. When multiple indices or features are +#' given, only the first one is used. #' #' @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. -#' -#' @param weights The edge weights to be used in the shortest path calculation. -#' Can be a numeric vector of the same length as the number of edges, a -#' \link[=spatial_edge_measures]{spatial edge measure function}, or a column in -#' the edges table of the network. Tidy evaluation is used such that column -#' names can be specified as if they were variables in the environment (e.g. -#' simply \code{length} instead of \code{igraph::edge_attr(x, "length")}). -#' If set to \code{NULL} or \code{NA} no edge weights are used, and the -#' shortest path is the path with the fewest number of edges, ignoring space. -#' The default is \code{\link{edge_length}}, which computes the geographic -#' lengths of the edges. -#' -#' @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. +#' values used for the cost matrix computation. Alternatively, units can be +#' specified explicitly by providing a \code{\link[units]{units}} object. #' #' @importFrom igraph induced_subgraph -#' @importFrom rlang enquo eval_tidy expr -#' @importFrom tidygraph node_distance_from node_distance_to with_graph -#' .register_graph_context +#' @importFrom units as_units deparse_unit #' @export -to_spatial_neighborhood = function(x, node, threshold, weights = edge_length(), - from = TRUE, ...) { +to_spatial_neighborhood = function(x, node, threshold, ...) { # 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 using tidy evaluation on the network edges. - # This can be done equal to setting weights for path calculations. - # Note that once deprecation is settled we can just remove this. - # In that case tidygraph will take care of parsing the weights argument. - .register_graph_context(x, free = TRUE) - weights = enquo(weights) - weights = eval_tidy(weights, .E()) - if (is_single_string(weights)) { - # Allow character values for backward compatibility. - deprecate_weights_is_string("to_spatial_neighborhood") - 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("to_spatial_neighborhood") - weights = NA - } - # 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, ...)) - } else { - dist = with_graph(x, node_distance_to(node, weights = weights, ...)) + # Compute the cost matrix from the source node. + # By calling st_network_cost with the given arguments. + args = list(...) + if (isFALSE(args$from)) { + # Deprecate the former "from" argument specifying routing direction. + deprecate_from() + args$direction = "in" } + args$x = x + args$from = node + costs = do.call("st_network_cost", args) # Use the given threshold to define which nodes are in the neighborhood. - in_neighborhood = dist <= threshold + if (inherits(costs, "units") && ! inherits(threshold, "units")) { + threshold = as_units(threshold, deparse_unit(costs)) + } + in_neighborhood = costs[1, ] <= threshold # Subset the network to keep only the nodes in the neighborhood. x_new = induced_subgraph(x, in_neighborhood) # Return in a list. diff --git a/man/spatial_morphers.Rd b/man/spatial_morphers.Rd index e80b37ef..cd9cd21a 100644 --- a/man/spatial_morphers.Rd +++ b/man/spatial_morphers.Rd @@ -26,14 +26,7 @@ to_spatial_directed(x) to_spatial_explicit(x, ...) -to_spatial_neighborhood( - x, - node, - threshold, - weights = edge_length(), - from = TRUE, - ... -) +to_spatial_neighborhood(x, node, threshold, ...) to_spatial_shortest_paths(x, ...) @@ -84,33 +77,18 @@ 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{node}{The node for which the neighborhood will be calculated. Can be +an integer specifying its index. Can also be an object of class +\code{\link[sf]{sf}} or \code{\link[sf]{sfc}} containing a single spatial +feature. In that case, this feature will be snapped to its nearest node +before calculating the neighborhood. When multiple indices or features are +given, only the first one is used.} \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 to be used in the shortest path calculation. -Can be a numeric vector of the same length as the number of edges, a -\link[=spatial_edge_measures]{spatial edge measure function}, or a column in -the edges table of the network. Tidy evaluation is used such that column -names can be specified as if they were variables in the environment (e.g. -simply \code{length} instead of \code{igraph::edge_attr(x, "length")}). -If set to \code{NULL} or \code{NA} no edge weights are used, and the -shortest path is the path with the fewest number of edges, ignoring space. -The default is \code{\link{edge_length}}, which computes the geographic -lengths of the edges.} - -\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.} +values used for the cost matrix computation. Alternatively, units can be +specified explicitly by providing a \code{\link[units]{units}} object.} \item{remove_multiple}{Should multiple edges be merged into one. Defaults to \code{TRUE}.} @@ -187,11 +165,10 @@ drawn between the source and target node of each edge. Returns a \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 source node to all other nodes in the +network. 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 From 637f7aca5b2a76b3a7eba80d79fcd0c1dfef368c Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Thu, 1 Aug 2024 14:08:28 +0200 Subject: [PATCH 023/246] feat: Add utility functions for finding nodes or edges :gift: --- NAMESPACE | 8 ++ R/blend.R | 4 +- R/morphers.R | 4 +- R/node.R | 1 - R/paths.R | 16 ++-- R/print.R | 5 +- R/utils.R | 186 ++++++++++++++++++++++++++++++++-------- man/ids.Rd | 32 +++++++ man/n.Rd | 28 ++++++ man/nearest.Rd | 54 ++++++++++++ man/nearest_ids.Rd | 41 +++++++++ man/st_network_cost.Rd | 4 +- man/st_network_paths.Rd | 2 +- 13 files changed, 328 insertions(+), 57 deletions(-) create mode 100644 man/ids.Rd create mode 100644 man/n.Rd create mode 100644 man/nearest.Rd create mode 100644 man/nearest_ids.Rd diff --git a/NAMESPACE b/NAMESPACE index 7559f1e9..00e79802 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -71,6 +71,7 @@ export(edge_covers) export(edge_crosses) export(edge_displacement) export(edge_equals) +export(edge_ids) export(edge_intersects) export(edge_is_covered_by) export(edge_is_disjoint) @@ -81,11 +82,18 @@ export(edge_overlaps) export(edge_touches) export(is.sfnetwork) export(is_sfnetwork) +export(n_edges) +export(n_nodes) +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_equals) +export(node_ids) export(node_intersects) export(node_is_covered_by) export(node_is_disjoint) diff --git a/R/blend.R b/R/blend.R index f2d7dea2..4101f70f 100644 --- a/R/blend.R +++ b/R/blend.R @@ -108,7 +108,7 @@ st_network_blend.sfnetwork = function(x, y, tolerance = Inf) { } #' @importFrom dplyr bind_rows full_join -#' @importFrom igraph is_directed vcount +#' @importFrom igraph is_directed #' @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<- @@ -129,7 +129,7 @@ blend_ = function(x, y, tolerance) { # --> 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) + ncount = n_nodes(x) geom_colname = attr(nodes, "sf_column") ## =========================== # STEP I: PARSE THE TOLERANCE diff --git a/R/morphers.R b/R/morphers.R index 1616e629..797973d3 100644 --- a/R/morphers.R +++ b/R/morphers.R @@ -412,7 +412,7 @@ to_spatial_neighborhood = function(x, node, threshold, ...) { # 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 (is_sf(node) | is_sfc(node)) node = nearest_node_ids(x, node) if (length(node) > 1) raise_multiple_elements("node") # Compute the cost matrix from the source node. # By calling st_network_cost with the given arguments. @@ -647,7 +647,7 @@ to_spatial_smooth = function(x, } protect = matched_names } else if (is_sf(protect) | is_sfc(protect)) { - protect = get_nearest_node_index(x, protect) + protect = nearest_node_ids(x, protect) } # Mark all protected nodes as not being a pseudo node. pseudo[protect] = FALSE diff --git a/R/node.R b/R/node.R index 3549b0bc..61d0f968 100644 --- a/R/node.R +++ b/R/node.R @@ -75,7 +75,6 @@ node_M = function() { get_coords(pull_node_geom(x), "M") } -#' @importFrom igraph vcount #' @importFrom sf st_coordinates get_coords = function(x, value) { all_coords = st_coordinates(x) diff --git a/R/paths.R b/R/paths.R index c5a4d387..c8b15950 100644 --- a/R/paths.R +++ b/R/paths.R @@ -160,7 +160,7 @@ #' #' @importFrom igraph V #' @export -st_network_paths = function(x, from, to = igraph::V(x), +st_network_paths = function(x, from, to = node_ids(x), weights = edge_length(), type = "shortest", use_names = TRUE, ...) { UseMethod("st_network_paths") @@ -171,15 +171,15 @@ st_network_paths = function(x, from, to = igraph::V(x), #' @importFrom sf st_geometry #' @importFrom tidygraph .E .register_graph_context #' @export -st_network_paths.sfnetwork = function(x, from, to = igraph::V(x), +st_network_paths.sfnetwork = function(x, from, to = node_ids(x), weights = edge_length(), type = "shortest", use_names = TRUE, ...) { # Parse from and to arguments. # --> Convert geometries to node indices. # --> Raise warnings when igraph requirements are not met. - 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) + if (is_sf(from) | is_sfc(from)) from = nearest_node_ids(x, from) + if (is_sf(to) | is_sfc(to)) to = nearest_node_ids(x, to) if (length(from) > 1) raise_multiple_elements("from") if (any(is.na(c(from, to)))) raise_na_values("from and/or to") # Parse weights argument using tidy evaluation on the network edges. @@ -369,7 +369,7 @@ get_all_simple_paths = function(x, from, to, use_names = TRUE, ...) { #' #' @importFrom igraph V #' @export -st_network_cost = function(x, from = igraph::V(x), to = igraph::V(x), +st_network_cost = function(x, from = node_ids(x), to = node_ids(x), weights = edge_length(), direction = "out", Inf_as_NaN = FALSE, ...) { UseMethod("st_network_cost") @@ -380,15 +380,15 @@ st_network_cost = function(x, from = igraph::V(x), to = igraph::V(x), #' @importFrom tidygraph .E .register_graph_context #' @importFrom units as_units deparse_unit #' @export -st_network_cost.sfnetwork = function(x, from = igraph::V(x), to = igraph::V(x), +st_network_cost.sfnetwork = function(x, from = node_ids(x), to = node_ids(x), weights = edge_length(), direction = "out", Inf_as_NaN = FALSE, ...) { # Parse from and to arguments. # --> Convert geometries to node indices. # --> Raise warnings when igraph requirements are not met. - 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) + if (is_sf(from) | is_sfc(from)) from = nearest_node_ids(x, from) + if (is_sf(to) | is_sfc(to)) to = nearest_node_ids(x, to) if (any(is.na(c(from, to)))) raise_na_values("from and/or to") # Parse weights argument using tidy evaluation on the network edges. .register_graph_context(x, free = TRUE) diff --git a/R/print.R b/R/print.R index 5a6b6804..9018acc6 100644 --- a/R/print.R +++ b/R/print.R @@ -1,4 +1,3 @@ -#' @importFrom igraph ecount vcount #' @importFrom sf st_crs #' @importFrom tibble as_tibble #' @importFrom tidygraph as_tbl_graph @@ -8,8 +7,8 @@ print.sfnetwork = function(x, ...) { 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. + nN = n_nodes(x) # Number of nodes in network. + nE = n_edges(x) # Number of edges in network. # Print header. cat_subtle(c("# A sfnetwork with", nN, "nodes and", nE, "edges\n")) cat_subtle("#\n") diff --git a/R/utils.R b/R/utils.R index e738348b..935d049c 100644 --- a/R/utils.R +++ b/R/utils.R @@ -1,3 +1,149 @@ +#' 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}}. +#' +#' @return An integer. +#' +#' @examples +#' net = as_sfnetwork(roxel) +#' n_nodes(net) +#' n_edges(net) +#' +#' @name n +#' @importFrom igraph vcount +#' @export +n_nodes = function(x) { + vcount(x) +} + +#' @name n +#' @importFrom igraph ecount +#' @export +n_edges = function(x) { + ecount(x) +} + +#' Extract the indices of nodes or edges from a network +#' +#' @param x An object of class \code{\link{sfnetwork}}, or any other network +#' object inheriting from \code{\link[igraph]{igraph}}. +#' +#' @details The indices in these objects are always integers that correspond to +#' rownumbers in respectively the nodes or edges table. +#' +#' @return An vector of integers. +#' +#' @examples +#' net = as_sfnetwork(roxel[1:10, ]) +#' node_ids(net) +#' edge_ids(net) +#' +#' @name ids +#' @export +node_ids = function(x) { + seq_len(n_nodes(x)) +} + +#' @name ids +#' @export +edge_ids = function(x) { + seq_len(n_edges(x)) +} + +#' 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}}. +#' +#' @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)) +#' +#' 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) { + nodes = nodes_as_sf(x) + nodes[st_nearest_feature(st_geometry(y), nodes), ] +} + +#' @name nearest +#' @importFrom sf st_geometry st_nearest_feature +#' @export +nearest_edges = function(x, y) { + edges = edges_as_sf(x) + 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}}. +#' +#' @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)) +#' +#' 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) { + st_nearest_feature(st_geometry(y), nodes_as_sf(x)) +} + +#' @name nearest_ids +#' @importFrom sf st_geometry st_nearest_feature +#' @export +nearest_edge_ids = function(x, y) { + st_nearest_feature(st_geometry(y), edges_as_sf(x)) +} + +#' Get the nearest + #' Convert an adjacency matrix into a neighbor list #' #' Adjacency matrices of networks are n x n matrices with n being the number of @@ -212,7 +358,6 @@ edge_boundary_points = function(x) { #' indices of the start points of the edges, the seconds column contains the #' node indices of the end points of the edges. #' -#' @importFrom igraph ecount #' @importFrom sf st_equals #' @noRd edge_boundary_point_indices = function(x, matrix = FALSE) { @@ -224,7 +369,7 @@ edge_boundary_point_indices = function(x, matrix = FALSE) { # 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) { + if (length(idxs_vct) != n_edges(x) * 2) { n = length(idxs_lst) from = idxs_lst[seq(1, n - 1, 2)] to = idxs_lst[seq(2, n, 2)] @@ -265,7 +410,6 @@ edges_as_table = function(x) { #' @return An object of class \code{\link{sfnetwork}} with spatially explicit #' edges. #' -#' @importFrom igraph ecount #' @importFrom sf st_crs st_geometry st_sfc #' @importFrom tidygraph mutate #' @noRd @@ -274,7 +418,7 @@ explicitize_edges = function(x) { x } else { # Add empty geometry column if there are no edges. - if (ecount(x) == 0) { + if (n_edges(x) == 0) { return(mutate_edge_geom(x, st_sfc(crs = st_crs(x)))) } # Extract the node geometries from the network. @@ -290,40 +434,6 @@ explicitize_edges = function(x) { } } -#' Get the nearest nodes to given 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}}. -#' -#' @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}. -#' -#' @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), ] -} - -#' Get the index of the nearest nodes to given 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}}. -#' -#' @return An vector integers. The length of the vector will be equal to the -#' amount of features in \code{y}. -#' -#' @importFrom sf st_geometry st_nearest_feature -#' @noRd -get_nearest_node_index = function(x, y) { - st_nearest_feature(st_geometry(y), nodes_as_sf(x)) -} - #' Make edges spatially implicit #' #' @param x An object of class \code{\link{sfnetwork}}. diff --git a/man/ids.Rd b/man/ids.Rd new file mode 100644 index 00000000..8b25db5d --- /dev/null +++ b/man/ids.Rd @@ -0,0 +1,32 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/utils.R +\name{ids} +\alias{ids} +\alias{node_ids} +\alias{edge_ids} +\title{Extract the indices of nodes or edges from a network} +\usage{ +node_ids(x) + +edge_ids(x) +} +\arguments{ +\item{x}{An object of class \code{\link{sfnetwork}}, or any other network +object inheriting from \code{\link[igraph]{igraph}}.} +} +\value{ +An vector of integers. +} +\description{ +Extract the indices of nodes or edges from a 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/n.Rd b/man/n.Rd new file mode 100644 index 00000000..3b8921a6 --- /dev/null +++ b/man/n.Rd @@ -0,0 +1,28 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/utils.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) + +n_edges(x) +} +\arguments{ +\item{x}{An object of class \code{\link{sfnetwork}}, or any other network +object inheriting from \code{\link[igraph]{igraph}}.} +} +\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/nearest.Rd b/man/nearest.Rd new file mode 100644 index 00000000..95c75213 --- /dev/null +++ b/man/nearest.Rd @@ -0,0 +1,54 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/utils.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) + +nearest_edges(x, y) +} +\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}}.} +} +\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)) + +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..aa4c1b7b --- /dev/null +++ b/man/nearest_ids.Rd @@ -0,0 +1,41 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/utils.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) + +nearest_edge_ids(x, y) +} +\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}}.} +} +\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)) + +nearest_node_ids(net, pts) +nearest_edge_ids(net, pts) + +} diff --git a/man/st_network_cost.Rd b/man/st_network_cost.Rd index 9c9b51f8..f4672692 100644 --- a/man/st_network_cost.Rd +++ b/man/st_network_cost.Rd @@ -6,8 +6,8 @@ \usage{ st_network_cost( x, - from = igraph::V(x), - to = igraph::V(x), + from = node_ids(x), + to = node_ids(x), weights = edge_length(), direction = "out", Inf_as_NaN = FALSE, diff --git a/man/st_network_paths.Rd b/man/st_network_paths.Rd index b6f788cf..6004c379 100644 --- a/man/st_network_paths.Rd +++ b/man/st_network_paths.Rd @@ -7,7 +7,7 @@ st_network_paths( x, from, - to = igraph::V(x), + to = node_ids(x), weights = edge_length(), type = "shortest", use_names = TRUE, From a07f3c96c48de7947904e228bc881176615f05ca Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Thu, 1 Aug 2024 14:46:05 +0200 Subject: [PATCH 024/246] feat: New query functions node_is_nearest and edge_is_nearest :gift: --- NAMESPACE | 2 ++ R/edge.R | 35 +++++++++++++++++++++++----------- R/node.R | 35 +++++++++++++++++++++++----------- man/spatial_edge_predicates.Rd | 28 ++++++++++++++++----------- man/spatial_node_predicates.Rd | 28 ++++++++++++++++----------- 5 files changed, 84 insertions(+), 44 deletions(-) diff --git a/NAMESPACE b/NAMESPACE index 00e79802..c65bf6dd 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -75,6 +75,7 @@ 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) @@ -97,6 +98,7 @@ export(node_ids) export(node_intersects) export(node_is_covered_by) export(node_is_disjoint) +export(node_is_nearest) export(node_is_within) export(node_is_within_distance) export(node_touches) diff --git a/R/edge.R b/R/edge.R index 6c93ec1e..be5c25f3 100644 --- a/R/edge.R +++ b/R/edge.R @@ -134,10 +134,9 @@ straight_line_distance = function(x) { #' 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}}. @@ -149,13 +148,17 @@ straight_line_distance = function(x) { #' 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 @@ -306,3 +309,13 @@ edge_is_within_distance = function(y, ...) { x = .G() lengths(st_is_within_distance(pull_edge_geom(x), y, ...)) > 0 } + +#' @name spatial_edge_predicates +#' @export +edge_is_nearest = function(y) { + require_active_edges() + x = .G() + vec = rep(FALSE, n_edges(x)) + vec[nearest_edge_ids(x, y)] = TRUE + vec +} \ No newline at end of file diff --git a/R/node.R b/R/node.R index 61d0f968..c77b3cb9 100644 --- a/R/node.R +++ b/R/node.R @@ -93,10 +93,9 @@ 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}}. @@ -108,13 +107,17 @@ get_coords = function(x, value) { #' 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 @@ -226,3 +229,13 @@ node_is_within_distance = function(y, ...) { x = .G() lengths(st_is_within_distance(pull_node_geom(x), y, ...)) > 0 } + +#' @name spatial_node_predicates +#' @export +node_is_nearest = function(y) { + require_active_nodes() + x = .G() + vec = rep(FALSE, n_nodes(x)) + vec[nearest_node_ids(x, y)] = TRUE + vec +} diff --git a/man/spatial_edge_predicates.Rd b/man/spatial_edge_predicates.Rd index c1619b01..1aa5f1c2 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,6 +40,8 @@ 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 @@ -56,20 +59,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 diff --git a/man/spatial_node_predicates.Rd b/man/spatial_node_predicates.Rd index 5241f70f..312963f6 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,6 +25,8 @@ 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 @@ -41,20 +44,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 From 082de64bf63968eff2308e1ed16f3097a74de2d3 Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Thu, 1 Aug 2024 15:01:36 +0200 Subject: [PATCH 025/246] fix: Prohibit setting sparse argument in node and edge predicates :wrench: --- R/edge.R | 31 ++++++++++++++++++------------- R/node.R | 21 +++++++++++++-------- man/spatial_edge_predicates.Rd | 3 ++- man/spatial_node_predicates.Rd | 3 ++- 4 files changed, 35 insertions(+), 23 deletions(-) diff --git a/R/edge.R b/R/edge.R index be5c25f3..379bc79b 100644 --- a/R/edge.R +++ b/R/edge.R @@ -142,7 +142,8 @@ straight_line_distance = function(x) { #' 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. @@ -208,7 +209,7 @@ NULL edge_intersects = function(y, ...) { require_active_edges() x = .G() - lengths(st_intersects(pull_edge_geom(x), y, ...)) > 0 + evaluate_edge_predicate(st_intersects, x, y, ...) } #' @name spatial_edge_predicates @@ -217,7 +218,7 @@ edge_intersects = function(y, ...) { edge_is_disjoint = function(y, ...) { require_active_edges() x = .G() - lengths(st_disjoint(pull_edge_geom(x), y, ...)) > 0 + evaluate_edge_predicate(st_disjoint, x, y, ...) } #' @name spatial_edge_predicates @@ -226,7 +227,7 @@ edge_is_disjoint = function(y, ...) { edge_touches = function(y, ...) { require_active_edges() x = .G() - lengths(st_touches(pull_edge_geom(x), y, ...)) > 0 + evaluate_edge_predicate(st_touches, x, y, ...) } #' @name spatial_edge_predicates @@ -235,7 +236,7 @@ edge_touches = function(y, ...) { edge_crosses = function(y, ...) { require_active_edges() x = .G() - lengths(st_crosses(pull_edge_geom(x), y, ...)) > 0 + evaluate_edge_predicate(st_crosses, x, y, ...) } #' @name spatial_edge_predicates @@ -244,7 +245,7 @@ edge_crosses = function(y, ...) { edge_is_within = function(y, ...) { require_active_edges() x = .G() - lengths(st_within(pull_edge_geom(x), y, ...)) > 0 + evaluate_edge_predicate(st_within, x, y, ...) } #' @name spatial_edge_predicates @@ -253,7 +254,7 @@ edge_is_within = function(y, ...) { edge_contains = function(y, ...) { require_active_edges() x = .G() - lengths(st_contains(pull_edge_geom(x), y, ...)) > 0 + evaluate_edge_predicate(st_contains, x, y, ...) } #' @name spatial_edge_predicates @@ -262,7 +263,7 @@ edge_contains = function(y, ...) { 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, x, y, ...) } #' @name spatial_edge_predicates @@ -271,7 +272,7 @@ edge_contains_properly = function(y, ...) { edge_overlaps = function(y, ...) { require_active_edges() x = .G() - lengths(st_overlaps(pull_edge_geom(x), y, ...)) > 0 + evaluate_edge_predicate(st_overlaps, x, y, ...) } #' @name spatial_edge_predicates @@ -280,7 +281,7 @@ edge_overlaps = function(y, ...) { edge_equals = function(y, ...) { require_active_edges() x = .G() - lengths(st_equals(pull_edge_geom(x), y, ...)) > 0 + evaluate_edge_predicate(st_equals, x, y, ...) } #' @name spatial_edge_predicates @@ -289,7 +290,7 @@ edge_equals = function(y, ...) { edge_covers = function(y, ...) { require_active_edges() x = .G() - lengths(st_covers(pull_edge_geom(x), y, ...)) > 0 + evaluate_edge_predicate(st_covers, x, y, ...) } #' @name spatial_edge_predicates @@ -298,7 +299,7 @@ edge_covers = function(y, ...) { 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, x, y, ...) } #' @name spatial_edge_predicates @@ -307,7 +308,7 @@ edge_is_covered_by = function(y, ...) { edge_is_within_distance = function(y, ...) { require_active_edges() x = .G() - lengths(st_is_within_distance(pull_edge_geom(x), y, ...)) > 0 + evaluate_edge_predicate(st_is_within_distance, x, y, ...) } #' @name spatial_edge_predicates @@ -318,4 +319,8 @@ edge_is_nearest = function(y) { vec = rep(FALSE, n_edges(x)) vec[nearest_edge_ids(x, y)] = TRUE vec +} + +evaluate_edge_predicate = function(predicate, x, y, ...) { + lengths(predicate(pull_edge_geom(x), y, sparse = TRUE, ...)) > 0 } \ No newline at end of file diff --git a/R/node.R b/R/node.R index c77b3cb9..0df5f4e8 100644 --- a/R/node.R +++ b/R/node.R @@ -101,7 +101,8 @@ get_coords = function(x, value) { #' 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. @@ -173,7 +174,7 @@ NULL node_intersects = function(y, ...) { require_active_nodes() x = .G() - lengths(st_intersects(pull_node_geom(x), y, ...)) > 0 + evaluate_node_predicate(st_intersects, x, y, ...) } #' @name spatial_node_predicates @@ -182,7 +183,7 @@ node_intersects = function(y, ...) { node_is_disjoint = function(y, ...) { require_active_nodes() x = .G() - lengths(st_disjoint(pull_node_geom(x), y, ...)) > 0 + evaluate_node_predicate(st_disjoint, x, y, ...) } #' @name spatial_node_predicates @@ -191,7 +192,7 @@ node_is_disjoint = function(y, ...) { node_touches = function(y, ...) { require_active_nodes() x = .G() - lengths(st_touches(pull_node_geom(x), y, ...)) > 0 + evaluate_node_predicate(st_touches, x, y, ...) } #' @name spatial_node_predicates @@ -200,7 +201,7 @@ node_touches = function(y, ...) { node_is_within = function(y, ...) { require_active_nodes() x = .G() - lengths(st_within(pull_node_geom(x), y, ...)) > 0 + evaluate_node_predicate(st_within, x, y, ...) } #' @name spatial_node_predicates @@ -209,7 +210,7 @@ node_is_within = function(y, ...) { node_equals = function(y, ...) { require_active_nodes() x = .G() - lengths(st_equals(pull_node_geom(x), y, ...)) > 0 + evaluate_node_predicate(st_equals, x, y, ...) } #' @name spatial_node_predicates @@ -218,7 +219,7 @@ node_equals = function(y, ...) { 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, x, y, ...) } #' @name spatial_node_predicates @@ -227,7 +228,7 @@ node_is_covered_by = function(y, ...) { node_is_within_distance = function(y, ...) { require_active_nodes() x = .G() - lengths(st_is_within_distance(pull_node_geom(x), y, ...)) > 0 + evaluate_node_predicate(st_is_within_distance, x, y, ...) } #' @name spatial_node_predicates @@ -239,3 +240,7 @@ node_is_nearest = function(y) { vec[nearest_node_ids(x, y)] = TRUE vec } + +evaluate_node_predicate = function(predicate, x, y, ...) { + lengths(predicate(pull_node_geom(x), y, sparse = TRUE, ...)) > 0 +} \ No newline at end of file diff --git a/man/spatial_edge_predicates.Rd b/man/spatial_edge_predicates.Rd index 1aa5f1c2..299c9028 100644 --- a/man/spatial_edge_predicates.Rd +++ b/man/spatial_edge_predicates.Rd @@ -48,7 +48,8 @@ edge_is_nearest(y) 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 diff --git a/man/spatial_node_predicates.Rd b/man/spatial_node_predicates.Rd index 312963f6..8d19bf75 100644 --- a/man/spatial_node_predicates.Rd +++ b/man/spatial_node_predicates.Rd @@ -33,7 +33,8 @@ node_is_nearest(y) 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 From 835a2bcd1188fcad5ea14decfa74e6d8d6ca2dac Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Sat, 3 Aug 2024 12:57:26 +0200 Subject: [PATCH 026/246] fix: Update and debug print method. Refs #247 #256 :wrench: --- NAMESPACE | 5 +- R/print.R | 301 +++++++++++++++++++++++++++++++++++------------------- R/utils.R | 75 +++++++++++--- 3 files changed, 260 insertions(+), 121 deletions(-) diff --git a/NAMESPACE b/NAMESPACE index c65bf6dd..97a8c883 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -123,7 +123,6 @@ export(to_spatial_transformed) export(validate_network) importFrom(cli,cli_alert) importFrom(cli,cli_alert_success) -importFrom(crayon,silver) importFrom(dplyr,across) importFrom(dplyr,bind_rows) importFrom(dplyr,full_join) @@ -183,6 +182,7 @@ importFrom(lifecycle,deprecate_stop) importFrom(lifecycle,deprecate_warn) importFrom(lifecycle,deprecated) importFrom(lwgeom,st_geod_azimuth) +importFrom(pillar,style_subtle) importFrom(rlang,enquo) importFrom(rlang,eval_tidy) importFrom(rlang,expr) @@ -251,7 +251,6 @@ importFrom(sfheaders,sfc_to_df) importFrom(stats,median) importFrom(tibble,as_tibble) importFrom(tibble,tibble) -importFrom(tibble,trunc_mat) importFrom(tidygraph,"%>%") importFrom(tidygraph,.E) importFrom(tidygraph,.G) @@ -268,13 +267,11 @@ importFrom(tidygraph,reroute) importFrom(tidygraph,tbl_graph) importFrom(tidygraph,unmorph) importFrom(tidygraph,with_graph) -importFrom(tools,toTitleCase) 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/R/print.R b/R/print.R index 9018acc6..07497be6 100644 --- a/R/print.R +++ b/R/print.R @@ -1,108 +1,81 @@ -#' @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 = n_nodes(x) # Number of nodes in network. - nE = n_edges(x) # Number of edges in network. +print.sfnetwork = function(x, ..., + n = getOption("sfn_max_print_active", default = 6), + n_non_active = getOption("sfn_max_print_inactive", default = 3)) { + N = as_tibble(x, "nodes") + E = as_tibble(x, "edges") + is_explicit = is_sf(E) # 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(c("# A sfnetwork:", nrow(N), "nodes and", nrow(E), "edges\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(describe_graph(x, is_explicit), "\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(describe_space(x, is_explicit), "\n") 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")) + # Print tables. + if (attr(x, "active") == "nodes") { + active_data = N + active_name = "Node data" + inactive_data = E + inactive_name = "Edge 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" - ) + active_data = E + active_name = "Edge data" + inactive_data = N + inactive_name = "Node data" } - x + 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 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") + cat(gsub("tbl_graph", "sfnetwork", out[[1]]), "\n") + cat(out[[2]], "\n") + cat(out[[3]], "\n") + cat(out[[4]], "\n") invisible(x) } # nocov start -#' Describe graph function for print method -#' From: https://github.com/thomasp85/tidygraph/blob/master/R/tbl_graph.R -#' November 5, 2020 +#' @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 +#' gorder count_components #' @noRd -describe_graph = function(x) { +describe_graph = function(x, is_explicit = NULL) { if (gorder(x) == 0) return("An empty graph") prop = list( simple = is_simple(x), @@ -111,34 +84,62 @@ describe_graph = function(x) { connected = is_connected(x), tree = is_tree(x), forest = is_forest(x), - DAG = is_dag(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) { - desc[1] = if (prop$directed) "A rooted" - else "An unrooted" - desc[2] = if (prop$tree) "tree" - else paste0( - "forest with ", - count_components(x), - " trees" - ) + 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 { - 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 "" - ) + 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(desc, collapse = " ") + paste(c("#", desc), collapse = " ") } -#' @importFrom igraph is_connected is_simple gorder gsize is_directed +#' @importFrom igraph is_connected is_simple gorder gsize is_tree = function(x) { is_connected(x) && is_simple(x) && @@ -153,4 +154,96 @@ is_forest = function(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 = attr(node_geom, "precision") + 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/utils.R b/R/utils.R index 935d049c..89e4d1e4 100644 --- a/R/utils.R +++ b/R/utils.R @@ -234,19 +234,6 @@ bind_rows_list = function(...) { mutate(out, across(which(!is_listcol), unlist)) } -#' Print a string with a subtle style. -#' -#' @param ... A string to print. -#' -#' @return A printed string to console with subtle style. -#' -#' @importFrom crayon silver -#' @noRd -cat_subtle = function(...) { # nocov start - # Util function for print method, testing should be up to crayon - cat(silver(...)) -} # nocov end - #' Draw lines between two sets of points, row-wise #' #' @param x An object of class \code{\link[sf]{sfc}} with \code{POINT} @@ -551,6 +538,68 @@ multilinestrings_to_linestrings = function(x) { lines } +#' 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 +} + #' Determine duplicated geometries #' #' @param x An object of class \code{\link[sf]{sf}} or \code{\link[sf]{sfc}}. From cf6f9308e7727e7fd339d283fdf184db0a846694 Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Sat, 3 Aug 2024 12:57:52 +0200 Subject: [PATCH 027/246] refactor: Tidy st_network_bbox function :construction: --- R/bbox.R | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/R/bbox.R b/R/bbox.R index 7ec52746..7c269345 100644 --- a/R/bbox.R +++ b/R/bbox.R @@ -56,13 +56,10 @@ st_network_bbox = function(x, ...) { #' @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"), ...) + nodes_bbox = st_bbox(pull_node_geom(x), ...) + edges_bbox = st_bbox(pull_edge_geom(x), ...) # 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 + merge_bboxes(nodes_bbox, edges_bbox) } + + From 34dfec106b386d8038900f85a30fb21f33bc5696 Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Sat, 3 Aug 2024 13:00:23 +0200 Subject: [PATCH 028/246] deps: Remove crayon as dependency :couple: --- DESCRIPTION | 1 - 1 file changed, 1 deletion(-) diff --git a/DESCRIPTION b/DESCRIPTION index 2465ac3f..ab5fc1b5 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -35,7 +35,6 @@ Depends: R (>= 3.6) Imports: cli, - crayon, dplyr, graphics, igraph, From 694040b847f3379a1ee0fef1a77f19d2ce4415a4 Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Tue, 6 Aug 2024 12:47:19 +0200 Subject: [PATCH 029/246] refactor: Tidy routing functions :construction: --- DESCRIPTION | 1 + NAMESPACE | 3 +- R/messages.R | 31 ++++- R/paths.R | 261 +++++++++++++++++++--------------------- man/st_network_cost.Rd | 48 ++++---- man/st_network_paths.Rd | 75 ++++++------ 6 files changed, 210 insertions(+), 209 deletions(-) diff --git a/DESCRIPTION b/DESCRIPTION index ab5fc1b5..b542f018 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -40,6 +40,7 @@ Imports: igraph, lifecycle, lwgeom, + methods, rlang, sf, sfheaders, diff --git a/NAMESPACE b/NAMESPACE index 97a8c883..17916e2c 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -136,7 +136,6 @@ 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) @@ -182,7 +181,9 @@ importFrom(lifecycle,deprecate_stop) importFrom(lifecycle,deprecate_warn) importFrom(lifecycle,deprecated) importFrom(lwgeom,st_geod_azimuth) +importFrom(methods,hasArg) importFrom(pillar,style_subtle) +importFrom(rlang,dots_n) importFrom(rlang,enquo) importFrom(rlang,eval_tidy) importFrom(rlang,expr) diff --git a/R/messages.R b/R/messages.R index 0fec4326..0c9612dc 100644 --- a/R/messages.R +++ b/R/messages.R @@ -19,17 +19,18 @@ raise_assume_planar = function(caller) { raise_multiple_elements = function(arg) { warning( - "Although argument ", + "Although argument `", arg, - " has length > 1, only the first element is used", + "` has length > 1, only the first element is used", call. = FALSE ) } raise_na_values = function(arg) { stop( - "NA values present in argument ", + "NA values present in argument `", arg, + "`", call. = FALSE ) } @@ -44,9 +45,9 @@ raise_overwrite = function(value) { raise_reserved_attr = function(value) { stop( - "The attribute name '", + "The attribute name `", value, - "' is reserved", + "` is reserved", call. = FALSE ) } @@ -59,6 +60,26 @@ raise_unknown_input = function(value) { ) } +raise_unsupported_arg = function(arg, replacement = NULL) { + if (is.null(replacement)) { + stop( + "Setting argument `", + name, + "` is not supported", + call. = FALSE + ) + } else { + stop( + "Setting argument `", + name, + "` is not supported, use `", + replacement, + "` instead", + call. = FALSE + ) + } +} + raise_invalid_sf_column = function() { stop( "Attribute 'sf_column' does not point to a geometry column.\n", diff --git a/R/paths.R b/R/paths.R index c8b15950..40d55837 100644 --- a/R/paths.R +++ b/R/paths.R @@ -1,31 +1,25 @@ -#' Paths between points in geographical space +#' Find paths between nodes in a spatial network #' -#' 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. +#' A function implementing one-to-one and one-to-many routing on spatial +#' networks. It can be used to either find one shortest path, all shortest +#' paths, or all simple paths between one node and one or +#' more other nodes in the 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. Can be an integer +#' specifying its index or a character specifying its name. Can also be an +#' object of class \code{\link[sf]{sf}} or \code{\link[sf]{sfc}} containing a +#' single spatial feature. In that case, this feature will be snapped to its +#' nearest node before finding the paths. When multiple indices, names or +#' features are given, only the first one is used. +#' +#' @param to The nodes where the paths should end. Can be an integer vector +#' specifying their indices or a character vector specifying their name. Can +#' also be an object of class \code{\link[sf]{sf}} or \code{\link[sf]{sfc}} +#' containing spatial features. In that case, these feature will be snapped to +#' their nearest node before finding the paths. 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 of the same length as the number of edges, a @@ -39,22 +33,28 @@ #' lengths of the edges. #' #' @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 +#' performed. If set to \code{'shortest'} paths are found using +#' \code{\link[igraph]{shortest_paths}}, if set to \code{'all_shortest'} paths +#' are found using \code{\link[igraph]{all_shortest_paths}}, if set to +#' \code{'all_simple'} paths are found using #' \code{\link[igraph]{all_simple_paths}}. Defaults to \code{'shortest'}. #' +#' @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 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 #' 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. +#' @param ... Additional arguments passed on to the wrapped igraph functions. +#' Arguments \code{predecessors} and \code{inbound.edges} are ignored. +#' Instead of the \code{mode} argument, use the \code{direction} argument. #' #' @details Spatial features provided to the \code{from} and/or #' \code{to} argument don't necessarily have to be points. Internally, the @@ -70,8 +70,8 @@ #' 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 +#' For more details on the wrapped igraph functions see the +#' \code{\link[igraph]{distances}} and #' \code{\link[igraph]{all_simple_paths}} documentation pages. #' #' @seealso \code{\link{st_network_cost}} @@ -81,8 +81,8 @@ #' 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. +#' that path). Type \code{'all_simple'} returns only \code{node_paths}, while +#' \code{'shortest'} and \code{'all_shortest'} return both. #' #' @examples #' library(sf, quietly = TRUE) @@ -158,24 +158,23 @@ #' # If there is more than one shortest path, this returns one path per row. #' st_network_paths(net, from = 5, to = 1, type = "all_shortest") #' -#' @importFrom igraph V #' @export st_network_paths = function(x, from, to = node_ids(x), weights = edge_length(), type = "shortest", - use_names = TRUE, ...) { + direction = "out", use_names = TRUE, ...) { UseMethod("st_network_paths") } -#' @importFrom igraph V +#' @importFrom igraph vertex_attr vertex_attr_names #' @importFrom rlang enquo eval_tidy expr -#' @importFrom sf st_geometry +#' @importFrom tibble as_tibble #' @importFrom tidygraph .E .register_graph_context #' @export st_network_paths.sfnetwork = function(x, from, to = node_ids(x), weights = edge_length(), - type = "shortest", + type = "shortest", direction = "out", use_names = TRUE, ...) { - # Parse from and to arguments. + # Parse from and to arguments. # --> Convert geometries to node indices. # --> Raise warnings when igraph requirements are not met. if (is_sf(from) | is_sfc(from)) from = nearest_node_ids(x, from) @@ -196,89 +195,82 @@ st_network_paths.sfnetwork = function(x, from, to = node_ids(x), deprecate_weights_is_null("st_network_paths") weights = NA } - # 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) - ) -} - -#' @importFrom igraph shortest_paths vertex_attr_names -#' @importFrom tibble as_tibble -get_shortest_paths = function(x, from, to, weights, use_names = TRUE, ...) { - # Call igraph function. - paths = shortest_paths(x, from, to, weights = weights, output = "both", ...) - # Extract vector of node indices or names. + # Compute the shortest paths. + paths = igraph_paths(x, from, to, weights, type, direction, ...) + # 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$node_paths = lapply(paths$node_paths, \(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))) + # Return as a tibble. + as_tibble(do.call(cbind, paths)) } -#' @importFrom igraph all_shortest_paths vertex_attr_names -#' @importFrom tibble as_tibble -get_all_shortest_paths = function(x, from, to, weights, use_names = TRUE,...) { - # 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) - } - # Return as column in a tibble. - as_tibble(do.call(cbind, list(node_paths = npaths))) -} - -#' @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") - } else { - npaths = lapply(paths, as.integer) - } - # Return as column in a tibble. - as_tibble(do.call(cbind, list(node_paths = npaths))) +#' @importFrom igraph all_shortest_paths all_simple_paths shortest_paths +#' igraph_opt igraph_options +#' @importFrom methods hasArg +igraph_paths = function(x, from, to, weights, type = "shortest", + 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") + # Call igraph paths calculation function according to type argument. + paths = switch( + type, + shortest = shortest_paths( + x, from, to, + weights = weights, + output = "both", + mode = direction, + ... + ), + all_shortest = all_shortest_paths( + x, from, to, + weights = weights, + mode = direction, + ... + ), + all_simple = list(vpaths = all_simple_paths( + x, from, to, + mode = direction, + ... + )), + raise_unknown_input(type) + ) + # Extract the nodes in the paths, and the edges in the paths (if given). + npaths = paths[[1]] + epaths = if (length(paths) > 1) paths[[2]] else NULL + # Return in a list. + list(node_paths = npaths, edge_paths = epaths) } #' 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. +#' A function to 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 (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 from The nodes where the paths should start. Can be an integer vector +#' specifying their indices or a character vector specifying their name. Can +#' also be an object of class \code{\link[sf]{sf}} or \code{\link[sf]{sfc}} +#' containing spatial features. In that case, these feature will be snapped to +#' their nearest node before finding the paths. By default, all nodes in the +#' network are included. +#' +#' @param to The nodes where the paths should end. Can be an integer vector +#' specifying their indices or a character vector specifying their name. Can +#' also be an object of class \code{\link[sf]{sf}} or \code{\link[sf]{sfc}} +#' containing spatial features. In that case, these feature will be snapped to +#' their nearest node before finding the paths. 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 of the same length as the number of edges, a @@ -292,9 +284,9 @@ get_all_simple_paths = function(x, from, to, use_names = TRUE, ...) { #' 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 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. @@ -302,8 +294,8 @@ get_all_simple_paths = function(x, from, to, use_names = TRUE, ...) { #' @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. +#' @param ... Additional arguments passed on to \code{\link[igraph]{distances}}. +#' Instead of the \code{mode} argument, use the \code{direction} argument. #' #' @details Spatial features provided to the \code{from} and/or #' \code{to} argument don't necessarily have to be points. Internally, the @@ -319,8 +311,8 @@ get_all_simple_paths = function(x, from, to, use_names = TRUE, ...) { #' 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. +#' For more details on the wrapped igraph function see the +#' \code{\link[igraph]{distances}} documentation page. #' #' @seealso \code{\link{st_network_paths}} #' @@ -367,7 +359,6 @@ get_all_simple_paths = function(x, from, to, use_names = TRUE, ...) { #' cost_matrix = st_network_cost(net) #' dim(cost_matrix) #' -#' @importFrom igraph V #' @export st_network_cost = function(x, from = node_ids(x), to = node_ids(x), weights = edge_length(), direction = "out", @@ -375,7 +366,8 @@ st_network_cost = function(x, from = node_ids(x), to = node_ids(x), UseMethod("st_network_cost") } -#' @importFrom igraph distances V +#' @importFrom igraph distances +#' @importFrom methods hasArg #' @importFrom rlang enquo eval_tidy expr #' @importFrom tidygraph .E .register_graph_context #' @importFrom units as_units deparse_unit @@ -405,29 +397,20 @@ st_network_cost.sfnetwork = function(x, from = node_ids(x), to = node_ids(x), weights = NA } # Parse other arguments. - # --> The mode argument in ... is ignored in favor of the direction argument. - dots = list(...) - if (!is.null(dots$mode)) { - dots$mode = NULL - warning( - "Argument `mode` is ignored, use `direction` instead.", - call. = 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 the 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))) { - to_unique = unique(to) - match = match(to, to_unique) - args = list(x, from, to_unique, weights = weights, mode = direction) - matrix = do.call(igraph::distances, c(args, dots)) - matrix = matrix[, match, drop = FALSE] + tou = unique(to) + matrix = distances(x, from, tou, weights = weights, mode = direction, ...) + matrix = matrix[, match(to, tou), drop = FALSE] } else { - args = list(x, from, to, weights = weights, mode = direction) - matrix = do.call(igraph::distances, c(args, dots)) + matrix = distances(x, from, to, weights = weights, mode = direction, ...) } # Post-process and return. # --> Convert Inf to NaN if requested. diff --git a/man/st_network_cost.Rd b/man/st_network_cost.Rd index f4672692..c0260b97 100644 --- a/man/st_network_cost.Rd +++ b/man/st_network_cost.Rd @@ -17,22 +17,19 @@ st_network_cost( \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. Can be an integer vector +specifying their indices or a character vector specifying their name. Can +also be an object of class \code{\link[sf]{sf}} or \code{\link[sf]{sfc}} +containing spatial features. In that case, these feature will be snapped to +their nearest node before finding the paths. By default, all nodes in the +network are included.} + +\item{to}{The nodes where the paths should end. Can be an integer vector +specifying their indices or a character vector specifying their name. Can +also be an object of class \code{\link[sf]{sf}} or \code{\link[sf]{sfc}} +containing spatial features. In that case, these feature will be snapped to +their nearest node before finding the paths. 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 of the same length as the number of edges, a @@ -46,9 +43,9 @@ 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.} @@ -56,19 +53,16 @@ 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{...}{Additional arguments passed on to \code{\link[igraph]{distances}}. +Instead of the \code{mode} argument, use the \code{direction} argument.} } \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. +A function to compute total travel costs of shortest paths between nodes +in a spatial network. } \details{ Spatial features provided to the \code{from} and/or @@ -85,8 +79,8 @@ 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. +For more details on the wrapped igraph function see the +\code{\link[igraph]{distances}} documentation page. } \examples{ library(sf, quietly = TRUE) diff --git a/man/st_network_paths.Rd b/man/st_network_paths.Rd index 6004c379..41ad5b8f 100644 --- a/man/st_network_paths.Rd +++ b/man/st_network_paths.Rd @@ -2,7 +2,7 @@ % Please edit documentation in R/paths.R \name{st_network_paths} \alias{st_network_paths} -\title{Paths between points in geographical space} +\title{Find paths between nodes in a spatial network} \usage{ st_network_paths( x, @@ -10,6 +10,7 @@ st_network_paths( to = node_ids(x), weights = edge_length(), type = "shortest", + direction = "out", use_names = TRUE, ... ) @@ -17,22 +18,19 @@ st_network_paths( \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. Can be an integer +specifying its index or a character specifying its name. Can also be an +object of class \code{\link[sf]{sf}} or \code{\link[sf]{sfc}} containing a +single spatial feature. In that case, this feature will be snapped to its +nearest node before finding the paths. When multiple indices, names or +features are given, only the first one is used.} + +\item{to}{The nodes where the paths should end. Can be an integer vector +specifying their indices or a character vector specifying their name. Can +also be an object of class \code{\link[sf]{sf}} or \code{\link[sf]{sfc}} +containing spatial features. In that case, these feature will be snapped to +their nearest node before finding the paths. 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 of the same length as the number of edges, a @@ -46,22 +44,28 @@ The default is \code{\link{edge_length}}, which computes the geographic lengths of the edges.} \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 +performed. If set to \code{'shortest'} paths are found using +\code{\link[igraph]{shortest_paths}}, if set to \code{'all_shortest'} paths +are found using \code{\link[igraph]{all_shortest_paths}}, if set to +\code{'all_simple'} paths are found using \code{\link[igraph]{all_simple_paths}}. Defaults to \code{'shortest'}.} +\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{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 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{...}{Additional arguments passed on to the wrapped igraph functions. +Arguments \code{predecessors} and \code{inbound.edges} are ignored. +Instead of the \code{mode} argument, use the \code{direction} argument.} } \value{ An object of class \code{\link[tibble]{tbl_df}} with one row per @@ -69,17 +73,14 @@ 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. +that path). Type \code{'all_simple'} returns only \code{node_paths}, while +\code{'shortest'} and \code{'all_shortest'} return both. } \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. +A function implementing one-to-one and one-to-many routing on spatial +networks. It can be used to either find one shortest path, all shortest +paths, or all simple paths between one node and one or +more other nodes in the network. } \details{ Spatial features provided to the \code{from} and/or @@ -96,8 +97,8 @@ 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 +For more details on the wrapped igraph functions see the +\code{\link[igraph]{distances}} and \code{\link[igraph]{all_simple_paths}} documentation pages. } \examples{ From 957a759b0a5b7095a1d64a0db95de62ac6142515 Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Tue, 6 Aug 2024 12:47:42 +0200 Subject: [PATCH 030/246] refactor: Tidy :construction: --- R/create.R | 37 +++++++++++++++++++------------------ R/morphers.R | 33 +++++++++++++++------------------ R/plot.R | 30 +++++++++++++++++------------- R/sf.R | 15 +++++++-------- R/utils.R | 2 -- man/spatial_morphers.Rd | 10 +++++----- 6 files changed, 63 insertions(+), 64 deletions(-) diff --git a/R/create.R b/R/create.R index 9caa11ac..b6062eda 100644 --- a/R/create.R +++ b/R/create.R @@ -255,12 +255,16 @@ as_sfnetwork.default = function(x, ...) { #' #' par(oldpar) #' +#' @importFrom methods hasArg #' @export as_sfnetwork.sf = function(x, ...) { - dots = list(...) - if (! is.null(dots$length_as_weight)) deprecate_length_as_weight("as_sfnetwork.sf") + if (hasArg("length_as_weight") && length_as_weight) { + deprecate_length_as_weight("as_sfnetwork.sf") + } if (has_single_geom_type(x, "LINESTRING")) { - if (! is.null(dots$edges_as_lines)) deprecate_edges_as_lines() + if (hasArg("edges_as_lines") && ! is.null(dots$edges_as_lines)) { + deprecate_edges_as_lines() + } create_from_spatial_lines(x, ...) } else if (has_single_geom_type(x, "POINT")) { create_from_spatial_points(x, ...) @@ -346,16 +350,14 @@ as_sfnetwork.psp = function(x, ...) { #' through the \code{directed} argument. #' #' @importFrom igraph is_directed +#' @importFrom methods hasArg #' @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) + 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 @@ -375,15 +377,14 @@ as_sfnetwork.sfNetwork = function(x, ...) { #' as_sfnetwork(tbl_net, coords = c("lon", "lat"), crs = 4326) #' #' @importFrom igraph is_directed +#' @importFrom methods hasArg #' @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) + if (hasArg("directed")) { + sfnetwork(as.list(x), ...) + } else { + sfnetwork(as.list(x), directed = is_directed(x), ...) + } } #' Create a spatial network from linestring geometries diff --git a/R/morphers.R b/R/morphers.R index 797973d3..afd73986 100644 --- a/R/morphers.R +++ b/R/morphers.R @@ -365,14 +365,14 @@ 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 rlang dots_n #' @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) { + if (dots_n > 0) { edges = edges_as_table(x) new_edges = st_as_sf(edges, ...) x_new = x @@ -393,11 +393,11 @@ to_spatial_explicit = function(x, ...) { #' class \code{\link{sfnetwork}}. #' #' @param node The node for which the neighborhood will be calculated. Can be -#' an integer specifying its index. Can also be an object of class -#' \code{\link[sf]{sf}} or \code{\link[sf]{sfc}} containing a single spatial -#' feature. In that case, this feature will be snapped to its nearest node -#' before calculating the neighborhood. When multiple indices or features are -#' given, only the first one is used. +#' an integer specifying its index or a character specifying its name. Can also +#' be an object of class \code{\link[sf]{sf}} or \code{\link[sf]{sfc}} +#' containing a single spatial feature. In that case, this feature will be +#' snapped to its nearest node before calculating the neighborhood. When +#' multiple indices, names or features are given, only the first one is used. #' #' @param threshold The threshold distance to be used. Only nodes within the #' threshold distance from the reference node will be included in the @@ -406,6 +406,7 @@ to_spatial_explicit = function(x, ...) { #' specified explicitly by providing a \code{\link[units]{units}} object. #' #' @importFrom igraph induced_subgraph +#' @importFrom methods hasArg #' @importFrom units as_units deparse_unit #' @export to_spatial_neighborhood = function(x, node, threshold, ...) { @@ -416,15 +417,13 @@ to_spatial_neighborhood = function(x, node, threshold, ...) { if (length(node) > 1) raise_multiple_elements("node") # Compute the cost matrix from the source node. # By calling st_network_cost with the given arguments. - args = list(...) - if (isFALSE(args$from)) { + if (hasArg("from") && isFALSE(from)) { # Deprecate the former "from" argument specifying routing direction. deprecate_from() - args$direction = "in" + costs = st_network_cost(x, from = node, direction = "in", ...) + } else { + costs = st_network_cost(x, from = node, ...) } - args$x = x - args$from = node - costs = do.call("st_network_cost", args) # Use the given threshold to define which nodes are in the neighborhood. if (inherits(costs, "units") && ! inherits(threshold, "units")) { threshold = as_units(threshold, deparse_unit(costs)) @@ -449,11 +448,9 @@ to_spatial_neighborhood = function(x, node, threshold, ...) { #' @importFrom igraph delete_edges delete_vertices edge_attr vertex_attr #' @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) + if (hasArg("type")) raise_unsupported_arg("type") + paths = st_network_paths(x, ..., type = "shortest") # 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") @@ -581,7 +578,7 @@ to_spatial_smooth = function(x, # 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. + # 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)) diff --git a/R/plot.R b/R/plot.R index 5f7e82d5..14e832e6 100644 --- a/R/plot.R +++ b/R/plot.R @@ -41,23 +41,27 @@ #' par(oldpar) #' #' @importFrom graphics plot +#' @importFrom methods hasArg #' @importFrom sf st_geometry #' @export plot.sfnetwork = function(x, draw_lines = TRUE, ...) { - # Extract and setup extra args. - 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)) + # Default pch should be 20. + node_geoms = pull_node_geom(x) + if (hasArg("pch")) { + plot(node_geoms, ...) + } else { + plot(node_geoms, pch = 20, ...) + } + # Plot the edges. + if (has_explicit_edges(x)) { + plot(pull_edge_geom(x), ..., add = TRUE) + } else { + if (draw_lines) { + bids = edge_boundary_node_indices(x, matrix = TRUE) + lines = draw_lines(node_geoms[bids[, 1]], node_geoms[bids[, 2]]) + plot(lines, ..., add = TRUE) + } } invisible() } diff --git a/R/sf.R b/R/sf.R index 50f854dd..a6a42a72 100644 --- a/R/sf.R +++ b/R/sf.R @@ -263,11 +263,11 @@ st_z_range.sfnetwork = function(obj, active = NULL, ...) { 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) } @@ -354,7 +354,7 @@ 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, ...)) + d_tmp = op(x_sf, ...) mutate_geom(x, st_geometry(d_tmp), active = active) } @@ -401,6 +401,7 @@ st_join.morphed_sfnetwork = function(x, y, ...) { } #' @importFrom igraph delete_vertices vertex_attr<- +#' @importFrom methods hasArg #' @importFrom sf st_as_sf st_join spatial_join_nodes = function(x, y, ...) { # Convert x and y to sf. @@ -430,8 +431,7 @@ spatial_join_nodes = function(x, y, ...) { # 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 (hasArg("left") && ! 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 @@ -608,8 +608,7 @@ spatial_clip_edges = function(x, y, ..., .operator = sf::st_intersection) { ## =========================== # 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. @@ -690,7 +689,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] diff --git a/R/utils.R b/R/utils.R index 89e4d1e4..3fd039e9 100644 --- a/R/utils.R +++ b/R/utils.R @@ -142,8 +142,6 @@ nearest_edge_ids = function(x, y) { st_nearest_feature(st_geometry(y), edges_as_sf(x)) } -#' Get the nearest - #' Convert an adjacency matrix into a neighbor list #' #' Adjacency matrices of networks are n x n matrices with n being the number of diff --git a/man/spatial_morphers.Rd b/man/spatial_morphers.Rd index cd9cd21a..5aa7776e 100644 --- a/man/spatial_morphers.Rd +++ b/man/spatial_morphers.Rd @@ -78,11 +78,11 @@ column named \code{.orig_data}. This is in line with the design principles of \code{tidygraph}. Defaults to \code{FALSE}.} \item{node}{The node for which the neighborhood will be calculated. Can be -an integer specifying its index. Can also be an object of class -\code{\link[sf]{sf}} or \code{\link[sf]{sfc}} containing a single spatial -feature. In that case, this feature will be snapped to its nearest node -before calculating the neighborhood. When multiple indices or features are -given, only the first one is used.} +an integer specifying its index or a character specifying its name. Can also +be an object of class \code{\link[sf]{sf}} or \code{\link[sf]{sfc}} +containing a single spatial feature. In that case, this feature will be +snapped to its nearest node before calculating the neighborhood. When +multiple indices, names or features are given, only the first one is used.} \item{threshold}{The threshold distance to be used. Only nodes within the threshold distance from the reference node will be included in the From b1ffcebdbb592f259c38a1e0c3f0d3aefa13f3bc Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Tue, 6 Aug 2024 18:42:25 +0200 Subject: [PATCH 031/246] feat: New structure for the output of st_network_paths :gift: --- NAMESPACE | 2 + R/morphers.R | 13 +++- R/paths.R | 141 +++++++++++++++++++++++++++------------- R/utils.R | 39 +++++++++++ man/st_network_paths.Rd | 82 +++++++++++++---------- 5 files changed, 195 insertions(+), 82 deletions(-) diff --git a/NAMESPACE b/NAMESPACE index 17916e2c..e5494aa6 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -187,6 +187,7 @@ importFrom(rlang,dots_n) importFrom(rlang,enquo) importFrom(rlang,eval_tidy) importFrom(rlang,expr) +importFrom(rlang,has_name) importFrom(sf,"st_agr<-") importFrom(sf,"st_crs<-") importFrom(sf,"st_geometry<-") @@ -226,6 +227,7 @@ 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) diff --git a/R/morphers.R b/R/morphers.R index afd73986..ac5b0885 100644 --- a/R/morphers.R +++ b/R/morphers.R @@ -450,14 +450,21 @@ to_spatial_neighborhood = function(x, node, threshold, ...) { to_spatial_shortest_paths = function(x, ...) { # Call st_network_paths with the given arguments. if (hasArg("type")) raise_unsupported_arg("type") - paths = st_network_paths(x, ..., type = "shortest") + paths = st_network_paths( + x, + ..., + type = "shortest", + 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") # 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]]) + edge_idxs = as.integer(paths$edges[[i]]) + node_idxs = as.integer(paths$nodes[[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 diff --git a/R/paths.R b/R/paths.R index 40d55837..4d73db96 100644 --- a/R/paths.R +++ b/R/paths.R @@ -52,6 +52,15 @@ #' the node indices? Defaults to \code{TRUE}. Ignored when the nodes table does #' not have a column named \code{name}. #' +#' @param return_cost Should the total cost of each path be computed? Defaults +#' to \code{TRUE}. Ignored if \code{type = 'all_simple'}. +#' +#' @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 if \code{type = 'all_simple'} and for networks with +#' spatially implicit edges. +#' #' @param ... Additional arguments passed on to the wrapped igraph functions. #' Arguments \code{predecessors} and \code{inbound.edges} are ignored. #' Instead of the \code{mode} argument, use the \code{direction} argument. @@ -70,24 +79,51 @@ #' named \code{name}. This column should contain character values without #' duplicates. #' +#' When computing simple paths by setting \code{type = 'all_simple'}, note that +#' potentially there are exponentially many paths between two nodes, and you +#' may run out of memory especially in undirected, dense, and/or lattice-like +#' networks. +#' #' For more details on the wrapped igraph functions see the #' \code{\link[igraph]{distances}} and #' \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). Type \code{'all_simple'} returns only \code{node_paths}, while -#' \code{'shortest'} and \code{'all_shortest'} return both. +#' @return An object of class \code{\link[tibble]{tbl_df}} or +#' \code{\link[sf]{sf}} with one row per path. If \code{type = 'shortest'}, the +#' number of rows is always equal to the number of requested paths, meaning +#' that node pairs for which no path could be found are still part of the +#' output. For all other path types, the output only contains finite paths. +#' +#' Depending on the argument setting, 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{nodes}: A vector containing the indices of all nodes on the +#' path, in order of visit. +#' \item \code{edges}: A vector containing the indices of all edges on the +#' path, in order of visit. Not returned if \code{type = 'all_simple'}. +#' \item \code{path_found}: A boolean describing if a path was found between +#' the two nodes. Returned only if \code{type = 'shortest'}. +#' \item \code{cost}: The total cost of the path, obtained by summing the +#' weights of all visited edges. Returned only if \code{return_cost = TRUE}. +#' Never returned if \code{type = 'all_simple'}. +#' \item \code{geometry}: The geometry of the path, obtained by merging the +#' geometries of all visited edges. Returned only if +#' \code{return_geometry = TRUE} and the network has spatially explicit +#' edges. Never returned if \code{type = 'all_simple'}. +#' } #' #' @examples #' library(sf, quietly = TRUE) #' library(tidygraph, quietly = TRUE) #' +#' oldpar = par(no.readonly = TRUE) +#' par(mar = c(1,1,1,1)) +#' #' net = as_sfnetwork(roxel, directed = FALSE) |> #' st_transform(3035) #' @@ -96,19 +132,8 @@ #' paths = st_network_paths(net, from = 495, to = 121) #' 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(slice(net, node_path), col = "red", add = TRUE) -#' par(oldpar) +#' plot(st_geometry(paths), col = "red", lwd = 1.5, add = TRUE) #' #' # Compute the shortest paths from one to multiple nodes. #' # This will return a tibble with one row per path. @@ -124,20 +149,9 @@ #' 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(net, node_path), col = "red", add = TRUE) -#' par(oldpar) +#' plot(st_geometry(paths), col = "red", lwd = 1.5, add = TRUE) #' #' # Use a spatial edge measure to specify edge weights. #' # By default edge_length() is used. @@ -154,32 +168,32 @@ #' # This is the path with the fewest number of edges, ignoring space. #' st_network_paths(net, p1, p2, weights = NULL) #' -#' # Compute all shortest paths between two nodes. -#' # If there is more than one shortest path, this returns one path per row. -#' st_network_paths(net, from = 5, to = 1, type = "all_shortest") +#' par(oldpar) #' #' @export st_network_paths = function(x, from, to = node_ids(x), weights = edge_length(), type = "shortest", - direction = "out", use_names = TRUE, ...) { + direction = "out", use_names = TRUE, + return_cost = TRUE, return_geometry = TRUE, ...) { UseMethod("st_network_paths") } #' @importFrom igraph vertex_attr vertex_attr_names -#' @importFrom rlang enquo eval_tidy expr -#' @importFrom tibble as_tibble +#' @importFrom rlang enquo eval_tidy expr has_name +#' @importFrom sf st_as_sf #' @importFrom tidygraph .E .register_graph_context #' @export st_network_paths.sfnetwork = function(x, from, to = node_ids(x), weights = edge_length(), type = "shortest", direction = "out", - use_names = TRUE, ...) { - # Parse from and to arguments. + use_names = TRUE, return_cost = TRUE, + return_geometry = TRUE, ...) { + # Parse from and to arguments. # --> Convert geometries to node indices. # --> Raise warnings when igraph requirements are not met. if (is_sf(from) | is_sfc(from)) from = nearest_node_ids(x, from) if (is_sf(to) | is_sfc(to)) to = nearest_node_ids(x, to) - if (length(from) > 1) raise_multiple_elements("from") + if (length(from) > 1) raise_multiple_elements("from"); from = from[1] if (any(is.na(c(from, to)))) raise_na_values("from and/or to") # Parse weights argument using tidy evaluation on the network edges. .register_graph_context(x, free = TRUE) @@ -200,15 +214,38 @@ st_network_paths.sfnetwork = function(x, from, to = node_ids(x), # Convert node indices to node names if requested. if (use_names && "name" %in% vertex_attr_names(x)) { nnames = vertex_attr(x, "name") - paths$node_paths = lapply(paths$node_paths, \(x) nnames[x]) + paths$from = do.call("c", lapply(paths$from, \(x) nnames[x])) + paths$to = do.call("c", lapply(paths$to, \(x) nnames[x])) + paths$nodes = lapply(paths$nodes, \(x) nnames[x]) + } + # Enrich the paths with additional information. + if (has_name(paths, "edges")) { + E = paths$edges + # Compute total cost of each path if requested. + if (return_cost) { + if (length(weights) == 1 && is.na(weights)) { + costs = do.call("c", lapply(paths$edges, length)) + } else { + costs = do.call("c", lapply(paths$edges, \(x) sum(weights[x]))) + } + if (has_name(paths, "path_found")) costs[paths$path_found] = Inf + paths$cost = costs + } + # Construct path geometries of requested. + if (return_geometry && has_explicit_edges(x)) { + egeom = pull_edge_geom(x) + pgeom = do.call("c", lapply(paths$edges, \(x) merge_lines(egeom[x]))) + paths$geometry = pgeom + paths = st_as_sf(paths) + } } - # Return as a tibble. - as_tibble(do.call(cbind, paths)) + paths } #' @importFrom igraph all_shortest_paths all_simple_paths shortest_paths #' igraph_opt igraph_options #' @importFrom methods hasArg +#' @importFrom tibble tibble igraph_paths = function(x, from, to, weights, type = "shortest", direction = "out", ...) { # Change default igraph options. @@ -247,8 +284,22 @@ igraph_paths = function(x, from, to, weights, type = "shortest", # Extract the nodes in the paths, and the edges in the paths (if given). npaths = paths[[1]] epaths = if (length(paths) > 1) paths[[2]] else NULL - # Return in a list. - list(node_paths = npaths, edge_paths = epaths) + # Define the nodes from which the returned paths start and at which they end. + if (type == "shortest") { + starts = rep(from, length(to)) + ends = to + path_found = lengths(epaths) > 0 | starts == ends + } else { + starts = do.call("c", lapply(npaths, `[`, 1)) + ends = do.call("c", lapply(npaths, last_element)) + path_found = NULL + } + # Return in a tibble. + tibble( + from = starts, to = ends, + nodes = npaths, edges = epaths, + path_found = path_found + ) } #' Compute a cost matrix of a spatial network diff --git a/R/utils.R b/R/utils.R index 3fd039e9..0d83acf8 100644 --- a/R/utils.R +++ b/R/utils.R @@ -232,6 +232,22 @@ bind_rows_list = function(...) { mutate(out, across(which(!is_listcol), unlist)) } +#' Get the last element of a vector +#' +#' @param x A vector. +#' +#' @return The last element of \code{x}. +#' +#' @noRd +last_element = function(x) { + n = length(x) + if (n > 0) { + x[n] + } else { + x[1] + } +} + #' Draw lines between two sets of points, row-wise #' #' @param x An object of class \code{\link[sf]{sfc}} with \code{POINT} @@ -259,6 +275,29 @@ draw_lines = function(x, y) { lines } +#' Merge multiple linestring geometries into one linestring +#' +#' @param x An object of class \code{\link[sf]{sfc}} with \code{LINESTRING} +#' geometries. +#' +#' @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. +#' +#' @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 +merge_lines = function(x) { + if (length(x) == 0) { + st_sfc(st_linestring(), crs = st_crs(x)) + } else { + st_line_merge(st_combine(x)) + } +} + #' Get the geometries of the boundary nodes of edges in an sfnetwork #' #' @param x An object of class \code{\link{sfnetwork}}. diff --git a/man/st_network_paths.Rd b/man/st_network_paths.Rd index 41ad5b8f..4baf9085 100644 --- a/man/st_network_paths.Rd +++ b/man/st_network_paths.Rd @@ -12,6 +12,8 @@ st_network_paths( type = "shortest", direction = "out", use_names = TRUE, + return_cost = TRUE, + return_geometry = TRUE, ... ) } @@ -63,18 +65,46 @@ 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 not have a column named \code{name}.} +\item{return_cost}{Should the total cost of each path be computed? Defaults +to \code{TRUE}. Ignored if \code{type = 'all_simple'}.} + +\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 if \code{type = 'all_simple'} and for networks with +spatially implicit edges.} + \item{...}{Additional arguments passed on to the wrapped igraph functions. Arguments \code{predecessors} and \code{inbound.edges} are ignored. Instead of the \code{mode} argument, use the \code{direction} argument.} } \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). Type \code{'all_simple'} returns only \code{node_paths}, while -\code{'shortest'} and \code{'all_shortest'} return both. +An object of class \code{\link[tibble]{tbl_df}} or +\code{\link[sf]{sf}} with one row per path. If \code{type = 'shortest'}, the +number of rows is always equal to the number of requested paths, meaning +that node pairs for which no path could be found are still part of the +output. For all other path types, the output only contains finite paths. + +Depending on the argument setting, 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{nodes}: A vector containing the indices of all nodes on the + path, in order of visit. + \item \code{edges}: A vector containing the indices of all edges on the + path, in order of visit. Not returned if \code{type = 'all_simple'}. + \item \code{path_found}: A boolean describing if a path was found between + the two nodes. Returned only if \code{type = 'shortest'}. + \item \code{cost}: The total cost of the path, obtained by summing the + weights of all visited edges. Returned only if \code{return_cost = TRUE}. + Never returned if \code{type = 'all_simple'}. + \item \code{geometry}: The geometry of the path, obtained by merging the + geometries of all visited edges. Returned only if + \code{return_geometry = TRUE} and the network has spatially explicit + edges. Never returned if \code{type = 'all_simple'}. +} } \description{ A function implementing one-to-one and one-to-many routing on spatial @@ -97,6 +127,11 @@ 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. +When computing simple paths by setting \code{type = 'all_simple'}, note that +potentially there are exponentially many paths between two nodes, and you +may run out of memory especially in undirected, dense, and/or lattice-like +networks. + For more details on the wrapped igraph functions see the \code{\link[igraph]{distances}} and \code{\link[igraph]{all_simple_paths}} documentation pages. @@ -105,6 +140,9 @@ For more details on the wrapped igraph functions see the library(sf, quietly = TRUE) library(tidygraph, quietly = TRUE) +oldpar = par(no.readonly = TRUE) +par(mar = c(1,1,1,1)) + net = as_sfnetwork(roxel, directed = FALSE) |> st_transform(3035) @@ -113,19 +151,8 @@ net = as_sfnetwork(roxel, directed = FALSE) |> paths = st_network_paths(net, from = 495, to = 121) 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(slice(net, node_path), col = "red", add = TRUE) -par(oldpar) +plot(st_geometry(paths), col = "red", lwd = 1.5, add = TRUE) # Compute the shortest paths from one to multiple nodes. # This will return a tibble with one row per path. @@ -141,20 +168,9 @@ 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(net, node_path), col = "red", add = TRUE) -par(oldpar) +plot(st_geometry(paths), col = "red", lwd = 1.5, add = TRUE) # Use a spatial edge measure to specify edge weights. # By default edge_length() is used. @@ -171,9 +187,7 @@ net |> # This is the path with the fewest number of edges, ignoring space. st_network_paths(net, p1, p2, weights = NULL) -# Compute all shortest paths between two nodes. -# If there is more than one shortest path, this returns one path per row. -st_network_paths(net, from = 5, to = 1, type = "all_shortest") +par(oldpar) } \seealso{ From 4957eab5631116d4d9493f883abd1b686d943c07 Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Wed, 7 Aug 2024 17:30:47 +0200 Subject: [PATCH 032/246] refactor: Tidy messages and convert to cli + rlang :construction: --- NAMESPACE | 6 ++- R/agr.R | 4 +- R/attrs.R | 8 +-- R/blend.R | 27 ++++++++-- R/checks.R | 88 ++++++++++++-------------------- R/convert.R | 49 +++--------------- R/create.R | 56 +++++++++++++-------- R/geom.R | 14 +++--- R/join.R | 23 +++++++-- R/messages.R | 128 +++++++++++++++++++++++++---------------------- R/morphers.R | 36 +++++++------ R/paths.R | 5 +- R/sf.R | 12 ++--- R/tidygraph.R | 14 ++++-- R/validate.R | 32 +++--------- man/as.linnet.Rd | 2 +- 16 files changed, 248 insertions(+), 256 deletions(-) diff --git a/NAMESPACE b/NAMESPACE index e5494aa6..42bbce16 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -121,8 +121,10 @@ export(to_spatial_subdivision) export(to_spatial_subset) export(to_spatial_transformed) export(validate_network) +importFrom(cli,cli_abort) importFrom(cli,cli_alert) importFrom(cli,cli_alert_success) +importFrom(cli,cli_warn) importFrom(dplyr,across) importFrom(dplyr,bind_rows) importFrom(dplyr,full_join) @@ -183,11 +185,14 @@ importFrom(lifecycle,deprecated) importFrom(lwgeom,st_geod_azimuth) importFrom(methods,hasArg) importFrom(pillar,style_subtle) +importFrom(rlang,check_installed) importFrom(rlang,dots_n) importFrom(rlang,enquo) importFrom(rlang,eval_tidy) importFrom(rlang,expr) importFrom(rlang,has_name) +importFrom(rlang,is_installed) +importFrom(rlang,quo_text) importFrom(sf,"st_agr<-") importFrom(sf,"st_crs<-") importFrom(sf,"st_geometry<-") @@ -276,5 +281,4 @@ importFrom(units,drop_units) importFrom(units,set_units) importFrom(utils,capture.output) importFrom(utils,head) -importFrom(utils,packageVersion) importFrom(utils,tail) diff --git a/R/agr.R b/R/agr.R index 7f9a0d27..139189e7 100644 --- a/R/agr.R +++ b/R/agr.R @@ -21,7 +21,7 @@ agr = function(x, active = NULL) { active, nodes = node_agr(x), edges = edge_agr(x), - raise_unknown_input(active) + raise_invalid_active(active) ) } @@ -53,7 +53,7 @@ edge_agr = function(x) { active, nodes = `node_agr<-`(x, value), edges = `edge_agr<-`(x, value), - raise_unknown_input(active) + raise_invalid_active(active) ) } diff --git a/R/attrs.R b/R/attrs.R index ceff6459..d2d2dc4e 100644 --- a/R/attrs.R +++ b/R/attrs.R @@ -26,7 +26,7 @@ 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")) ) } @@ -131,7 +131,7 @@ attribute_names = function(x, active = NULL, idxs = FALSE, geom = TRUE) { active, nodes = node_attribute_names(x, geom = geom), edges = edge_attribute_names(x, idxs = idxs, geom = geom), - raise_unknown_input(active) + raise_invalid_active(active) ) } @@ -191,7 +191,7 @@ edge_attribute_names = function(x, idxs = FALSE, geom = TRUE) { active, nodes = `node_attribute_values<-`(x, value), edges = `edge_attribute_values<-`(x, value), - raise_unknown_input(active) + raise_invalid_active(active) ) } @@ -264,6 +264,6 @@ summariser = function(name) { mean = function(x) mean(x), median = function(x) median(x), concat = function(x) c(x), - raise_unknown_input(name) + raise_unknown_summariser(name) ) } diff --git a/R/blend.R b/R/blend.R index 4101f70f..6be8d9fa 100644 --- a/R/blend.R +++ b/R/blend.R @@ -97,13 +97,30 @@ st_network_blend = function(x, y, tolerance = Inf) { UseMethod("st_network_blend") } +#' @importFrom cli cli_abort #' @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") + 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 (! has_single_geom_type(y, "POINT")) { + 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) } diff --git a/R/checks.R b/R/checks.R index 21739f6b..4c196c7c 100644 --- a/R/checks.R +++ b/R/checks.R @@ -244,16 +244,16 @@ will_assume_constant = function(x) { any(is.na(real_agr)) || any(real_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() } @@ -267,26 +267,22 @@ will_assume_planar = function(x) { #' 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.") } } @@ -294,46 +290,21 @@ 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) { +require_explicit_edges = function(x) { 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 - ) - } + cli_abort(c( + "This call requires spatially explicit edges.", + "i" = "If you meant to call it on the nodes, activate nodes first.", + "i" = "Call {.code convert(x, to_spatial_explicit} to explicitize edges." + )) } } -#' Proceed only when the network has a valid sfnetwork structure -#' -#' @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 has a valid sfnetwork structure, an error -#' message otherwise. -#' -#' @noRd -require_valid_network_structure = function(x, message = FALSE) { - validate_network(x, message) -} #' Proceed only if the given object is a valid adjacency matrix #' @@ -350,15 +321,20 @@ require_valid_network_structure = function(x, message = FALSE) { #' @return Nothing if the given object is a valid adjacency matrix #' referencing the given nodes, an error message otherwise. #' +#' @importFrom cli cli_abort #' @importFrom sf st_geometry #' @noRd require_valid_adjacency_matrix = function(x, nodes) { n_nodes = length(st_geometry(nodes)) if (! (nrow(x) == n_nodes && ncol(x) == n_nodes)) { - stop( - "The dimensions of the adjacency matrix should match the ", - " number of nodes (", n_nodes, ").", - call. = FALSE + cli_abort( + c( + "The dimensions of the matrix should match the number of nodes.", + "x" = paste( + "The provided matrix has dimensions {nrow(x)} x {ncol(x)},", + "while there are {n_nodes} nodes." + ) + ) ) } } @@ -377,21 +353,23 @@ require_valid_adjacency_matrix = function(x, nodes) { #' @return Nothing if the given object is a valid neighbor list referencing #' the given nodes, and error message afterwards. #' +#' @importFrom cli cli_abort #' @importFrom sf st_geometry #' @noRd require_valid_neighbor_list = function(x, nodes) { n_nodes = length(st_geometry(nodes)) if (! length(x) == n_nodes) { - stop( - "The length of the sparse adjacency matrix should match the ", - " number of nodes (", n_nodes, ").", - call. = FALSE + 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." + ) + ) ) } if (! all(vapply(x, is.integer, FUN.VALUE = logical(1)))) { - stop( - "The sparse adjacency matrix should contain integer node indices.", - call. = FALSE - ) + cli_abort("The sparse matrix should contain integer node indices.") } } diff --git a/R/convert.R b/R/convert.R index 97ee49d3..33948974 100644 --- a/R/convert.R +++ b/R/convert.R @@ -55,14 +55,14 @@ as_tibble.sfnetwork = function(x, active = NULL, spatial = TRUE, ...) { active, nodes = nodes_as_sf(x), edges = edges_as_table(x), - raise_unknown_input(active) + 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) + raise_invalid_active(active) ) } } @@ -104,11 +104,14 @@ as_s2_geography.sfnetwork = function(x, ...) { #' \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") + check_installed("spatstat.geom") + check_installed("spatstat.linnet") + check_installed("sf (>= 1.0)") + if (is_installed("spatstat")) check_installed("spatstat (>= 2.0)") # Extract the vertices of the sfnetwork. X_vertices_ppp = spatstat.geom::as.ppp(pull_node_geom(X)) # Extract the edge list. @@ -122,41 +125,3 @@ as.linnet.sfnetwork = function(X, ...) { ... ) } - -#' @importFrom utils packageVersion -check_spatstat = function(pkg) { - # Auxiliary function which is used to test that: - # --> The relevant spatstat packages are installed. - # --> The spatstat version is 2.0.0 or greater. - 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() -} - -#' @importFrom utils packageVersion -check_spatstat_sf = function() { - # Auxiliary function which is used to test that: - # --> The sf version is compatible with the new spatstat structure - if (packageVersion("sf") < "0.9.8") { - stop( - "spatstat code requires sf >= 0.9.8; please update sf", - call. = FALSE - ) - } -} \ No newline at end of file diff --git a/R/create.R b/R/create.R index b6062eda..3070fdf4 100644 --- a/R/create.R +++ b/R/create.R @@ -100,6 +100,7 @@ #' # 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 sf st_as_sf @@ -118,11 +119,11 @@ sfnetwork = function(nodes, edges = NULL, directed = TRUE, node_key = "name", nodes = tryCatch( st_as_sf(nodes, ...), error = function(e) { - stop( - "Failed to convert nodes to sf object because: ", - e, - call. = FALSE - ) + 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")) } ) } @@ -255,6 +256,7 @@ as_sfnetwork.default = function(x, ...) { #' #' par(oldpar) #' +#' @importFrom cli cli_abort #' @importFrom methods hasArg #' @export as_sfnetwork.sf = function(x, ...) { @@ -269,10 +271,11 @@ as_sfnetwork.sf = function(x, ...) { } else if (has_single_geom_type(x, "POINT")) { create_from_spatial_points(x, ...) } else { - stop( - "Geometries are not all of type LINESTRING, or all of type POINT", - call. = FALSE - ) + 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}." + )) } } @@ -304,9 +307,12 @@ as_sfnetwork.sfc = function(x, ...) { #' as_sfnetwork(simplenet) #' } #' +#' @importFrom rlang check_installed is_installed #' @export as_sfnetwork.linnet = function(x, ...) { - check_spatstat("spatstat.geom") + 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) @@ -326,10 +332,11 @@ as_sfnetwork.linnet = function(x, ...) { #' as_sfnetwork(test_psp) #' } #' +#' @importFrom rlang check_installed #' @importFrom sf st_as_sf st_collection_extract #' @export as_sfnetwork.psp = function(x, ...) { - check_spatstat_sf() + 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. @@ -619,7 +626,7 @@ create_from_spatial_points = function(x, connections = "complete", relative_neighbourhood = relative_neighbors(x), nearest_neighbors = nearest_neighbors(x, k), nearest_neighbours = nearest_neighbors(x, k), - raise_unknown_input(connections) + raise_unknown_input("connections", connections) ) } else { nblist = custom_neighbors(x, connections) @@ -627,6 +634,7 @@ create_from_spatial_points = function(x, connections = "complete", nb2net(nblist, x, directed, edges_as_lines, compute_length) } +#' @importFrom cli cli_abort custom_neighbors = function(x, connections) { if (is.matrix(connections)) { require_valid_adjacency_matrix(connections, x) @@ -635,11 +643,13 @@ custom_neighbors = function(x, connections) { require_valid_neighbor_list(connections, x) connections } else { - stop( - "Connections should be specified as a matrix, a list-formatted sparse", - " matrix, or a single character.", - call. = FALSE - ) + cli_abort(c( + "Invalid value for {.arg connections}.", + "i" = paste( + "Connections should be specified as a matrix, a list-formatted", + "sparse matrix, or a single character." + ) + )) } } @@ -683,27 +693,31 @@ mst_neighbors = function(x, directed = TRUE, edges_as_lines = TRUE) { as_adj_list(mst) } +#' @importFrom rlang check_installed #' @importFrom sf st_geometry delaunay_neighbors = function(x) { - requireNamespace("spdep") # Package spdep is required for this function. + check_installed("spdep") # Package spdep is required for this function. tri2nb(st_geometry(x)) } +#' @importFrom rlang check_installed #' @importFrom sf st_geometry gabriel_neighbors = function(x) { - requireNamespace("spdep") # Package spdep is required for this function. + 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) { - requireNamespace("spdep") # Package spdep is required for this function. + 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) { - requireNamespace("spdep") # Package spdep is required for this function. + check_installed("spdep") # Package spdep is required for this function. spdep::knn2nb(spdep::knearneigh(st_geometry(x), k = k), sym = FALSE) } diff --git a/R/geom.R b/R/geom.R index 70ac425b..8fa1d908 100644 --- a/R/geom.R +++ b/R/geom.R @@ -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) ) } @@ -58,7 +58,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) ) } @@ -96,7 +96,7 @@ pull_geom = function(x, active = NULL) { active, nodes = pull_node_geom(x), edges = pull_edge_geom(x), - raise_unknown_input(active) + raise_invalid_active(active) ) } @@ -143,7 +143,7 @@ mutate_geom = function(x, y, active = NULL) { active, nodes = mutate_node_geom(x, y), edges = mutate_edge_geom(x, y), - raise_unknown_input(active) + raise_invalid_active(active) ) } @@ -188,7 +188,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,9 +208,7 @@ 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 x_new diff --git a/R/join.R b/R/join.R index a1f16f02..27c0bc07 100644 --- a/R/join.R +++ b/R/join.R @@ -47,11 +47,28 @@ st_network_join = function(x, y, ...) { UseMethod("st_network_join") } +#' @importFrom cli cli_abort #' @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) + } + 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, ...) } diff --git a/R/messages.R b/R/messages.R index 0c9612dc..025a6fc0 100644 --- a/R/messages.R +++ b/R/messages.R @@ -1,91 +1,101 @@ # 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.", + "x" = "Not all attributes are labelled as being constant.", + "i" = "You can label attribute-geometry relations using {.fn sf::st_set_agr}." + ), + call = NULL ) } -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.", + "x" = 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}." + ), + call = NULL ) } +#' @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.", call = NULL) } +#' @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}.", call = NULL) } +#' @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.") } -raise_unknown_input = function(value) { - stop( - "Unknown input: ", - value, - call. = FALSE - ) +#' @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_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_summariser = 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)) { - stop( - "Setting argument `", - name, - "` is not supported", - call. = FALSE - ) + cli_abort("Setting argument {.arg {arg}} is not supported") } else { - stop( - "Setting argument `", - name, - "` is not supported, use `", - replacement, - "` instead", - call. = FALSE - ) + cli_abort(c( + "Setting argument {.arg {arg}} is not supported.", + "i" = "Use {.arg {replacement}} instead." + )) } } +#' @importFrom cli cli_abort 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 - ) + 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 lifecycle deprecate_stop @@ -109,7 +119,7 @@ deprecate_length_as_weight = function(caller) { ) ) ), - raise_unknown_input(caller) + raise_unknown_input("caller", caller) ) } diff --git a/R/morphers.R b/R/morphers.R index ac5b0885..8ce7acee 100644 --- a/R/morphers.R +++ b/R/morphers.R @@ -91,7 +91,7 @@ NULL to_spatial_contracted = function(x, ..., simplify = FALSE, summarise_attributes = "ignore", store_original_data = FALSE) { - if (will_assume_planar(x)) raise_assume_planar("to_spatial_contracted") + if (will_assume_projected(x)) raise_assume_projected("to_spatial_contracted") # Retrieve nodes from the network. nodes = nodes_as_sf(x) geom_colname = attr(nodes, "sf_column") @@ -571,6 +571,7 @@ to_spatial_simple = function(x, remove_multiple = TRUE, remove_loops = TRUE, #' those attributes are checked for equality. Equality tests are evaluated #' using the \code{==} operator. #' +#' @importFrom cli cli_abort #' @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 @@ -632,22 +633,19 @@ to_spatial_smooth = function(x, # 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 - ) + cli_abort(c( + "Failed to identify protected nodes by their name.", + "x" = "There is not node attribute {.field name}" + )) } # 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 - ) + unknown_names = paste(protect[is.na(matched_names)], collapse = ", ") + cli_abort(c( + "Failed to identify protected nodes by their name.", + "x" = "The following node names were not found: {unknown_names}" + )) } protect = matched_names } else if (is_sf(protect) | is_sfc(protect)) { @@ -668,11 +666,11 @@ to_spatial_smooth = function(x, # 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 - ) + unknown_attrs = paste(require_equal[!attr_exists], collapse = ", ") + cli_abort(c( + "Failed to check for edge attribute equality.", + "x" = "The following edge attributes were not found: {unknown_attrs}" + )) } } # Get the node indices of the detected pseudo nodes. @@ -1165,7 +1163,7 @@ to_spatial_subset = function(x, ..., subset_by = NULL) { 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")) ) list( subset = x_new diff --git a/R/paths.R b/R/paths.R index 4d73db96..6ba7f253 100644 --- a/R/paths.R +++ b/R/paths.R @@ -194,7 +194,8 @@ st_network_paths.sfnetwork = function(x, from, to = node_ids(x), if (is_sf(from) | is_sfc(from)) from = nearest_node_ids(x, from) if (is_sf(to) | is_sfc(to)) to = nearest_node_ids(x, to) if (length(from) > 1) raise_multiple_elements("from"); from = from[1] - if (any(is.na(c(from, to)))) raise_na_values("from and/or to") + if (any(is.na(from))) raise_na_values("from") + if (any(is.na(to))) raise_na_value("to") # Parse weights argument using tidy evaluation on the network edges. .register_graph_context(x, free = TRUE) weights = enquo(weights) @@ -279,7 +280,7 @@ igraph_paths = function(x, from, to, weights, type = "shortest", mode = direction, ... )), - raise_unknown_input(type) + raise_unknown_input("type", type, c("shortest", "all_shortest", "all_simple")) ) # Extract the nodes in the paths, and the edges in the paths (if given). npaths = paths[[1]] diff --git a/R/sf.R b/R/sf.R index a6a42a72..b0cab727 100644 --- a/R/sf.R +++ b/R/sf.R @@ -57,7 +57,7 @@ st_as_sf.sfnetwork = function(x, active = NULL, ...) { active, nodes = nodes_as_sf(x, ...), edges = edges_as_sf(x, ...), - raise_unknown_input(active) + raise_invalid_active(active) ) } @@ -388,7 +388,7 @@ st_join.sfnetwork = function(x, y, ...) { active, nodes = spatial_join_nodes(x, y, ...), edges = spatial_join_edges(x, y, ...), - raise_unknown_input(active) + raise_invalid_active(active) ) } @@ -485,7 +485,7 @@ st_filter.sfnetwork = function(x, y, ...) { active, nodes = spatial_filter_nodes(x, y, ...), edges = spatial_filter_edges(x, y, ...), - raise_unknown_input(active) + raise_invalid_active(active) ) } @@ -526,7 +526,7 @@ st_crop.sfnetwork = function(x, y, ...) { 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) ) } @@ -547,7 +547,7 @@ st_difference.sfnetwork = function(x, y, ...) { 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) ) } @@ -568,7 +568,7 @@ st_intersection.sfnetwork = function(x, y, ...) { 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) ) } diff --git a/R/tidygraph.R b/R/tidygraph.R index 4c24ec5f..f44e813a 100644 --- a/R/tidygraph.R +++ b/R/tidygraph.R @@ -17,9 +17,11 @@ as_tbl_graph.sfnetwork = function(x, ...) { x } +#' @importFrom cli cli_abort +#' @importFrom rlang enquo quo_text #' @importFrom tidygraph as_tbl_graph morph #' @export -morph.sfnetwork = function(.data, ...) { +morph.sfnetwork = function(.data, .f, ...) { # Morph using tidygraphs morphing functionality: # --> First try to morph the sfnetwork object directly. # --> If this gives errors, convert to tbl_graph and then morph. @@ -28,8 +30,14 @@ morph.sfnetwork = function(.data, ...) { NextMethod(), error = function(e1) { tryCatch( - morph(as_tbl_graph(.data), ...), - error = function(e2) stop(e1) + morph(as_tbl_graph(.data), .f, ...), + error = function(e) { + morpher = quo_text(enquo(.f)) + cli_abort(c( + "Failed to morph the {.cls sfnetwork} object using {.fn {morpher}}.", + "x" = "The following error occured: {e1}" + ), call = call("morph.sfnetwork")) + } ) } ) diff --git a/R/validate.R b/R/validate.R index 4f579290..ae25dccf 100644 --- a/R/validate.R +++ b/R/validate.R @@ -13,17 +13,14 @@ #' reference system and the same coordinate precision, and coordinates of #' edge boundaries match coordinates of their corresponding nodes. #' -#' @importFrom cli cli_alert cli_alert_success +#' @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 (! has_single_geom_type(nodes, "POINT")) { - stop( - "Not all nodes have geometry type POINT", - call. = FALSE - ) + cli_abort("Not all nodes have geometry type POINT") } if (message) cli_alert_success("All nodes have geometry type POINT") if (has_explicit_edges(x)) { @@ -31,28 +28,19 @@ validate_network = function(x, message = TRUE) { # Check 2: Are all edge geometries linestrings? if (message) cli_alert("Checking edge geometry types ...") if (! has_single_geom_type(edges, "LINESTRING")) { - stop( - "Not all edges have geometry type LINESTRING", - call. = FALSE - ) + cli_abort("Not all edges have geometry type 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)) { - stop( - "Nodes and edges do not have the same coordinate reference system", - call. = FALSE - ) + 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)) { - stop( - "Nodes and edges do not have the same coordinate precision", - call. = FALSE - ) + 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? @@ -61,19 +49,13 @@ validate_network = function(x, message = TRUE) { # Start point should match start node. # End point should match end node. if (! all(nodes_match_edge_boundaries(x))) { - stop( - "Node locations do not match edge boundaries", - call. = FALSE - ) + cli_abort("Node locations do not match edge boundaries") } } 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( - "Node locations do not match edge boundaries", - call. = FALSE - ) + cli_abort("Node locations do not match edge boundaries") } } if (message) cli_alert_success("Node locations match edge boundaries") diff --git a/man/as.linnet.Rd b/man/as.linnet.Rd index 2832a967..680c0036 100644 --- a/man/as.linnet.Rd +++ b/man/as.linnet.Rd @@ -5,7 +5,7 @@ \alias{as.linnet.sfnetwork} \title{Convert a sfnetwork into a linnet} \usage{ -as.linnet.sfnetwork(X, ...) +\method{as.linnet}{sfnetwork}(X, ...) } \arguments{ \item{X}{An object of class \code{\link{sfnetwork}} with a projected CRS.} From 3de852b69217cded623ea789c236a00bfde658ae Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Wed, 7 Aug 2024 17:54:51 +0200 Subject: [PATCH 033/246] refactor: Convert remaining messages to cli + rlang :construction: --- R/blend.R | 11 ++++++----- R/checks.R | 4 ++-- R/node.R | 3 ++- R/sf.R | 39 +++++++++++++++++++++++---------------- 4 files changed, 33 insertions(+), 24 deletions(-) diff --git a/R/blend.R b/R/blend.R index 6be8d9fa..3209c3d0 100644 --- a/R/blend.R +++ b/R/blend.R @@ -102,7 +102,7 @@ st_network_blend = function(x, y, tolerance = Inf) { st_network_blend.sfnetwork = function(x, y, tolerance = Inf) { if (! has_explicit_edges(x)) { cli_abort(c( - "{.arg x} should have spatially explicit edges", + "{.arg x} should have spatially explicit edges.", "i" = "Call {.fn sfnetworks::to_spatial_explicit} to explicitize edges." )) } @@ -124,6 +124,7 @@ st_network_blend.sfnetwork = function(x, y, tolerance = Inf) { blend_(x, y, tolerance) } +#' @importFrom cli cli_warn #' @importFrom dplyr bind_rows full_join #' @importFrom igraph is_directed #' @importFrom sf st_as_sf st_cast st_crs st_crs<- st_distance st_equals @@ -227,10 +228,10 @@ blend_ = function(x, y, tolerance) { Y = Y[is_on | is_close] # Return x when there are no features left 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") diff --git a/R/checks.R b/R/checks.R index 4c196c7c..23915d18 100644 --- a/R/checks.R +++ b/R/checks.R @@ -299,8 +299,8 @@ require_explicit_edges = function(x) { if (! has_explicit_edges(x)) { cli_abort(c( "This call requires spatially explicit edges.", - "i" = "If you meant to call it on the nodes, activate nodes first.", - "i" = "Call {.code convert(x, to_spatial_explicit} to explicitize edges." + "i" = "Call {.fn tidygraph::activate} to activate nodes instead.", + "i" = "Call {.fn sfnetworks::to_spatial_explicit} to explicitize edges." )) } } diff --git a/R/node.R b/R/node.R index 0df5f4e8..0aef25db 100644 --- a/R/node.R +++ b/R/node.R @@ -75,13 +75,14 @@ node_M = function() { get_coords(pull_node_geom(x), "M") } +#' @importFrom cli cli_warn #' @importFrom sf st_coordinates get_coords = function(x, value) { all_coords = st_coordinates(x) tryCatch( all_coords[, value], error = function(e) { - warning(value, " coordinates are not available", call. = FALSE) + cli_warn("{value} coordinates are not available.", call = FALSE) rep(NA, length(x)) } ) diff --git a/R/sf.R b/R/sf.R index b0cab727..82888495 100644 --- a/R/sf.R +++ b/R/sf.R @@ -315,6 +315,7 @@ st_agr.sfnetwork = function(x, active = NULL, ...) { # switched). #' @name sf +#' @importFrom cli cli_warn #' @importFrom igraph is_directed #' @importFrom sf st_reverse #' @importFrom tidygraph as_tbl_graph reroute @@ -324,9 +325,11 @@ st_reverse.sfnetwork = function(x, ...) { if (active == "edges") { require_explicit_edges(x) if (is_directed(x)) { - warning( - "In directed networks st_reverse swaps columns 'to' and 'from'", - call. = FALSE + cli_warn( + paste( + "{.fn st_reverse} swaps {.field from} and {.field to} columns", + "in directed networks." + ), call = FALSE ) node_ids = edge_boundary_node_indices(x, matrix = TRUE) from_ids = node_ids[, 1] @@ -335,10 +338,11 @@ st_reverse.sfnetwork = function(x, ...) { x = tbg_to_sfn(x_tbg) } } 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.", + call = FALSE + )) } geom_unary_ops(st_reverse, x, active,...) } @@ -400,6 +404,7 @@ st_join.morphed_sfnetwork = function(x, y, ...) { x } +#' @importFrom cli cli_warn #' @importFrom igraph delete_vertices vertex_attr<- #' @importFrom methods hasArg #' @importFrom sf st_as_sf st_join @@ -422,11 +427,13 @@ spatial_join_nodes = function(x, y, ...) { 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 - ) + cli_warn(c( + "{.fn st_join} for {.cls sfnetwork} objects only joins one feature per node.", + "x" = paste( + "Multiple matches were detected for some nodes,", + "of which all but the first one are ignored." + ), call = FALSE + )) } # 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. @@ -589,6 +596,7 @@ spatial_clip_nodes = function(x, y, ..., .operator = sf::st_intersection) { delete_vertices(x, drop) %preserve_all_attrs% x } +#' @importFrom cli cli_warn #' @importFrom dplyr bind_rows #' @importFrom igraph is_directed #' @importFrom sf st_cast st_equals st_geometry st_is st_line_merge st_sf @@ -597,10 +605,9 @@ spatial_clip_edges = function(x, y, ..., .operator = sf::st_intersection) { 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 + cli_warn( + "Clipping edges does not give correct results in undirected networks", + call = FALSE ) } ## =========================== From ca8226c5cbc2be3556eb3518f219da325c5d1e57 Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Thu, 8 Aug 2024 13:10:37 +0200 Subject: [PATCH 034/246] fix: Dont supply list to sfnetwork in as_sfnetwork tbl_graph method :wrench: --- R/create.R | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/R/create.R b/R/create.R index 3070fdf4..f943db0a 100644 --- a/R/create.R +++ b/R/create.R @@ -385,13 +385,17 @@ as_sfnetwork.sfNetwork = function(x, ...) { #' #' @importFrom igraph is_directed #' @importFrom methods hasArg +#' @importFrom tibble as_tibble #' @export as_sfnetwork.tbl_graph = function(x, ...) { + nodes = as_tibble(x, "nodes") + edges = as_tibble(x, "edges") if (hasArg("directed")) { - sfnetwork(as.list(x), ...) + x_sfn = sfnetwork(nodes, edges, ...) } else { - sfnetwork(as.list(x), directed = is_directed(x), ...) + x_sfn = sfnetwork(nodes, edges, directed = is_directed(x), ...) } + x_sfn } #' Create a spatial network from linestring geometries From cd7b8f0328c3a4ce25fb46b1aa6d13248d1df5d3 Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Thu, 8 Aug 2024 14:42:17 +0200 Subject: [PATCH 035/246] refactor: Tidy and update geometry handling :construction: --- R/attrs.R | 22 +++++++++++----------- R/checks.R | 33 +++++++++++++++++++++++++++++++-- R/geom.R | 19 ++++++++++--------- 3 files changed, 52 insertions(+), 22 deletions(-) diff --git a/R/attrs.R b/R/attrs.R index d2d2dc4e..48168fd1 100644 --- a/R/attrs.R +++ b/R/attrs.R @@ -53,13 +53,7 @@ sf_attr = function(x, name, active = NULL) { #' @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_sf_attrs%`(`%preserve_network_attrs%`(new, orig), orig) } #' Preserve the attributes of the original network @@ -98,10 +92,16 @@ sf_attr = function(x, name, active = NULL) { #' #' @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) + 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) + } + 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) + } new } diff --git a/R/checks.R b/R/checks.R index 23915d18..1249fc28 100644 --- a/R/checks.R +++ b/R/checks.R @@ -47,6 +47,32 @@ 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 x Object to be checked. @@ -93,9 +119,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 @@ -108,7 +136,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 diff --git a/R/geom.R b/R/geom.R index 8fa1d908..ddcf0ac7 100644 --- a/R/geom.R +++ b/R/geom.R @@ -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 } @@ -148,7 +151,6 @@ mutate_geom = function(x, y, active = NULL) { } #' @name mutate_geom -#' @importFrom igraph vertex_attr<- #' @importFrom sf st_geometry #' @noRd mutate_node_geom = function(x, y) { @@ -159,7 +161,6 @@ mutate_node_geom = function(x, y) { } #' @name mutate_geom -#' @importFrom igraph edge_attr<- #' @importFrom sf st_geometry #' @noRd mutate_edge_geom = function(x, y) { From c26814ed788d132fe3df9492d4e730c6efbf6f7f Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Thu, 8 Aug 2024 14:43:04 +0200 Subject: [PATCH 036/246] fix: Preserve attributes when converting from tbl_graph :wrench: --- R/create.R | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/R/create.R b/R/create.R index f943db0a..251551db 100644 --- a/R/create.R +++ b/R/create.R @@ -395,7 +395,7 @@ as_sfnetwork.tbl_graph = function(x, ...) { } else { x_sfn = sfnetwork(nodes, edges, directed = is_directed(x), ...) } - x_sfn + tbg_to_sfn(x_sfn %preserve_all_attrs% x) } #' Create a spatial network from linestring geometries From 3854d6cb0ef1b9e965641990e3c7df657fef0205 Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Thu, 8 Aug 2024 16:55:24 +0200 Subject: [PATCH 037/246] fix: Do not call arguments from dots directly :wrench: --- R/create.R | 8 ++------ R/morphers.R | 8 ++++++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/R/create.R b/R/create.R index 251551db..47940bf2 100644 --- a/R/create.R +++ b/R/create.R @@ -260,13 +260,9 @@ as_sfnetwork.default = function(x, ...) { #' @importFrom methods hasArg #' @export as_sfnetwork.sf = function(x, ...) { - if (hasArg("length_as_weight") && length_as_weight) { - deprecate_length_as_weight("as_sfnetwork.sf") - } + if (hasArg("length_as_weight")) deprecate_length_as_weight("as_sfnetwork.sf") if (has_single_geom_type(x, "LINESTRING")) { - if (hasArg("edges_as_lines") && ! is.null(dots$edges_as_lines)) { - deprecate_edges_as_lines() - } + if (hasArg("edges_as_lines")) deprecate_edges_as_lines() create_from_spatial_lines(x, ...) } else if (has_single_geom_type(x, "POINT")) { create_from_spatial_points(x, ...) diff --git a/R/morphers.R b/R/morphers.R index 8ce7acee..fb85881d 100644 --- a/R/morphers.R +++ b/R/morphers.R @@ -417,10 +417,14 @@ to_spatial_neighborhood = function(x, node, threshold, ...) { if (length(node) > 1) raise_multiple_elements("node") # Compute the cost matrix from the source node. # By calling st_network_cost with the given arguments. - if (hasArg("from") && isFALSE(from)) { + if (hasArg("from")) { # Deprecate the former "from" argument specifying routing direction. deprecate_from() - costs = st_network_cost(x, from = node, direction = "in", ...) + if (isFALSE(dots_list(...)$from)) { + costs = st_network_cost(x, from = node, direction = "in", ...) + } else { + costs = st_network_cost(x, from = node, ...) + } } else { costs = st_network_cost(x, from = node, ...) } From adb7722fb171ec5ff3d1c739904bfbc68979ecfe Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Thu, 8 Aug 2024 17:44:38 +0200 Subject: [PATCH 038/246] fix: Always use nodes for crs and precision equality checks :wrench: --- R/checks.R | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/R/checks.R b/R/checks.R index 1249fc28..041e5e40 100644 --- a/R/checks.R +++ b/R/checks.R @@ -154,7 +154,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 @@ -171,7 +173,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 From 4187b5bdcb8cb8519cc75732e2de7898df3d555c Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Thu, 8 Aug 2024 17:45:08 +0200 Subject: [PATCH 039/246] fix: Correctly compute number of features in st_duplicated :wrench: --- R/utils.R | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/R/utils.R b/R/utils.R index 0d83acf8..509a9be6 100644 --- a/R/utils.R +++ b/R/utils.R @@ -643,10 +643,10 @@ merge_mranges = function(a, b) { #' #' @return A logical vector of the same length as \code{x}. #' -#' @importFrom sf st_equals +#' @importFrom sf st_equals st_geometry #' @noRd st_duplicated = function(x) { - dup = rep(FALSE, length(x)) + dup = rep(FALSE, length(st_geometry(x))) dup[unique(do.call("c", lapply(st_equals(x), `[`, - 1)))] = TRUE dup } From 9cb4fe3e09a1bdf36989b6b42a05b90ae363c05d Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Thu, 8 Aug 2024 17:50:54 +0200 Subject: [PATCH 040/246] refactor: Improve performance of edge type equality check :construction: --- R/checks.R | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/R/checks.R b/R/checks.R index 041e5e40..8dca6487 100644 --- a/R/checks.R +++ b/R/checks.R @@ -189,13 +189,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 From a1371f050b57de08a235b2446c8bf2f93f54bc8a Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Mon, 12 Aug 2024 12:29:50 +0200 Subject: [PATCH 041/246] refactor: Restructure explicit edges checks :construction: --- NAMESPACE | 2 ++ R/checks.R | 8 +------- R/convert.R | 42 ++++++++++++++++++++++++++++++++++---- R/geom.R | 7 ++++--- R/messages.R | 9 +++++++++ R/morphers.R | 57 ++++++++++++++++++++++++---------------------------- R/sf.R | 26 +++++++++++------------- R/utils.R | 51 +++++++++++++++++++++++++++------------------- man/data.Rd | 28 ++++++++++++++++++++++++++ man/ids.Rd | 7 +++---- 10 files changed, 153 insertions(+), 84 deletions(-) create mode 100644 man/data.Rd diff --git a/NAMESPACE b/NAMESPACE index 42bbce16..e8dc3c72 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -69,6 +69,7 @@ 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) @@ -93,6 +94,7 @@ export(node_M) export(node_X) export(node_Y) export(node_Z) +export(node_data) export(node_equals) export(node_ids) export(node_intersects) diff --git a/R/checks.R b/R/checks.R index 8dca6487..43636bcb 100644 --- a/R/checks.R +++ b/R/checks.R @@ -325,13 +325,7 @@ require_active_edges <- function() { #' @importFrom cli cli_abort #' @noRd require_explicit_edges = function(x) { - if (! has_explicit_edges(x)) { - 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." - )) - } + if (! has_explicit_edges(x)) raise_require_explicit() } diff --git a/R/convert.R b/R/convert.R index 33948974..722d3319 100644 --- a/R/convert.R +++ b/R/convert.R @@ -53,20 +53,54 @@ as_tibble.sfnetwork = function(x, active = NULL, spatial = TRUE, ...) { if (spatial) { switch( active, - nodes = nodes_as_sf(x), - edges = edges_as_table(x), + nodes = nodes_as_spatial_tibble(x, ...), + edges = nodes_as_spatial_tibble(x, ...), raise_invalid_active(active) ) } else { switch( active, - nodes = as_tibble(as_tbl_graph(x), "nodes"), - edges = as_tibble(as_tbl_graph(x), "edges"), + nodes = nodes_as_regular_tibble(x, ...), + edges = edges_as_regular_tibble(x, ...), raise_invalid_active(active) ) } } +#' @importFrom sf st_as_sf +nodes_as_spatial_tibble = function(x, ...) { + st_as_sf( + nodes_as_regular_tibble(x, ...), + agr = node_agr(x), + sf_column_name = node_geom_colname(x) + ) +} + +#' @importFrom sf st_as_sf +edges_as_spatial_tibble = function(x, ...) { + if (has_explicit_edges(x)) { + st_as_sf( + edges_as_regular_tibble(x, ...), + agr = edge_agr(x), + sf_column_name = edge_geom_colname(x) + ) + } else { + edges_as_regular_tibble(x, ...) + } +} + +#' @importFrom tibble as_tibble +#' @importFrom tidygraph as_tbl_graph +nodes_as_regular_tibble = function(x, ...) { + as_tibble(as_tbl_graph(x), "nodes", ...) +} + +#' @importFrom tibble as_tibble +#' @importFrom tidygraph as_tbl_graph +edges_as_regular_tibble = function(x, ...) { + as_tibble(as_tbl_graph(x), "edges", ...) +} + #' Convert a sfnetwork into a S2 geography vector #' #' A method to convert an object of class \code{\link{sfnetwork}} into diff --git a/R/geom.R b/R/geom.R index ddcf0ac7..8ba4d02f 100644 --- a/R/geom.R +++ b/R/geom.R @@ -116,8 +116,9 @@ pull_node_geom = function(x) { #' @importFrom igraph edge_attr #' @noRd pull_edge_geom = function(x) { - require_explicit_edges(x) - geom = edge_attr(x, edge_geom_colname(x)) + 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() geom } @@ -164,7 +165,7 @@ mutate_node_geom = function(x, y) { #' @importFrom sf st_geometry #' @noRd mutate_edge_geom = function(x, y) { - edges = edges_as_table(x) + edges = edge_data(x) st_geometry(edges) = y edge_attribute_values(x) = edges x diff --git a/R/messages.R b/R/messages.R index 025a6fc0..46edb0e4 100644 --- a/R/messages.R +++ b/R/messages.R @@ -87,6 +87,15 @@ raise_unsupported_arg = function(arg, replacement = NULL) { } } +#' @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_invalid_sf_column = function() { cli_abort(c( diff --git a/R/morphers.R b/R/morphers.R index fb85881d..aa503c53 100644 --- a/R/morphers.R +++ b/R/morphers.R @@ -94,7 +94,7 @@ to_spatial_contracted = function(x, ..., simplify = FALSE, if (will_assume_projected(x)) raise_assume_projected("to_spatial_contracted") # Retrieve nodes from the network. nodes = nodes_as_sf(x) - geom_colname = attr(nodes, "sf_column") + node_geomcol = attr(nodes, "sf_column") ## ======================= # STEP I: GROUP THE NODES # Group the nodes table by forwarding ... to dplyr::group_by. @@ -123,7 +123,7 @@ to_spatial_contracted = function(x, ..., simplify = FALSE, # --> 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) + x_tmp = delete_vertex_attr(x, node_geomcol) # 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. @@ -155,14 +155,14 @@ to_spatial_contracted = function(x, ..., simplify = FALSE, } 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) + new_nodes[node_geomcol] = 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) + new_nodes = st_as_sf(new_nodes, sf_column_name = node_geomcol) node_attribute_values(x_new) = new_nodes # Convert in a sfnetwork. x_new = tbg_to_sfn(x_new) @@ -335,7 +335,6 @@ to_spatial_contracted = function(x, ..., simplify = FALSE, #' @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) @@ -373,7 +372,7 @@ to_spatial_explicit = function(x, ...) { # --> 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. if (dots_n > 0) { - edges = edges_as_table(x) + edges = edge_data(x) new_edges = st_as_sf(edges, ...) x_new = x edge_attribute_values(x_new) = new_edges @@ -498,9 +497,6 @@ to_spatial_shortest_paths = function(x, ...) { to_spatial_simple = function(x, remove_multiple = TRUE, remove_loops = TRUE, summarise_attributes = "first", 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: @@ -509,12 +505,8 @@ to_spatial_simple = function(x, remove_multiple = TRUE, remove_loops = TRUE, 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. + edge_geomcol = edge_geom_colname(x) + if (! is.null(edge_geomcol)) summarise_attributes[edge_geomcol] = "first" summarise_attributes[".tidygraph_edge_index"] = "concat" # Simplify the network. x_new = simplify( @@ -526,19 +518,19 @@ to_spatial_simple = function(x, remove_multiple = TRUE, remove_loops = TRUE, # 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) + 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) 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 = edge_data(x) edges$.tidygraph_edge_index = NULL - new_edges = edges_as_table(x_new) + new_edges = edge_data(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 @@ -596,14 +588,14 @@ to_spatial_smooth = function(x, 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) + edges = edge_data(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") + 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. @@ -856,7 +848,7 @@ to_spatial_smooth = function(x, ## =================================== # 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) + exclude = c(".tidygraph_edge_index", edge_geomcol) edge_attrs = edge.attributes(x) edge_attrs = edge_attrs[!(names(edge_attrs) %in% exclude)] # For each replacement edge: @@ -880,7 +872,7 @@ to_spatial_smooth = function(x, # --> 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) { + if (explicit_edges) { # Obtain geometries of all original edges and nodes in the network. edge_geoms = st_geometry(edges) node_geoms = st_geometry(nodes) @@ -928,12 +920,16 @@ to_spatial_smooth = function(x, data.frame(do.call("rbind", new_idxs)), data.frame(do.call("rbind", new_attrs)) ) - 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) + 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) ## ============================================ @@ -953,7 +949,7 @@ to_spatial_smooth = function(x, ## ============================================== if (store_original_data) { # Store the original edge data in a .orig_data column. - new_edges = edges_as_sf(x_new) + new_edges = edge_data(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) @@ -982,7 +978,6 @@ to_spatial_smooth = function(x, #' @importFrom sfheaders sf_to_df sfc_linestring sfc_point #' @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) diff --git a/R/sf.R b/R/sf.R index 82888495..3f29e860 100644 --- a/R/sf.R +++ b/R/sf.R @@ -62,25 +62,24 @@ st_as_sf.sfnetwork = function(x, active = NULL, ...) { } #' @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_regular_tibble(x), agr = node_agr(x), - sf_column_name = node_geom_colname(x) + sf_column_name = node_geom_colname(x), + ... ) } #' @importFrom sf st_as_sf -#' @importFrom tibble as_tibble -#' @importFrom tidygraph as_tbl_graph edges_as_sf = function(x, ...) { - require_explicit_edges(x) + geom_colname = edge_geom_colname(x) + if (is.null(geom_colname)) raise_require_explicit() st_as_sf( - as_tibble(as_tbl_graph(x), "edges"), + edges_as_regular_tibble(x), agr = edge_agr(x), - sf_column_name = edge_geom_colname(x) + sf_column_name = geom_colname, + ... ) } @@ -117,7 +116,7 @@ st_geometry.sfnetwork = function(obj, active = NULL, ...) { x_new = drop_geom(x) } else { x_new = mutate_geom(x, value) - validate_network(x_new, message = FALSE) + validate_network(x_new) } x_new } @@ -452,7 +451,6 @@ spatial_join_nodes = function(x, y, ...) { #' @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) @@ -516,7 +514,6 @@ 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) @@ -601,7 +598,6 @@ spatial_clip_nodes = function(x, y, ..., .operator = sf::st_intersection) { #' @importFrom igraph is_directed #' @importFrom sf st_cast st_equals st_geometry st_is st_line_merge st_sf spatial_clip_edges = function(x, y, ..., .operator = sf::st_intersection) { - require_explicit_edges(x) directed = is_directed(x) # Clipping does not work good yet for undirected networks. if (!directed) { @@ -610,12 +606,14 @@ spatial_clip_edges = function(x, y, ..., .operator = sf::st_intersection) { call = FALSE ) } + x_sf = edges_as_sf(x) + y_sf = st_geometry(y) ## =========================== # STEP I: CLIP THE EDGES ## =========================== # Clip the edges using the given operator. # Possible operators are st_intersection, st_difference and st_crop. - e_new = .operator(edges_as_sf(x), st_geometry(y), ...) + e_new = .operator(x_sf, y_sf, ...) # 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. diff --git a/R/utils.R b/R/utils.R index 509a9be6..ec186738 100644 --- a/R/utils.R +++ b/R/utils.R @@ -24,10 +24,37 @@ n_edges = function(x) { ecount(x) } -#' Extract the indices of nodes or edges from a network +#' Extract the node or edge data from a spatial network #' -#' @param x An object of class \code{\link{sfnetwork}}, or any other network -#' object inheriting from \code{\link[igraph]{igraph}}. +#' @param x An object of class \code{\link{sfnetwork}}. +#' +#' @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}. +#' +#' @name data +#' @export +node_data = function(x) { + nodes_as_sf(x) +} + +#' @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}. +#' +#' @importFrom tibble as_tibble +#' @importFrom tidygraph as_tbl_graph +#' @export +edge_data = function(x, require_sf = FALSE) { + if (require_sf) edges_as_sf(x) else edges_as_spatial_tibble(x) +} + +#' Extract the node or edge indices from a spatial network +#' +#' @param x An object of class \code{\link{sfnetwork}}. #' #' @details The indices in these objects are always integers that correspond to #' rownumbers in respectively the nodes or edges table. @@ -409,24 +436,6 @@ edge_boundary_point_indices = function(x, matrix = FALSE) { if (matrix) t(matrix(idxs_vct, nrow = 2)) else idxs_vct } -#' Extract the edges as a table -#' -#' @param x An object of class \code{\link{sfnetwork}}. -#' -#' @return An object of class \code{\link[sf]{sf}} if the edges are spatially -#' explicit, and object of class \code{\link[tibble]{tibble}}. -#' -#' @importFrom tibble as_tibble -#' @importFrom tidygraph as_tbl_graph -#' @noRd -edges_as_table = function(x) { - if (has_explicit_edges(x)) { - edges_as_sf(x) - } else { - as_tibble(as_tbl_graph(x), "edges") - } -} - #' Make edges spatially explicit #' #' @param x An object of class \code{\link{sfnetwork}}. diff --git a/man/data.Rd b/man/data.Rd new file mode 100644 index 00000000..976adefc --- /dev/null +++ b/man/data.Rd @@ -0,0 +1,28 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/utils.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) + +edge_data(x, require_sf = FALSE) +} +\arguments{ +\item{x}{An object of class \code{\link{sfnetwork}}.} + +\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 +} diff --git a/man/ids.Rd b/man/ids.Rd index 8b25db5d..5e3ec17d 100644 --- a/man/ids.Rd +++ b/man/ids.Rd @@ -4,21 +4,20 @@ \alias{ids} \alias{node_ids} \alias{edge_ids} -\title{Extract the indices of nodes or edges from a network} +\title{Extract the node or edge indices from a spatial network} \usage{ node_ids(x) edge_ids(x) } \arguments{ -\item{x}{An object of class \code{\link{sfnetwork}}, or any other network -object inheriting from \code{\link[igraph]{igraph}}.} +\item{x}{An object of class \code{\link{sfnetwork}}.} } \value{ An vector of integers. } \description{ -Extract the indices of nodes or edges from a network +Extract the node or edge indices from a spatial network } \details{ The indices in these objects are always integers that correspond to From 569b046aca2c0a4619809869c278fddc1b1ba6a9 Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Mon, 12 Aug 2024 12:33:18 +0200 Subject: [PATCH 042/246] fix: Do not call arguments from dots directly :wrench: --- NAMESPACE | 1 + R/morphers.R | 1 + R/sf.R | 3 ++- 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/NAMESPACE b/NAMESPACE index e8dc3c72..ca793e20 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -188,6 +188,7 @@ importFrom(lwgeom,st_geod_azimuth) importFrom(methods,hasArg) importFrom(pillar,style_subtle) importFrom(rlang,check_installed) +importFrom(rlang,dots_list) importFrom(rlang,dots_n) importFrom(rlang,enquo) importFrom(rlang,eval_tidy) diff --git a/R/morphers.R b/R/morphers.R index aa503c53..d5ec86bd 100644 --- a/R/morphers.R +++ b/R/morphers.R @@ -406,6 +406,7 @@ to_spatial_explicit = function(x, ...) { #' #' @importFrom igraph induced_subgraph #' @importFrom methods hasArg +#' @importFrom rlang dots_list #' @importFrom units as_units deparse_unit #' @export to_spatial_neighborhood = function(x, node, threshold, ...) { diff --git a/R/sf.R b/R/sf.R index 3f29e860..88b08e38 100644 --- a/R/sf.R +++ b/R/sf.R @@ -406,6 +406,7 @@ st_join.morphed_sfnetwork = function(x, y, ...) { #' @importFrom cli cli_warn #' @importFrom igraph delete_vertices vertex_attr<- #' @importFrom methods hasArg +#' @importFrom rlang dots_list #' @importFrom sf st_as_sf st_join spatial_join_nodes = function(x, y, ...) { # Convert x and y to sf. @@ -437,7 +438,7 @@ spatial_join_nodes = function(x, y, ...) { # 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. - if (hasArg("left") && ! left) { + if (hasArg("left") && ! dots_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 From 0f81fc7646402761ed075b7d558b3cdad53cc025 Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Mon, 12 Aug 2024 12:38:53 +0200 Subject: [PATCH 043/246] fix: Make st_network_bbox work with implicit edges :wrench: --- R/bbox.R | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/R/bbox.R b/R/bbox.R index 7c269345..a7fa0ec1 100644 --- a/R/bbox.R +++ b/R/bbox.R @@ -52,14 +52,21 @@ 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. + # 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), ...) - edges_bbox = st_bbox(pull_edge_geom(x), ...) - # Take most extreme coordinates to form the network bbox. - merge_bboxes(nodes_bbox, edges_bbox) + 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 } From efb251226b248dff8173ecb1c4539ac9bff1b597 Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Mon, 12 Aug 2024 13:04:52 +0200 Subject: [PATCH 044/246] refactor: Simplify morph method after tidygraph updates :construction: --- NAMESPACE | 1 - R/tidygraph.R | 44 ++++++++++++-------------------------------- 2 files changed, 12 insertions(+), 33 deletions(-) diff --git a/NAMESPACE b/NAMESPACE index ca793e20..a7dca7f2 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -195,7 +195,6 @@ importFrom(rlang,eval_tidy) importFrom(rlang,expr) importFrom(rlang,has_name) importFrom(rlang,is_installed) -importFrom(rlang,quo_text) importFrom(sf,"st_agr<-") importFrom(sf,"st_crs<-") importFrom(sf,"st_geometry<-") diff --git a/R/tidygraph.R b/R/tidygraph.R index f44e813a..f913e6c1 100644 --- a/R/tidygraph.R +++ b/R/tidygraph.R @@ -17,48 +17,28 @@ as_tbl_graph.sfnetwork = function(x, ...) { x } -#' @importFrom cli cli_abort -#' @importFrom rlang enquo quo_text -#' @importFrom tidygraph as_tbl_graph morph +#' @importFrom tidygraph morph #' @export morph.sfnetwork = function(.data, .f, ...) { - # 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), .f, ...), - error = function(e) { - morpher = quo_text(enquo(.f)) - cli_abort(c( - "Failed to morph the {.cls sfnetwork} object using {.fn {morpher}}.", - "x" = "The following error occured: {e1}" - ), call = call("morph.sfnetwork")) - } - ) - } - ) + # 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_sfn = lapply(morphed, tbg_to_sfn) + attributes(morphed_sfn) = attributes(morphed) structure( - morphed_data, - class = c("morphed_sfnetwork", class(morphed_data)) + morphed, + class = c("morphed_sfnetwork", class(morphed)) ) } else { - morphed_data + morphed } } From 220f5872d2acfcd57af0d4e0f6f1452160146207 Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Mon, 12 Aug 2024 13:18:02 +0200 Subject: [PATCH 045/246] refactor: Simplify unmorph method :construction: --- R/attrs.R | 12 ++++++++++++ R/tidygraph.R | 48 +++++++++++++++++------------------------------- 2 files changed, 29 insertions(+), 31 deletions(-) diff --git a/R/attrs.R b/R/attrs.R index 48168fd1..71c48abc 100644 --- a/R/attrs.R +++ b/R/attrs.R @@ -105,6 +105,18 @@ sf_attr = function(x, name, active = NULL) { new } +#' Preserve the attributes of a morphed network +#' +#' @param new An object of class \code{\link{morphed_sfnetwork}}. +#' +#' @param orig An object of class \code{\link{morphed_sfnetwork}}. +#' +#' @noRd +`%preserve_morphed_attrs%` = function(new, orig) { + attributes(new) = attributes(orig) + new +} + #' Get attribute column names from the active element of a sfnetwork #' #' @param x An object of class \code{\link{sfnetwork}}. diff --git a/R/tidygraph.R b/R/tidygraph.R index f913e6c1..5f5323ec 100644 --- a/R/tidygraph.R +++ b/R/tidygraph.R @@ -31,10 +31,8 @@ morph.sfnetwork = function(.data, .f, ...) { class = c("morphed_sfnetwork", class(morphed)) ) } else if (has_spatial_nodes(morphed[[1]])) { - morphed_sfn = lapply(morphed, tbg_to_sfn) - attributes(morphed_sfn) = attributes(morphed) structure( - morphed, + lapply(morphed, tbg_to_sfn) %preserve_morphed_attrs% morphed, class = c("morphed_sfnetwork", class(morphed)) ) } else { @@ -42,41 +40,29 @@ morph.sfnetwork = function(.data, .f, ...) { } } -#' @importFrom igraph delete_edge_attr delete_vertex_attr edge_attr vertex_attr -#' edge_attr_names vertex_attr_names +#' @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) %preserve_morphed_attrs% .data } - # 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) %preserve_morphed_attrs% .data } # Call tidygraphs unmorph. NextMethod(.data, ...) From b103c269d5e64ffdcd8bc91c87e2dde50ecd36c9 Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Mon, 12 Aug 2024 15:06:57 +0200 Subject: [PATCH 046/246] refactor: Tidy and restructure :construction: --- R/attrs.R | 12 ----- R/checks.R | 111 ----------------------------------------------- R/geom.R | 4 +- R/messages.R | 54 +++++++++++------------ R/morphers.R | 2 +- R/plot.R | 1 - R/require.R | 110 ++++++++++++++++++++++++++++++++++++++++++++++ R/tidygraph.R | 7 +-- R/utils.R | 4 +- man/as.linnet.Rd | 2 +- man/autoplot.Rd | 2 +- 11 files changed, 148 insertions(+), 161 deletions(-) create mode 100644 R/require.R diff --git a/R/attrs.R b/R/attrs.R index 71c48abc..48168fd1 100644 --- a/R/attrs.R +++ b/R/attrs.R @@ -105,18 +105,6 @@ sf_attr = function(x, name, active = NULL) { new } -#' Preserve the attributes of a morphed network -#' -#' @param new An object of class \code{\link{morphed_sfnetwork}}. -#' -#' @param orig An object of class \code{\link{morphed_sfnetwork}}. -#' -#' @noRd -`%preserve_morphed_attrs%` = function(new, orig) { - attributes(new) = attributes(orig) - new -} - #' Get attribute column names from the active element of a sfnetwork #' #' @param x An object of class \code{\link{sfnetwork}}. diff --git a/R/checks.R b/R/checks.R index 43636bcb..d710cdc5 100644 --- a/R/checks.R +++ b/R/checks.R @@ -285,114 +285,3 @@ will_assume_constant = function(x) { will_assume_projected = function(x) { (!is.na(st_crs(x)) && st_is_longlat(x)) && !sf_use_s2() } - -#' Proceed only when a given network element is active -#' -#' @details These function are meant to be called in the context of an -#' operation in which the network that is currently being worked on is known -#' and thus not needed as an argument to the function. -#' -#' @return Nothing when the expected network element is active, an error -#' 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") { - 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") { - cli_abort("This call requires edges to be active.") - } -} - -#' Proceed only when edges are spatially explicit -#' -#' @param x An object of class \code{\link{sfnetwork}}. -#' -#' @return Nothing when the edges of x are spatially explicit, an error message -#' otherwise. -#' -#' @importFrom cli cli_abort -#' @noRd -require_explicit_edges = function(x) { - if (! has_explicit_edges(x)) raise_require_explicit() -} - - -#' Proceed only if the given object is a valid adjacency matrix -#' -#' 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. -#' -#' @param x Object to be checked. -#' -#' @param nodes The nodes that are referenced in the matrix 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 adjacency matrix -#' referencing the given nodes, an error message otherwise. -#' -#' @importFrom cli cli_abort -#' @importFrom sf st_geometry -#' @noRd -require_valid_adjacency_matrix = function(x, nodes) { - n_nodes = length(st_geometry(nodes)) - if (! (nrow(x) == n_nodes && ncol(x) == n_nodes)) { - cli_abort( - c( - "The dimensions of the matrix should match the number of nodes.", - "x" = paste( - "The provided matrix has dimensions {nrow(x)} x {ncol(x)},", - "while there are {n_nodes} nodes." - ) - ) - ) - } -} - -#' Proceed only if the given object is a valid 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 checked. -#' -#' @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, and error message afterwards. -#' -#' @importFrom cli cli_abort -#' @importFrom sf st_geometry -#' @noRd -require_valid_neighbor_list = function(x, nodes) { - n_nodes = length(st_geometry(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." - ) - ) - ) - } - if (! all(vapply(x, is.integer, FUN.VALUE = logical(1)))) { - cli_abort("The sparse matrix should contain integer node indices.") - } -} diff --git a/R/geom.R b/R/geom.R index 8ba4d02f..5485cd7b 100644 --- a/R/geom.R +++ b/R/geom.R @@ -152,7 +152,7 @@ mutate_geom = function(x, y, active = NULL) { } #' @name mutate_geom -#' @importFrom sf st_geometry +#' @importFrom sf st_geometry<- #' @noRd mutate_node_geom = function(x, y) { nodes = nodes_as_sf(x) @@ -162,7 +162,7 @@ mutate_node_geom = function(x, y) { } #' @name mutate_geom -#' @importFrom sf st_geometry +#' @importFrom sf st_geometry<- #' @noRd mutate_edge_geom = function(x, y) { edges = edge_data(x) diff --git a/R/messages.R b/R/messages.R index 46edb0e4..5befac01 100644 --- a/R/messages.R +++ b/R/messages.R @@ -27,6 +27,25 @@ raise_assume_projected = function(caller) { ) } +#' @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) { cli_warn("Only the first element of {.arg {arg}} is used.", call = NULL) @@ -43,16 +62,17 @@ raise_overwrite = function(value) { } #' @importFrom cli cli_abort -raise_reserved_attr = function(value) { - cli_abort("The attribute name {.field value} is reserved.") +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_invalid_active = function(value) { - cli_abort(c( - "Unknown value for argument {.arg active}: {value}.", - "i" = "Supported values are: nodes, edges." - )) +raise_reserved_attr = function(value) { + cli_abort("The attribute name {.field value} is reserved.") } #' @importFrom cli cli_abort @@ -87,26 +107,6 @@ raise_unsupported_arg = function(arg, replacement = NULL) { } } -#' @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_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 lifecycle deprecate_stop deprecate_length_as_weight = function(caller) { switch( diff --git a/R/morphers.R b/R/morphers.R index d5ec86bd..684a501e 100644 --- a/R/morphers.R +++ b/R/morphers.R @@ -904,7 +904,7 @@ to_spatial_smooth = function(x, # --> 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 = force_multilinestrings_to_linestrings(new_geom) } new_geom } diff --git a/R/plot.R b/R/plot.R index 14e832e6..1e888667 100644 --- a/R/plot.R +++ b/R/plot.R @@ -42,7 +42,6 @@ #' #' @importFrom graphics plot #' @importFrom methods hasArg -#' @importFrom sf st_geometry #' @export plot.sfnetwork = function(x, draw_lines = TRUE, ...) { # Plot the nodes. diff --git a/R/require.R b/R/require.R new file mode 100644 index 00000000..e9851a1c --- /dev/null +++ b/R/require.R @@ -0,0 +1,110 @@ +#' Proceed only when a given network element is active +#' +#' @details These function are meant to be called in the context of an +#' operation in which the network that is currently being worked on is known +#' and thus not needed as an argument to the function. +#' +#' @return Nothing when the expected network element is active, an error +#' 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") { + 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") { + cli_abort("This call requires edges to be active.") + } +} + +#' Proceed only when edges are spatially explicit +#' +#' @param x An object of class \code{\link{sfnetwork}}. +#' +#' @return Nothing when the edges of x are spatially explicit, an error message +#' otherwise. +#' +#' @importFrom cli cli_abort +#' @noRd +require_explicit_edges = function(x) { + if (! has_explicit_edges(x)) raise_require_explicit() +} + + +#' Proceed only if the given object is a valid adjacency matrix +#' +#' 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. +#' +#' @param x Object to be checked. +#' +#' @param nodes The nodes that are referenced in the matrix 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 adjacency matrix +#' referencing the given nodes, an error message otherwise. +#' +#' @importFrom cli cli_abort +#' @importFrom sf st_geometry +#' @noRd +require_valid_adjacency_matrix = function(x, nodes) { + n_nodes = length(st_geometry(nodes)) + if (! (nrow(x) == n_nodes && ncol(x) == n_nodes)) { + cli_abort( + c( + "The dimensions of the matrix should match the number of nodes.", + "x" = paste( + "The provided matrix has dimensions {nrow(x)} x {ncol(x)},", + "while there are {n_nodes} nodes." + ) + ) + ) + } +} + +#' Proceed only if the given object is a valid 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 checked. +#' +#' @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, and error message afterwards. +#' +#' @importFrom cli cli_abort +#' @importFrom sf st_geometry +#' @noRd +require_valid_neighbor_list = function(x, nodes) { + n_nodes = length(st_geometry(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." + ) + ) + ) + } + if (! all(vapply(x, is.integer, FUN.VALUE = logical(1)))) { + cli_abort("The sparse matrix should contain integer node indices.") + } +} diff --git a/R/tidygraph.R b/R/tidygraph.R index 5f5323ec..80a772d7 100644 --- a/R/tidygraph.R +++ b/R/tidygraph.R @@ -31,8 +31,9 @@ morph.sfnetwork = function(.data, .f, ...) { class = c("morphed_sfnetwork", class(morphed)) ) } else if (has_spatial_nodes(morphed[[1]])) { + morphed[] = lapply(morphed, tbg_to_sfn) structure( - lapply(morphed, tbg_to_sfn) %preserve_morphed_attrs% morphed, + morphed, class = c("morphed_sfnetwork", class(morphed)) ) } else { @@ -55,14 +56,14 @@ unmorph.morphed_sfnetwork = function(.data, ...) { vertex_attr(x, ".orig_data") = as_tibble(vertex_attr(x, ".orig_data")) x } - .data = lapply(.data, orig_data_to_tibble) %preserve_morphed_attrs% .data + .data[] = lapply(.data, orig_data_to_tibble) } 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) %preserve_morphed_attrs% .data + .data[] = lapply(.data, orig_data_to_tibble) } # Call tidygraphs unmorph. NextMethod(.data, ...) diff --git a/R/utils.R b/R/utils.R index ec186738..471188b2 100644 --- a/R/utils.R +++ b/R/utils.R @@ -553,7 +553,7 @@ linestring_segments = function(x) { segments } -#' 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 @@ -568,7 +568,7 @@ linestring_segments = function(x) { #' @importFrom sf st_crs st_crs<- st_geometry st_precision st_precision<- #' @importFrom sfheaders sfc_linestring 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. diff --git a/man/as.linnet.Rd b/man/as.linnet.Rd index 680c0036..2832a967 100644 --- a/man/as.linnet.Rd +++ b/man/as.linnet.Rd @@ -5,7 +5,7 @@ \alias{as.linnet.sfnetwork} \title{Convert a sfnetwork into a linnet} \usage{ -\method{as.linnet}{sfnetwork}(X, ...) +as.linnet.sfnetwork(X, ...) } \arguments{ \item{X}{An object of class \code{\link{sfnetwork}} with a projected CRS.} diff --git a/man/autoplot.Rd b/man/autoplot.Rd index 7d00e0f3..c490d106 100644 --- a/man/autoplot.Rd +++ b/man/autoplot.Rd @@ -5,7 +5,7 @@ \alias{autoplot.sfnetwork} \title{Plot sfnetwork geometries with ggplot2} \usage{ -autoplot.sfnetwork(object, ...) +\method{autoplot}{sfnetwork}(object, ...) } \arguments{ \item{object}{An object of class \code{\link{sfnetwork}}.} From 78f84b87ac36d9ea9d70fd3756e76c72ed4b022f Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Mon, 12 Aug 2024 15:09:09 +0200 Subject: [PATCH 047/246] fix: Make assume constant check recognize all reserved attrs :wrench: --- R/checks.R | 3 +++ 1 file changed, 3 insertions(+) diff --git a/R/checks.R b/R/checks.R index d710cdc5..ef385269 100644 --- a/R/checks.R +++ b/R/checks.R @@ -263,8 +263,11 @@ will_assume_constant = function(x) { ignore = c( "from", "to", + ".tidygraph_node_index", ".tidygraph_edge_index", ".tidygraph_index", + ".tbl_graph_index", + ".sfnetwork_node_index", ".sfnetwork_edge_index", ".sfnetwork_index" ) From 1ed7e16f93bfb65d333a56222160e0d659f96564 Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Mon, 12 Aug 2024 15:23:04 +0200 Subject: [PATCH 048/246] fix: Allow to add multiple sfnetwork plots to each other :wrench: --- R/plot.R | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/R/plot.R b/R/plot.R index 1e888667..37177113 100644 --- a/R/plot.R +++ b/R/plot.R @@ -44,6 +44,7 @@ #' @importFrom methods hasArg #' @export plot.sfnetwork = function(x, draw_lines = TRUE, ...) { + node_geoms = pull_node_geom(x) # Plot the nodes. # Default pch should be 20. node_geoms = pull_node_geom(x) @@ -53,13 +54,22 @@ plot.sfnetwork = function(x, draw_lines = TRUE, ...) { plot(node_geoms, pch = 20, ...) } # Plot the edges. + # Add them to the nodes plot. if (has_explicit_edges(x)) { - plot(pull_edge_geom(x), ..., add = TRUE) + if (hasArg("add")) { + plot(pull_edge_geom(x), ...) + } else { + plot(pull_edge_geom(x), ..., add = TRUE) + } } else { if (draw_lines) { bids = edge_boundary_node_indices(x, matrix = TRUE) lines = draw_lines(node_geoms[bids[, 1]], node_geoms[bids[, 2]]) - plot(lines, ..., add = TRUE) + if (hasArg("add")) { + plot(lines, ...) + } else { + plot(lines, ..., add = TRUE) + } } } invisible() From 14ab32713ed5744ac01e715b4f90ad61a2c64859 Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Mon, 12 Aug 2024 16:16:04 +0200 Subject: [PATCH 049/246] feat: Update plot method to allow different settings for nodes and edges. Refs #246 :gift: --- R/plot.R | 103 ++++++++++++++++++++++-------------------- man/autoplot.Rd | 2 +- man/plot.sfnetwork.Rd | 47 +++++++++++++------ 3 files changed, 89 insertions(+), 63 deletions(-) diff --git a/R/plot.R b/R/plot.R index 37177113..4bb0894b 100644 --- a/R/plot.R +++ b/R/plot.R @@ -8,70 +8,81 @@ #' 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 methods hasArg #' @export -plot.sfnetwork = function(x, draw_lines = TRUE, ...) { +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) - # Plot the nodes. - # Default pch should be 20. - node_geoms = pull_node_geom(x) - if (hasArg("pch")) { - plot(node_geoms, ...) - } else { - plot(node_geoms, pch = 20, ...) - } + edge_geoms = if (has_explicit_edges(x)) pull_edge_geom(x) else NULL + # Extract additional plot arguments. + dots = list(...) # Plot the edges. - # Add them to the nodes plot. - if (has_explicit_edges(x)) { - if (hasArg("add")) { - plot(pull_edge_geom(x), ...) - } else { - plot(pull_edge_geom(x), ..., add = TRUE) - } - } else { - if (draw_lines) { - bids = edge_boundary_node_indices(x, matrix = TRUE) - lines = draw_lines(node_geoms[bids[, 1]], node_geoms[bids[, 2]]) - if (hasArg("add")) { - plot(lines, ...) - } else { - plot(lines, ..., add = TRUE) - } - } + if (draw_lines && is.null(edge_geoms)) { + bids = edge_boundary_node_indices(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) + 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() } @@ -91,12 +102,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 = explicitize_edges(object) + ggplot2::ggplot() + + ggplot2::geom_sf(data = nodes_as_sf(object)) + + ggplot2::geom_sf(data = edges_as_sf(object)) } diff --git a/man/autoplot.Rd b/man/autoplot.Rd index c490d106..7d00e0f3 100644 --- a/man/autoplot.Rd +++ b/man/autoplot.Rd @@ -5,7 +5,7 @@ \alias{autoplot.sfnetwork} \title{Plot sfnetwork geometries with ggplot2} \usage{ -\method{autoplot}{sfnetwork}(object, ...) +autoplot.sfnetwork(object, ...) } \arguments{ \item{object}{An object of class \code{\link{sfnetwork}}.} diff --git a/man/plot.sfnetwork.Rd b/man/plot.sfnetwork.Rd index b814dd3f..836de0da 100644 --- a/man/plot.sfnetwork.Rd +++ b/man/plot.sfnetwork.Rd @@ -4,7 +4,7 @@ \alias{plot.sfnetwork} \title{Plot sfnetwork geometries} \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) } From 73998bb3636f4bb268f2428e230552dea6561ead Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Mon, 12 Aug 2024 16:19:24 +0200 Subject: [PATCH 050/246] refactor: Simply use list(...) instead of rlangs dots_list :construction: --- NAMESPACE | 1 - R/morphers.R | 3 +-- R/sf.R | 4 +--- 3 files changed, 2 insertions(+), 6 deletions(-) diff --git a/NAMESPACE b/NAMESPACE index a7dca7f2..4cb5c82b 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -188,7 +188,6 @@ importFrom(lwgeom,st_geod_azimuth) importFrom(methods,hasArg) importFrom(pillar,style_subtle) importFrom(rlang,check_installed) -importFrom(rlang,dots_list) importFrom(rlang,dots_n) importFrom(rlang,enquo) importFrom(rlang,eval_tidy) diff --git a/R/morphers.R b/R/morphers.R index 684a501e..df6c93c5 100644 --- a/R/morphers.R +++ b/R/morphers.R @@ -406,7 +406,6 @@ to_spatial_explicit = function(x, ...) { #' #' @importFrom igraph induced_subgraph #' @importFrom methods hasArg -#' @importFrom rlang dots_list #' @importFrom units as_units deparse_unit #' @export to_spatial_neighborhood = function(x, node, threshold, ...) { @@ -420,7 +419,7 @@ to_spatial_neighborhood = function(x, node, threshold, ...) { if (hasArg("from")) { # Deprecate the former "from" argument specifying routing direction. deprecate_from() - if (isFALSE(dots_list(...)$from)) { + if (isFALSE(list(...)$from)) { costs = st_network_cost(x, from = node, direction = "in", ...) } else { costs = st_network_cost(x, from = node, ...) diff --git a/R/sf.R b/R/sf.R index 88b08e38..748019fe 100644 --- a/R/sf.R +++ b/R/sf.R @@ -405,8 +405,6 @@ st_join.morphed_sfnetwork = function(x, y, ...) { #' @importFrom cli cli_warn #' @importFrom igraph delete_vertices vertex_attr<- -#' @importFrom methods hasArg -#' @importFrom rlang dots_list #' @importFrom sf st_as_sf st_join spatial_join_nodes = function(x, y, ...) { # Convert x and y to sf. @@ -438,7 +436,7 @@ spatial_join_nodes = function(x, y, ...) { # 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. - if (hasArg("left") && ! dots_list(...)$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 From 4f55636f95c026c4eab111b0623a4722d3084d2b Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Mon, 12 Aug 2024 20:34:05 +0200 Subject: [PATCH 051/246] feat: Integrate tidygraphs new `focus` verb :gift: --- NAMESPACE | 7 ++- R/blend.R | 2 + R/checks.R | 16 ++++++ R/convert.R | 53 ++++++++--------- R/create.R | 18 ++++-- R/edge.R | 17 +++--- R/geom.R | 44 +++++++++----- R/join.R | 7 ++- R/morphers.R | 14 ++--- R/node.R | 15 ++--- R/print.R | 16 +++++- R/sf.R | 69 ++++++++++++---------- R/tidygraph.R | 6 ++ R/utils.R | 127 +++++++++++++++++++++++++++++------------ man/as_s2_geography.Rd | 4 +- man/as_tibble.Rd | 6 +- man/data.Rd | 8 ++- man/ids.Rd | 8 ++- man/n.Rd | 8 ++- man/nearest.Rd | 8 ++- man/nearest_ids.Rd | 8 ++- man/sf.Rd | 12 +++- 22 files changed, 315 insertions(+), 158 deletions(-) diff --git a/NAMESPACE b/NAMESPACE index 4cb5c82b..5cfa95a4 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -4,6 +4,7 @@ S3method("st_agr<-",sfnetwork) S3method("st_crs<-",sfnetwork) S3method("st_geometry<-",sfnetwork) S3method(as_sfnetwork,default) +S3method(as_sfnetwork,focused_tbl_graph) S3method(as_sfnetwork,linnet) S3method(as_sfnetwork,psp) S3method(as_sfnetwork,sf) @@ -56,6 +57,7 @@ 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) @@ -139,7 +141,6 @@ importFrom(graphics,plot) importFrom(igraph,"edge_attr<-") importFrom(igraph,"graph_attr<-") importFrom(igraph,"vertex_attr<-") -importFrom(igraph,E) importFrom(igraph,adjacent_vertices) importFrom(igraph,all_shortest_paths) importFrom(igraph,all_simple_paths) @@ -174,6 +175,7 @@ importFrom(igraph,is_dag) importFrom(igraph,is_directed) importFrom(igraph,is_simple) importFrom(igraph,mst) +importFrom(igraph,reverse_edges) importFrom(igraph,shortest_paths) importFrom(igraph,simplify) importFrom(igraph,vcount) @@ -187,6 +189,7 @@ importFrom(lifecycle,deprecated) importFrom(lwgeom,st_geod_azimuth) importFrom(methods,hasArg) importFrom(pillar,style_subtle) +importFrom(rlang,"%||%") importFrom(rlang,check_installed) importFrom(rlang,dots_n) importFrom(rlang,enquo) @@ -270,10 +273,10 @@ importFrom(tidygraph,active) importFrom(tidygraph,as_tbl_graph) importFrom(tidygraph,graph_join) importFrom(tidygraph,morph) -importFrom(tidygraph,mutate) importFrom(tidygraph,play_geometry) importFrom(tidygraph,reroute) importFrom(tidygraph,tbl_graph) +importFrom(tidygraph,unfocus) importFrom(tidygraph,unmorph) importFrom(tidygraph,with_graph) importFrom(units,as_units) diff --git a/R/blend.R b/R/blend.R index 3209c3d0..20f9c2c5 100644 --- a/R/blend.R +++ b/R/blend.R @@ -98,8 +98,10 @@ st_network_blend = function(x, y, tolerance = Inf) { } #' @importFrom cli cli_abort +#' @importFrom tidygraph unfocus #' @export st_network_blend.sfnetwork = function(x, y, tolerance = Inf) { + x = unfocus(x) if (! has_explicit_edges(x)) { cli_abort(c( "{.arg x} should have spatially explicit edges.", diff --git a/R/checks.R b/R/checks.R index ef385269..99abfaf1 100644 --- a/R/checks.R +++ b/R/checks.R @@ -23,6 +23,22 @@ 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 +is_focused = function(x) { + inherits(x, "focused_tbl_graph") +} + #' Check if an object is an sf object #' #' @param x Object to be checked. diff --git a/R/convert.R b/R/convert.R index 722d3319..c4d71dbb 100644 --- a/R/convert.R +++ b/R/convert.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}. @@ -46,41 +50,42 @@ #' @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_spatial_tibble(x, ...), - edges = nodes_as_spatial_tibble(x, ...), + nodes = nodes_as_spatial_tibble(x, focused = focused, ...), + edges = edges_as_spatial_tibble(x, focused = focused, ...), raise_invalid_active(active) ) } else { switch( active, - nodes = nodes_as_regular_tibble(x, ...), - edges = edges_as_regular_tibble(x, ...), + nodes = nodes_as_regular_tibble(x, focused = focused, ...), + edges = edges_as_regular_tibble(x, focused = focused, ...), raise_invalid_active(active) ) } } #' @importFrom sf st_as_sf -nodes_as_spatial_tibble = function(x, ...) { +nodes_as_spatial_tibble = function(x, focused = FALSE, ...) { st_as_sf( - nodes_as_regular_tibble(x, ...), + 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, ...) { +edges_as_spatial_tibble = function(x, focused = FALSE, ...) { if (has_explicit_edges(x)) { st_as_sf( - edges_as_regular_tibble(x, ...), + edges_as_regular_tibble(x, focused = focused, ...), agr = edge_agr(x), sf_column_name = edge_geom_colname(x) ) @@ -91,17 +96,17 @@ edges_as_spatial_tibble = function(x, ...) { #' @importFrom tibble as_tibble #' @importFrom tidygraph as_tbl_graph -nodes_as_regular_tibble = function(x, ...) { - as_tibble(as_tbl_graph(x), "nodes", ...) +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, ...) { - as_tibble(as_tbl_graph(x), "edges", ...) +edges_as_regular_tibble = function(x, focused = FALSE, ...) { + as_tibble(as_tbl_graph(x), active = "edges", focused = focused, ...) } -#' Convert a sfnetwork into a S2 geography vector +#' 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 @@ -114,10 +119,8 @@ edges_as_regular_tibble = function(x, ...) { #' @return An object of class \code{\link[s2]{s2_geography}}. #' #' @name as_s2_geography -#' -#' @importFrom sf st_geometry -as_s2_geography.sfnetwork = function(x, ...) { - s2::as_s2_geography(st_geometry(x)) +as_s2_geography.sfnetwork = function(x, focused = TRUE, ...) { + s2::as_s2_geography(pull_geom(x, focused = focused), ...) } #' Convert a sfnetwork into a linnet @@ -146,16 +149,10 @@ as.linnet.sfnetwork = function(X, ...) { check_installed("spatstat.linnet") check_installed("sf (>= 1.0)") if (is_installed("spatstat")) check_installed("spatstat (>= 2.0)") - # Extract the vertices of the sfnetwork. - X_vertices_ppp = spatstat.geom::as.ppp(pull_node_geom(X)) + # 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/create.R b/R/create.R index 47940bf2..6dafbc99 100644 --- a/R/create.R +++ b/R/create.R @@ -384,14 +384,22 @@ as_sfnetwork.sfNetwork = function(x, ...) { #' @importFrom tibble as_tibble #' @export as_sfnetwork.tbl_graph = function(x, ...) { - nodes = as_tibble(x, "nodes") - edges = as_tibble(x, "edges") + nodes = as_tibble(x, "nodes", focused = FALSE) + edges = as_tibble(x, "edges", focused = FALSE) if (hasArg("directed")) { - x_sfn = sfnetwork(nodes, edges, ...) + x_new = sfnetwork(nodes, edges, ...) } else { - x_sfn = sfnetwork(nodes, edges, directed = is_directed(x), ...) + x_new = sfnetwork(nodes, edges, directed = is_directed(x), ...) } - tbg_to_sfn(x_sfn %preserve_all_attrs% 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 diff --git a/R/edge.R b/R/edge.R index 379bc79b..50056a71 100644 --- a/R/edge.R +++ b/R/edge.R @@ -47,7 +47,7 @@ NULL edge_azimuth = function(degrees = FALSE) { require_active_edges() x = .G() - bounds = edge_boundary_nodes(x) + bounds = edge_boundary_nodes(x, focused = TRUE) values = st_geod_azimuth(bounds)[seq(1, length(bounds), 2)] if (degrees) values = set_units(values, "degrees") values @@ -74,7 +74,9 @@ 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) + 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. @@ -96,7 +98,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) } @@ -123,7 +125,7 @@ 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_boundary_node_indices(x, focused = TRUE, matrix = TRUE) # Calculate distances pairwise. st_distance(nodes[idxs[, 1]], nodes[idxs[, 2]], by_element = TRUE) } @@ -317,10 +319,11 @@ edge_is_nearest = function(y) { require_active_edges() x = .G() vec = rep(FALSE, n_edges(x)) - vec[nearest_edge_ids(x, y)] = TRUE - vec + vec[nearest_edge_ids(x, y, focused = FALSE)] = TRUE + vec[edge_ids(x, focused = TRUE)] } evaluate_edge_predicate = function(predicate, x, y, ...) { - lengths(predicate(pull_edge_geom(x), y, sparse = TRUE, ...)) > 0 + E = pull_edge_geom(x, focused = TRUE) + lengths(predicate(E, y, sparse = TRUE, ...)) > 0 } \ No newline at end of file diff --git a/R/geom.R b/R/geom.R index 5485cd7b..af6d7648 100644 --- a/R/geom.R +++ b/R/geom.R @@ -88,17 +88,21 @@ 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), + nodes = pull_node_geom(x, focused = focused), + edges = pull_edge_geom(x, focused = focused), raise_invalid_active(active) ) } @@ -106,20 +110,22 @@ pull_geom = function(x, active = NULL) { #' @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 (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) { +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 } @@ -132,6 +138,10 @@ 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. +#' #' @return An object of class \code{\link{sfnetwork}}. #' #' @details Note that the returned network will not be checked for a valid @@ -139,14 +149,14 @@ 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), + nodes = mutate_node_geom(x, y, focused = focused), + edges = mutate_edge_geom(x, y, focused = focused), raise_invalid_active(active) ) } @@ -154,9 +164,13 @@ mutate_geom = function(x, y, active = NULL) { #' @name mutate_geom #' @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 + if (focused && is_focused(x)) { + st_geometry(nodes[node_ids(x, focused = TRUE), ]) = y + } else { + st_geometry(nodes) = y + } node_attribute_values(x) = nodes x } @@ -164,9 +178,13 @@ mutate_node_geom = function(x, y) { #' @name mutate_geom #' @importFrom sf st_geometry<- #' @noRd -mutate_edge_geom = function(x, y) { - edges = edge_data(x) - st_geometry(edges) = y +mutate_edge_geom = function(x, y, focused = FALSE) { + edges = edge_data(x, focused = FALSE) + if (focused && is_focused(x)) { + st_geometry(edges[edge_ids(x, focused = TRUE), ]) = y + } else { + st_geometry(edges) = y + } edge_attribute_values(x) = edges x } diff --git a/R/join.R b/R/join.R index 27c0bc07..2daf3f87 100644 --- a/R/join.R +++ b/R/join.R @@ -48,11 +48,12 @@ st_network_join = function(x, y, ...) { } #' @importFrom cli cli_abort +#' @importFrom tidygraph unfocus #' @export st_network_join.sfnetwork = function(x, y, ...) { - if (! is_sfnetwork(y)) { - y = as_sfnetwork(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( diff --git a/R/morphers.R b/R/morphers.R index df6c93c5..62838054 100644 --- a/R/morphers.R +++ b/R/morphers.R @@ -142,7 +142,7 @@ to_spatial_contracted = function(x, ..., simplify = FALSE, # --> If requested the original node data in tibble format. ## ====================================================== # Extract the nodes from the contracted network. - new_nodes = as_tibble(x_new, "nodes") + new_nodes = as_tibble(x_new, "nodes", focused = FALSE) # Add geometries to the new nodes. # For each node that was not contracted: # --> Use its original geometry. @@ -372,7 +372,7 @@ to_spatial_explicit = function(x, ...) { # --> 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. if (dots_n > 0) { - edges = edge_data(x) + edges = edge_data(x, focused = FALSE) new_edges = st_as_sf(edges, ...) x_new = x edge_attribute_values(x_new) = new_edges @@ -491,8 +491,6 @@ to_spatial_shortest_paths = function(x, ...) { #' #' @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 #' @export to_spatial_simple = function(x, remove_multiple = TRUE, remove_loops = TRUE, summarise_attributes = "first", @@ -528,9 +526,9 @@ to_spatial_simple = function(x, remove_multiple = TRUE, remove_loops = TRUE, } # If requested, original edge data should be stored in a .orig_data column. if (store_original_data) { - edges = edge_data(x) + edges = edge_data(x, focused = FALSE) edges$.tidygraph_edge_index = NULL - new_edges = edge_data(x_new) + new_edges = edge_data(x, focused = FALSE_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 @@ -588,7 +586,7 @@ to_spatial_smooth = function(x, on.exit(igraph_options(return.vs.es = default_igraph_opt)) # Retrieve nodes and edges from the network. nodes = nodes_as_sf(x) - edges = edge_data(x) + edges = edge_data(x, focused = FALSE) # For later use: # --> Check if x is directed. # --> Check if x has spatially explicit edges. @@ -949,7 +947,7 @@ to_spatial_smooth = function(x, ## ============================================== if (store_original_data) { # Store the original edge data in a .orig_data column. - new_edges = edge_data(x_new) + new_edges = edge_data(x, focused = FALSE_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) diff --git a/R/node.R b/R/node.R index 0aef25db..eb569d40 100644 --- a/R/node.R +++ b/R/node.R @@ -48,7 +48,7 @@ NULL node_X = function() { require_active_nodes() x = .G() - get_coords(pull_node_geom(x), "X") + get_coords(pull_node_geom(x, focused = TRUE), "X") } #' @name node_coordinates @@ -56,7 +56,7 @@ node_X = function() { node_Y = function() { require_active_nodes() x = .G() - get_coords(pull_node_geom(x), "Y") + get_coords(pull_node_geom(x, focused = TRUE), "Y") } #' @name node_coordinates @@ -64,7 +64,7 @@ node_Y = function() { node_Z = function() { require_active_nodes() x = .G() - get_coords(pull_node_geom(x), "Z") + get_coords(pull_node_geom(x, focused = TRUE), "Z") } #' @name node_coordinates @@ -72,7 +72,7 @@ node_Z = function() { node_M = function() { require_active_nodes() x = .G() - get_coords(pull_node_geom(x), "M") + get_coords(pull_node_geom(x, focused = TRUE), "M") } #' @importFrom cli cli_warn @@ -238,10 +238,11 @@ node_is_nearest = function(y) { require_active_nodes() x = .G() vec = rep(FALSE, n_nodes(x)) - vec[nearest_node_ids(x, y)] = TRUE - vec + vec[nearest_node_ids(x, y, focused = FALSE)] = TRUE + vec[node_ids(x, focused = TRUE)] } evaluate_node_predicate = function(predicate, x, y, ...) { - lengths(predicate(pull_node_geom(x), y, sparse = TRUE, ...)) > 0 + N = pull_node_geom(x, focused = TRUE) + lengths(predicate(N, y, sparse = TRUE, ...)) > 0 } \ No newline at end of file diff --git a/R/print.R b/R/print.R index 07497be6..51aff02e 100644 --- a/R/print.R +++ b/R/print.R @@ -3,9 +3,10 @@ print.sfnetwork = function(x, ..., n = getOption("sfn_max_print_active", default = 6), n_non_active = getOption("sfn_max_print_inactive", default = 3)) { - N = as_tibble(x, "nodes") - E = as_tibble(x, "edges") + 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") @@ -13,8 +14,17 @@ print.sfnetwork = function(x, ..., 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 (attr(x, "active") == "nodes") { + if (nodes_are_active) { active_data = N active_name = "Node data" inactive_data = E diff --git a/R/sf.R b/R/sf.R index 748019fe..8567c517 100644 --- a/R/sf.R +++ b/R/sf.R @@ -17,6 +17,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 value The value to be assigned. See the documentation of the #' corresponding sf function for details. #' @@ -51,20 +55,20 @@ #' #' @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, ...), + nodes = nodes_as_sf(x, focused = focused, ...), + edges = edges_as_sf(x, focused = focused, ...), raise_invalid_active(active) ) } #' @importFrom sf st_as_sf -nodes_as_sf = function(x, ...) { +nodes_as_sf = function(x, focused = FALSE, ...) { st_as_sf( - nodes_as_regular_tibble(x), + nodes_as_regular_tibble(x, focused = focused), agr = node_agr(x), sf_column_name = node_geom_colname(x), ... @@ -72,11 +76,11 @@ nodes_as_sf = function(x, ...) { } #' @importFrom sf st_as_sf -edges_as_sf = function(x, ...) { +edges_as_sf = function(x, focused = FALSE, ...) { geom_colname = edge_geom_colname(x) if (is.null(geom_colname)) raise_require_explicit() st_as_sf( - edges_as_regular_tibble(x), + edges_as_regular_tibble(x, focused = focused), agr = edge_agr(x), sf_column_name = geom_colname, ... @@ -86,8 +90,8 @@ edges_as_sf = function(x, ...) { #' @name sf #' @importFrom sf st_as_s2 #' @export -st_as_s2.sfnetwork = function(x, active = NULL, ...) { - st_as_s2(pull_geom(x, active), ...) +st_as_s2.sfnetwork = function(x, active = NULL, focused = TRUE, ...) { + st_as_s2(pull_geom(x, active, focused = focused), ...) } # ============================================================================= @@ -104,8 +108,8 @@ st_as_s2.sfnetwork = function(x, active = NULL, ...) { #' #' @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 @@ -115,7 +119,7 @@ st_geometry.sfnetwork = function(obj, active = NULL, ...) { if (is.null(value)) { x_new = drop_geom(x) } else { - x_new = mutate_geom(x, value) + x_new = mutate_geom(x, value, focused = TRUE) validate_network(x_new) } x_new @@ -136,28 +140,28 @@ st_drop_geometry.sfnetwork = function(x, ...) { #' @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 #' @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 #' @importFrom sf st_is #' @export st_is.sfnetwork = function(x, ...) { - st_is(pull_geom(x), ...) + st_is(pull_geom(x, focused = TRUE), ...) } #' @name sf #' @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), ...) } # ============================================================================= @@ -249,14 +253,14 @@ st_zm.sfnetwork = function(x, ...) { #' @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 #' @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, ...) { @@ -293,7 +297,7 @@ st_agr.sfnetwork = function(x, active = NULL, ...) { #' @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 @@ -315,26 +319,21 @@ st_agr.sfnetwork = function(x, active = NULL, ...) { #' @name sf #' @importFrom cli cli_warn -#' @importFrom igraph is_directed +#' @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)) { - cli_warn( + cli_warn(c( paste( "{.fn st_reverse} swaps {.field from} and {.field to} columns", "in directed networks." ), 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 { cli_warn(c( @@ -358,7 +357,7 @@ st_simplify.sfnetwork = function(x, ...) { geom_unary_ops = function(op, x, active, ...) { x_sf = st_as_sf(x, active = active) d_tmp = op(x_sf, ...) - mutate_geom(x, st_geometry(d_tmp), active = active) + mutate_geom(x, st_geometry(d_tmp), active = active, focused = TRUE) } # ============================================================================= @@ -383,9 +382,12 @@ geom_unary_ops = function(op, x, active, ...) { #' 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, ...) { + x = unfocus(x) active = attr(x, "active") switch( active, @@ -481,9 +483,12 @@ spatial_join_edges = function(x, y, ...) { #' 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, @@ -521,8 +526,10 @@ spatial_filter_edges = function(x, y, ...) { #' @name sf #' @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( @@ -543,8 +550,10 @@ st_crop.morphed_sfnetwork = function(x, y, ...) { #' @name sf #' @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, @@ -564,8 +573,10 @@ st_difference.morphed_sfnetwork = function(x, y, ...) { #' @name sf #' @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, diff --git a/R/tidygraph.R b/R/tidygraph.R index 80a772d7..fd5c1962 100644 --- a/R/tidygraph.R +++ b/R/tidygraph.R @@ -68,3 +68,9 @@ unmorph.morphed_sfnetwork = function(.data, ...) { # Call tidygraphs unmorph. NextMethod(.data, ...) } + +#' @importFrom tidygraph unfocus +#' @export +unfocus.sfnetwork = function(.data, ...) { + .data +} diff --git a/R/utils.R b/R/utils.R index 471188b2..19eb3978 100644 --- a/R/utils.R +++ b/R/utils.R @@ -3,6 +3,10 @@ #' @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 @@ -13,21 +17,33 @@ #' @name n #' @importFrom igraph vcount #' @export -n_nodes = function(x) { - vcount(x) +n_nodes = function(x, focused = FALSE) { + if (focused) { + length(attr(x, "nodes_focus_index")) %||% vcount(x) + } else { + vcount(x) + } } #' @name n #' @importFrom igraph ecount #' @export -n_edges = function(x) { - ecount(x) +n_edges = function(x, focused = FALSE) { + if (focused) { + length(attr(x, "edges_focus_index")) %||% ecount(x) + } else { + ecount(x) + } } #' 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}} @@ -35,8 +51,8 @@ n_edges = function(x) { #' #' @name data #' @export -node_data = function(x) { - nodes_as_sf(x) +node_data = function(x, focused = TRUE) { + nodes_as_sf(x, focused = focused) } #' @name data @@ -45,17 +61,23 @@ node_data = function(x) { #' extraction of edge data fail if the edges are spatially implicit. Defaults #' to \code{FALSE}. #' -#' @importFrom tibble as_tibble -#' @importFrom tidygraph as_tbl_graph #' @export -edge_data = function(x, require_sf = FALSE) { - if (require_sf) edges_as_sf(x) else edges_as_spatial_tibble(x) +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) + } } #' Extract the 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. #' @@ -67,15 +89,25 @@ edge_data = function(x, require_sf = FALSE) { #' edge_ids(net) #' #' @name ids +#' @importFrom rlang %||% #' @export -node_ids = function(x) { - seq_len(n_nodes(x)) +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) { - seq_len(n_edges(x)) +edge_ids = function(x, focused = TRUE) { + if (focused) { + attr(x, "edges_focus_index") %||% seq_len(n_edges(x)) + } else { + seq_len(n_edges(x)) + } } #' Extract the nearest nodes or edges to given spatial features @@ -85,6 +117,10 @@ edge_ids = function(x) { #' @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 @@ -118,16 +154,16 @@ edge_ids = function(x) { #' @name nearest #' @importFrom sf st_geometry st_nearest_feature #' @export -nearest_nodes = function(x, y) { - nodes = nodes_as_sf(x) +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) { - edges = edges_as_sf(x) +nearest_edges = function(x, y, focused = TRUE) { + edges = edges_as_sf(x, focused = focused) edges[st_nearest_feature(st_geometry(y), edges), ] } @@ -138,6 +174,10 @@ nearest_edges = function(x, y) { #' @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 @@ -158,15 +198,15 @@ nearest_edges = function(x, y) { #' @name nearest_ids #' @importFrom sf st_geometry st_nearest_feature #' @export -nearest_node_ids = function(x, y) { - st_nearest_feature(st_geometry(y), nodes_as_sf(x)) +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) { - st_nearest_feature(st_geometry(y), edges_as_sf(x)) +nearest_edge_ids = function(x, y, focused = TRUE) { + st_nearest_feature(st_geometry(y), pull_edge_geom(x, focused = focused)) } #' Convert an adjacency matrix into a neighbor list @@ -329,6 +369,10 @@ merge_lines = function(x) { #' #' @param x An object of class \code{\link{sfnetwork}}. #' +#' @param focused Should only the boundary nodes of edges that are in focus be +#' extracted? Defaults to \code{FALSE}. See \code{\link[tidygraph]{focus}} for +#' more information on focused networks. +#' #' @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, ...]. @@ -340,12 +384,11 @@ merge_lines = function(x) { #' 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 +#' @importFrom igraph ends #' @noRd -edge_boundary_nodes = function(x) { +edge_boundary_nodes = function(x, focused = FALSE) { nodes = pull_node_geom(x) - id_mat = ends(x, E(x), names = FALSE) + id_mat = ends(x, edge_ids(x, focused = focused), names = FALSE) id_vct = as.vector(t(id_mat)) nodes[id_vct] } @@ -354,6 +397,10 @@ edge_boundary_nodes = function(x) { #' #' @param x An object of class \code{\link{sfnetwork}}. #' +#' @param focused Should only the indices of boundary nodes of edges that are +#' in focus be extracted? 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}. #' @@ -365,10 +412,10 @@ edge_boundary_nodes = function(x) { #' the start nodes of the edges, the seconds column contains the indices of the #' end nodes of the edges. #' -#' @importFrom igraph E ends +#' @importFrom igraph ends #' @noRd -edge_boundary_node_indices = function(x, matrix = FALSE) { - ends = ends(x, E(x), names = FALSE) +edge_boundary_node_indices = function(x, focused = FALSE, matrix = FALSE) { + ends = ends(x, edge_ids(x, focused = focused), names = FALSE) if (matrix) ends else as.vector(t(ends)) } @@ -376,6 +423,10 @@ edge_boundary_node_indices = function(x, matrix = FALSE) { #' #' @param x An object of class \code{\link{sfnetwork}}. #' +#' @param focused Should only the boundary points of edges that are in focus be +#' extracted? Defaults to \code{FALSE}. See \code{\link[tidygraph]{focus}} for +#' more information on focused networks. +#' #' @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, ...]. @@ -387,10 +438,9 @@ edge_boundary_node_indices = function(x, matrix = FALSE) { #' in the edges table. In a valid network structure, boundary nodes should be #' equal to boundary points. #' -#' @importFrom sf st_as_sf #' @noRd -edge_boundary_points = function(x) { - edges = pull_edge_geom(x) +edge_boundary_points = function(x, focused = FALSE) { + edges = pull_edge_geom(x, focused = focused) linestring_boundary_points(edges) } @@ -398,6 +448,10 @@ edge_boundary_points = function(x) { #' #' @param x An object of class \code{\link{sfnetwork}}. #' +#' @param focused Should only the indices of boundary points of edges that are +#' in focus be extracted? Defaults to \code{FALSE}. See +#' \code{\link[tidygraph]{focus}} for more informatio +#' #' @param matrix Should te result be returned as a two-column matrix? Defaults #' to \code{FALSE}. #' @@ -411,16 +465,16 @@ edge_boundary_points = function(x) { #' #' @importFrom sf st_equals #' @noRd -edge_boundary_point_indices = function(x, matrix = FALSE) { +edge_boundary_point_indices = function(x, focused = FALSE, matrix = FALSE) { nodes = pull_node_geom(x) - edges = edges_as_sf(x) + edges = edges_as_sf(x, focused = focused) 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) != n_edges(x) * 2) { + if (length(idxs_vct) != n_edges(x, focused = focused) * 2) { n = length(idxs_lst) from = idxs_lst[seq(1, n - 1, 2)] to = idxs_lst[seq(2, n, 2)] @@ -443,8 +497,7 @@ edge_boundary_point_indices = function(x, matrix = FALSE) { #' @return An object of class \code{\link{sfnetwork}} with spatially explicit #' edges. #' -#' @importFrom sf st_crs st_geometry st_sfc -#' @importFrom tidygraph mutate +#' @importFrom sf st_crs st_sfc #' @noRd explicitize_edges = function(x) { if (has_explicit_edges(x)) { diff --git a/man/as_s2_geography.Rd b/man/as_s2_geography.Rd index 3a16dbed..2b7b6e2a 100644 --- a/man/as_s2_geography.Rd +++ b/man/as_s2_geography.Rd @@ -3,9 +3,9 @@ \name{as_s2_geography} \alias{as_s2_geography} \alias{as_s2_geography.sfnetwork} -\title{Convert a sfnetwork into a S2 geography vector} +\title{Extract the geometries of a sfnetwork as a S2 geography vector} \usage{ -as_s2_geography.sfnetwork(x, ...) +as_s2_geography.sfnetwork(x, focused = TRUE, ...) } \arguments{ \item{x}{An object of class \code{\link{sfnetwork}}.} diff --git a/man/as_tibble.Rd b/man/as_tibble.Rd index f27895af..5b1a6782 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}.} diff --git a/man/data.Rd b/man/data.Rd index 976adefc..94b56a5f 100644 --- a/man/data.Rd +++ b/man/data.Rd @@ -6,13 +6,17 @@ \alias{edge_data} \title{Extract the node or edge data from a spatial network} \usage{ -node_data(x) +node_data(x, focused = TRUE) -edge_data(x, require_sf = FALSE) +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}.} diff --git a/man/ids.Rd b/man/ids.Rd index 5e3ec17d..f0aeabbd 100644 --- a/man/ids.Rd +++ b/man/ids.Rd @@ -6,12 +6,16 @@ \alias{edge_ids} \title{Extract the node or edge indices from a spatial network} \usage{ -node_ids(x) +node_ids(x, focused = TRUE) -edge_ids(x) +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{ An vector of integers. diff --git a/man/n.Rd b/man/n.Rd index 3b8921a6..cabb94a5 100644 --- a/man/n.Rd +++ b/man/n.Rd @@ -6,13 +6,17 @@ \alias{n_edges} \title{Count the number of nodes or edges in a network} \usage{ -n_nodes(x) +n_nodes(x, focused = FALSE) -n_edges(x) +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. diff --git a/man/nearest.Rd b/man/nearest.Rd index 95c75213..8acff067 100644 --- a/man/nearest.Rd +++ b/man/nearest.Rd @@ -6,15 +6,19 @@ \alias{nearest_edges} \title{Extract the nearest nodes or edges to given spatial features} \usage{ -nearest_nodes(x, y) +nearest_nodes(x, y, focused = TRUE) -nearest_edges(x, y) +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 diff --git a/man/nearest_ids.Rd b/man/nearest_ids.Rd index aa4c1b7b..84f260bf 100644 --- a/man/nearest_ids.Rd +++ b/man/nearest_ids.Rd @@ -6,15 +6,19 @@ \alias{nearest_edge_ids} \title{Extract the indices of nearest nodes or edges to given spatial features} \usage{ -nearest_node_ids(x, y) +nearest_node_ids(x, y, focused = TRUE) -nearest_edge_ids(x, y) +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 diff --git a/man/sf.Rd b/man/sf.Rd index dc060d87..5e62a3e5 100644 --- a/man/sf.Rd +++ b/man/sf.Rd @@ -42,11 +42,11 @@ \alias{st_area.sfnetwork} \title{sf methods for sfnetworks} \usage{ -\method{st_as_sf}{sfnetwork}(x, active = NULL, ...) +\method{st_as_sf}{sfnetwork}(x, active = NULL, focused = TRUE, ...) -\method{st_as_s2}{sfnetwork}(x, active = NULL, ...) +\method{st_as_s2}{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 @@ -125,6 +125,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}}.} @@ -203,6 +207,7 @@ 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)) @@ -222,4 +227,5 @@ plot(net, col = "grey") plot(poly, border = "red", lty = 4, lwd = 4, add = TRUE) plot(filtered) par(oldpar) + } From 584d386944786dbc331dbf72f7e64f6b1c2ccca1 Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Mon, 12 Aug 2024 20:39:31 +0200 Subject: [PATCH 052/246] fix: Remove call = FALSE statements in warning messages :wrench: --- NAMESPACE | 1 - R/messages.R | 34 ++++++++++++++-------------------- R/node.R | 2 +- R/sf.R | 16 ++++++---------- 4 files changed, 21 insertions(+), 32 deletions(-) diff --git a/NAMESPACE b/NAMESPACE index 5cfa95a4..f9d50820 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -274,7 +274,6 @@ importFrom(tidygraph,as_tbl_graph) importFrom(tidygraph,graph_join) importFrom(tidygraph,morph) importFrom(tidygraph,play_geometry) -importFrom(tidygraph,reroute) importFrom(tidygraph,tbl_graph) importFrom(tidygraph,unfocus) importFrom(tidygraph,unmorph) diff --git a/R/messages.R b/R/messages.R index 5befac01..fb05d6b1 100644 --- a/R/messages.R +++ b/R/messages.R @@ -2,29 +2,23 @@ #' @importFrom cli cli_warn raise_assume_constant = function(caller) { - cli_warn( - c( - "{.fn {caller}} assumes all attributes are constant over geometries.", - "x" = "Not all attributes are labelled as being constant.", - "i" = "You can label attribute-geometry relations using {.fn sf::st_set_agr}." - ), - call = NULL - ) + cli_warn(c( + "{.fn {caller}} assumes all attributes are constant over geometries.", + "x" = "Not all attributes are labelled as being constant.", + "i" = "You can label attribute-geometry relations using {.fn sf::st_set_agr}." + )) } #' @importFrom cli cli_warn raise_assume_projected = function(caller) { - cli_warn( - c( - "{.fn {caller}} assumes coordinates are projected.", - "x" = 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}." + cli_warn(c( + "{.fn {caller}} assumes coordinates are projected.", + "x" = paste( + "The provided coordinates are geographic,", + "which may lead to inaccurate results." ), - call = NULL - ) + "i" = "You can transform to a projected CRS using {.fn sf::st_transform}." + )) } #' @importFrom cli cli_abort @@ -48,7 +42,7 @@ raise_invalid_sf_column = function() { #' @importFrom cli cli_warn raise_multiple_elements = function(arg) { - cli_warn("Only the first element of {.arg {arg}} is used.", call = NULL) + cli_warn("Only the first element of {.arg {arg}} is used.") } #' @importFrom cli cli_abort @@ -58,7 +52,7 @@ raise_na_values = function(arg) { #' @importFrom cli cli_warn raise_overwrite = function(value) { - cli_warn("Overwriting column {.field value}.", call = NULL) + cli_warn("Overwriting column {.field value}.") } #' @importFrom cli cli_abort diff --git a/R/node.R b/R/node.R index eb569d40..f9df87f1 100644 --- a/R/node.R +++ b/R/node.R @@ -82,7 +82,7 @@ get_coords = function(x, value) { tryCatch( all_coords[, value], error = function(e) { - cli_warn("{value} coordinates are not available.", call = FALSE) + cli_warn("{value} coordinates are not available.") rep(NA, length(x)) } ) diff --git a/R/sf.R b/R/sf.R index 8567c517..c713d9c6 100644 --- a/R/sf.R +++ b/R/sf.R @@ -327,19 +327,16 @@ st_reverse.sfnetwork = function(x, ...) { if (active == "edges") { require_explicit_edges(x) if (is_directed(x)) { - cli_warn(c( - paste( - "{.fn st_reverse} swaps {.field from} and {.field to} columns", - "in directed networks." - ), call = FALSE + cli_warn(paste( + "{.fn st_reverse} swaps {.field from} and {.field to} columns", + "in directed networks." )) x = reverse_edges(x, eids = edge_ids(x)) %preserve_all_attrs% x } } else { cli_warn(c( "{.fn st_reverse} has no effect on nodes.", - "i" = "Call {.fn tidygraph::activate} to activate edges instead.", - call = FALSE + "i" = "Call {.fn tidygraph::activate} to activate edges instead." )) } geom_unary_ops(st_reverse, x, active,...) @@ -432,7 +429,7 @@ spatial_join_nodes = function(x, y, ...) { "x" = paste( "Multiple matches were detected for some nodes,", "of which all but the first one are ignored." - ), call = FALSE + ) )) } # If an inner join was requested instead of a left join: @@ -612,8 +609,7 @@ spatial_clip_edges = function(x, y, ..., .operator = sf::st_intersection) { # Clipping does not work good yet for undirected networks. if (!directed) { cli_warn( - "Clipping edges does not give correct results in undirected networks", - call = FALSE + "Clipping edges does not give correct results in undirected networks" ) } x_sf = edges_as_sf(x) From 133f28d62a345bdc5fc8ac5d6cb5c4883610693a Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Tue, 13 Aug 2024 11:05:48 +0200 Subject: [PATCH 053/246] fix: Fix X argument in as.linnet method :wrench: --- R/convert.R | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/R/convert.R b/R/convert.R index c4d71dbb..ebf458ce 100644 --- a/R/convert.R +++ b/R/convert.R @@ -150,9 +150,9 @@ as.linnet.sfnetwork = function(X, ...) { 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)) + V = spatstat.geom::as.ppp(pull_node_geom(X)) # Extract the edge list. - E = as.matrix(edges_as_regular_tibble(x)[, c("from", "to")]) + E = as.matrix(edges_as_regular_tibble(X)[, c("from", "to")]) # Build linnet. spatstat.linnet::linnet(vertices = V, edges = E, ...) } From 5f6cda0fe73eade953e764000f0a59a2e4811314 Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Tue, 13 Aug 2024 11:56:44 +0200 Subject: [PATCH 054/246] refactor: Tidy :construction: --- R/blend.R | 2 +- R/checks.R | 50 ++++++++++++----- R/create.R | 4 +- R/edge.R | 98 ++++++++++++++++++---------------- R/morphers.R | 2 +- R/node.R | 85 ++++++++++++++++------------- R/plot.R | 2 +- R/print.R | 2 - R/sf.R | 2 +- R/validate.R | 6 +-- man/node_coordinates.Rd | 12 +++-- man/plot.sfnetwork.Rd | 2 +- man/spatial_edge_measures.Rd | 24 +++++---- man/spatial_edge_predicates.Rd | 20 ++++--- man/spatial_morphers.Rd | 2 +- man/spatial_node_predicates.Rd | 25 +++++---- man/validate_network.Rd | 4 +- 17 files changed, 197 insertions(+), 145 deletions(-) diff --git a/R/blend.R b/R/blend.R index 20f9c2c5..9c5601f6 100644 --- a/R/blend.R +++ b/R/blend.R @@ -108,7 +108,7 @@ st_network_blend.sfnetwork = function(x, y, tolerance = Inf) { "i" = "Call {.fn sfnetworks::to_spatial_explicit} to explicitize edges." )) } - if (! has_single_geom_type(y, "POINT")) { + if (! are_points(y)) { cli_abort("All features in {.arg y} should have {.cls POINT} geometries.") } if (! have_equal_crs(x, y)) { diff --git a/R/checks.R b/R/checks.R index 99abfaf1..f6ea1f0e 100644 --- a/R/checks.R +++ b/R/checks.R @@ -101,31 +101,42 @@ is_sfg = function(x) { inherits(x, "sfg") } -#' Check if a table has spatial information stored in a geometry list column +#' Check if an object has only linestring geometries #' -#' @param x A flat table, such as an sf object, data.frame or tibble. +#' @param x An object of class \code{\link{sfnetwork}}, \code{\link[sf]{sf}} or +#' \code{\link[sf]{sfc}}. #' -#' @return \code{TRUE} if the table has a geometry list column, \code{FALSE} -#' otherwise. +#' @return \code{TRUE} if the geometries of the given object are all of type +#' \code{LINESTRING}, \code{FALSE} otherwise. #' #' @noRd -has_sfc = function(x) { - any(vapply(x, is_sfc, FUN.VALUE = logical(1)), na.rm = TRUE) +are_linestrings = function(x) { + is_sfc_linestring(st_geometry(x)) } -#' Check if geometries are all of a specific type +#' 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}}. #' -#' @param x An object of class \code{\link{sfnetwork}} or \code{\link[sf]{sf}}. +#' @return \code{TRUE} if the geometries of the given object are all of type +#' \code{POINT}, \code{FALSE} otherwise. #' -#' @param type The geometry type to check for, as a string. +#' @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} when all geometries are of the given type, \code{FALSE} +#' @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 @@ -216,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. @@ -242,6 +255,10 @@ is_single_string = function(x) { #' #' @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 +#' contain the geometries of both its boundary nodes. +#' #' @importFrom sf st_equals #' @noRd nodes_in_edge_boundaries = function(x) { @@ -258,6 +275,11 @@ nodes_in_edge_boundaries = function(x) { #' #' @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 start point of the edge geometry +#' is equal to the geometry of its start node, and the end point of the edge +#' geometry is equal to the geometry of its end node. +#' #' @noRd nodes_match_edge_boundaries = function(x) { boundary_points = edge_boundary_points(x) diff --git a/R/create.R b/R/create.R index 6dafbc99..57c11416 100644 --- a/R/create.R +++ b/R/create.R @@ -261,10 +261,10 @@ as_sfnetwork.default = function(x, ...) { #' @export as_sfnetwork.sf = function(x, ...) { if (hasArg("length_as_weight")) deprecate_length_as_weight("as_sfnetwork.sf") - if (has_single_geom_type(x, "LINESTRING")) { + if (are_lines(x)) { if (hasArg("edges_as_lines")) deprecate_edges_as_lines() create_from_spatial_lines(x, ...) - } else if (has_single_geom_type(x, "POINT")) { + } else if (are_points(x)) { create_from_spatial_points(x, ...) } else { cli_abort(c( diff --git a/R/edge.R b/R/edge.R index 50056a71..ca5eece0 100644 --- a/R/edge.R +++ b/R/edge.R @@ -32,12 +32,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 @@ -62,8 +62,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 @@ -85,10 +85,13 @@ edge_circuity = function(Inf_as_NaN = FALSE) { } #' @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 @@ -106,17 +109,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 @@ -172,7 +175,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. @@ -181,13 +184,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) @@ -197,123 +200,128 @@ 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() - evaluate_edge_predicate(st_intersects, x, y, ...) + 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() - evaluate_edge_predicate(st_disjoint, x, y, ...) + 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() - evaluate_edge_predicate(st_touches, x, y, ...) + 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() - evaluate_edge_predicate(st_crosses, x, y, ...) + 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() - evaluate_edge_predicate(st_within, x, y, ...) + 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() - evaluate_edge_predicate(st_contains, x, y, ...) + 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() - evaluate_edge_predicate(st_contains_properly, x, y, ...) + 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() - evaluate_edge_predicate(st_overlaps, x, y, ...) + 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() - evaluate_edge_predicate(st_equals, x, y, ...) + 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() - evaluate_edge_predicate(st_covers, x, y, ...) + 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() - evaluate_edge_predicate(st_covered_by, x, y, ...) + 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() - x = .G() - evaluate_edge_predicate(st_is_within_distance, x, y, ...) + 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() diff --git a/R/morphers.R b/R/morphers.R index 62838054..cfa909ff 100644 --- a/R/morphers.R +++ b/R/morphers.R @@ -1,4 +1,4 @@ -#' 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 diff --git a/R/node.R b/R/node.R index f9df87f1..88bf0bd6 100644 --- a/R/node.R +++ b/R/node.R @@ -25,8 +25,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,49 +36,53 @@ #' 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, focused = TRUE), "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, focused = TRUE), "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, focused = TRUE), "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, focused = TRUE), "M") + extract_node_coords(.G(), "M") } #' @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) { @@ -132,7 +136,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. @@ -141,18 +145,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) @@ -161,75 +166,79 @@ 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_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() - evaluate_node_predicate(st_intersects, x, y, ...) + 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() - evaluate_node_predicate(st_disjoint, x, y, ...) + 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() - evaluate_node_predicate(st_touches, x, y, ...) + 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() - evaluate_node_predicate(st_within, x, y, ...) + 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() - evaluate_node_predicate(st_equals, x, y, ...) + 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() - evaluate_node_predicate(st_covered_by, x, y, ...) + 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() - x = .G() - evaluate_node_predicate(st_is_within_distance, x, y, ...) + evaluate_node_predicate(st_is_within_distance, .G(), y, ...) } #' @name spatial_node_predicates diff --git a/R/plot.R b/R/plot.R index 4bb0894b..a877f9b1 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}}. #' diff --git a/R/print.R b/R/print.R index 51aff02e..7c126fd6 100644 --- a/R/print.R +++ b/R/print.R @@ -1,4 +1,3 @@ -#' @importFrom tibble as_tibble #' @export print.sfnetwork = function(x, ..., n = getOption("sfn_max_print_active", default = 6), @@ -157,7 +156,6 @@ is_tree = function(x) { } #' @importFrom igraph is_connected is_simple gorder gsize count_components -#' is_directed is_forest = function(x) { !is_connected(x) && is_simple(x) && diff --git a/R/sf.R b/R/sf.R index c713d9c6..29984ebd 100644 --- a/R/sf.R +++ b/R/sf.R @@ -676,7 +676,7 @@ spatial_clip_edges = function(x, y, ..., .operator = sf::st_intersection) { # 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)) + matches = have_equal_geometries(bound_pts, bound_nds) # For boundary points that do not match their corresponding node: # --> These points will be added as new nodes to the network. n_add = list() diff --git a/R/validate.R b/R/validate.R index ae25dccf..73e306c7 100644 --- a/R/validate.R +++ b/R/validate.R @@ -1,4 +1,4 @@ -#' Validate the structure of a sfnetwork object +#' Validate the structure of a sfnetwork #' #' @param x An object of class \code{\link{sfnetwork}}. #' @@ -19,7 +19,7 @@ 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 (! has_single_geom_type(nodes, "POINT")) { + if (! are_points(nodes)) { cli_abort("Not all nodes have geometry type POINT") } if (message) cli_alert_success("All nodes have geometry type POINT") @@ -27,7 +27,7 @@ validate_network = function(x, message = TRUE) { edges = pull_edge_geom(x) # Check 2: Are all edge geometries linestrings? if (message) cli_alert("Checking edge geometry types ...") - if (! has_single_geom_type(edges, "LINESTRING")) { + if (! are_linestrings(edges)) { cli_abort("Not all edges have geometry type LINESTRING") } if (message) cli_alert_success("All edges have geometry type LINESTRING") 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/plot.sfnetwork.Rd b/man/plot.sfnetwork.Rd index 836de0da..cad12722 100644 --- a/man/plot.sfnetwork.Rd +++ b/man/plot.sfnetwork.Rd @@ -2,7 +2,7 @@ % 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, node_args = list(), edge_args = list(), ...) } diff --git a/man/spatial_edge_measures.Rd b/man/spatial_edge_measures.Rd index ee8b00da..88f03a4e 100644 --- a/man/spatial_edge_measures.Rd +++ b/man/spatial_edge_measures.Rd @@ -54,7 +54,9 @@ 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}}. @@ -66,24 +68,24 @@ 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()) } diff --git a/man/spatial_edge_predicates.Rd b/man/spatial_edge_predicates.Rd index 299c9028..444ddffa 100644 --- a/man/spatial_edge_predicates.Rd +++ b/man/spatial_edge_predicates.Rd @@ -88,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. @@ -97,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) @@ -113,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 5aa7776e..13863fe6 100644 --- a/man/spatial_morphers.Rd +++ b/man/spatial_morphers.Rd @@ -12,7 +12,7 @@ \alias{to_spatial_subdivision} \alias{to_spatial_subset} \alias{to_spatial_transformed} -\title{Spatial morphers for sfnetworks} +\title{Morph spatial networks into a different structure} \usage{ to_spatial_contracted( x, diff --git a/man/spatial_node_predicates.Rd b/man/spatial_node_predicates.Rd index 8d19bf75..c50a6360 100644 --- a/man/spatial_node_predicates.Rd +++ b/man/spatial_node_predicates.Rd @@ -75,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. @@ -84,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) @@ -104,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_within(poly)) +head(within) + } diff --git a/man/validate_network.Rd b/man/validate_network.Rd index f2928c62..886e0b77 100644 --- a/man/validate_network.Rd +++ b/man/validate_network.Rd @@ -2,7 +2,7 @@ % Please edit documentation in R/validate.R \name{validate_network} \alias{validate_network} -\title{Validate the structure of a sfnetwork object} +\title{Validate the structure of a sfnetwork} \usage{ validate_network(x, message = TRUE) } @@ -16,7 +16,7 @@ validate_network(x, message = TRUE) Nothing when the network is valid. Otherwise, an error is thrown. } \description{ -Validate the structure of a sfnetwork object +Validate the structure of a sfnetwork } \details{ A valid sfnetwork structure means that all nodes have \code{POINT} From 1ee4e5c60a0388e34dc0fdee26e81e2c70456614 Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Tue, 13 Aug 2024 12:06:37 +0200 Subject: [PATCH 055/246] fix: Use correct linestring check function in as_sfnetwork.sf :wrench: --- R/create.R | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/R/create.R b/R/create.R index 57c11416..93b79814 100644 --- a/R/create.R +++ b/R/create.R @@ -261,7 +261,7 @@ as_sfnetwork.default = function(x, ...) { #' @export as_sfnetwork.sf = function(x, ...) { if (hasArg("length_as_weight")) deprecate_length_as_weight("as_sfnetwork.sf") - if (are_lines(x)) { + if (are_linestrings(x)) { if (hasArg("edges_as_lines")) deprecate_edges_as_lines() create_from_spatial_lines(x, ...) } else if (are_points(x)) { From a7543776dafdee04eb359708d765131a3bb54a18 Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Tue, 13 Aug 2024 12:07:06 +0200 Subject: [PATCH 056/246] fix: Correctly compute node and edge counts for focused networks :wrench: --- R/utils.R | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/R/utils.R b/R/utils.R index 19eb3978..fad94d1b 100644 --- a/R/utils.R +++ b/R/utils.R @@ -19,7 +19,8 @@ #' @export n_nodes = function(x, focused = FALSE) { if (focused) { - length(attr(x, "nodes_focus_index")) %||% vcount(x) + fids = attr(x, "nodes_focus_index") + if (is.null(fids)) vcount(x) else length(fids) } else { vcount(x) } @@ -30,7 +31,8 @@ n_nodes = function(x, focused = FALSE) { #' @export n_edges = function(x, focused = FALSE) { if (focused) { - length(attr(x, "edges_focus_index")) %||% ecount(x) + fids = attr(x, "edges_focus_index") + if (is.null(fids)) ecount(x) else length(fids) } else { ecount(x) } From b91f6238a543547d8d6e8d0c05537165759e929c Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Tue, 13 Aug 2024 12:08:09 +0200 Subject: [PATCH 057/246] fix: Allow to compute circuity of implicit edges :wrench: --- R/edge.R | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/R/edge.R b/R/edge.R index ca5eece0..9aae6180 100644 --- a/R/edge.R +++ b/R/edge.R @@ -73,14 +73,19 @@ edge_azimuth = function(degrees = FALSE) { edge_circuity = function(Inf_as_NaN = FALSE) { require_active_edges() x = .G() - # Calculate circuity. - 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 + 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 } From 802600d69f6a15acb997abb15a110d9b5fccee65 Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Tue, 13 Aug 2024 16:54:03 +0200 Subject: [PATCH 058/246] refactor: Use new style igraph functions, with _ instead of . :construction: --- NAMESPACE | 1 - R/morphers.R | 6 +++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/NAMESPACE b/NAMESPACE index f9d50820..13f13a7e 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -156,7 +156,6 @@ 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) diff --git a/R/morphers.R b/R/morphers.R index cfa909ff..9818040c 100644 --- a/R/morphers.R +++ b/R/morphers.R @@ -567,7 +567,7 @@ to_spatial_simple = function(x, remove_multiple = TRUE, remove_loops = TRUE, #' #' @importFrom cli cli_abort #' @importFrom igraph adjacent_vertices decompose degree delete_vertices -#' edge_attr edge.attributes get.edge.ids igraph_opt igraph_options +#' 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_equals st_is #' st_line_merge @@ -679,7 +679,7 @@ to_spatial_smooth = function(x, 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] + incident_attrs = edge_attr(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) { @@ -847,7 +847,7 @@ to_spatial_smooth = function(x, # 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.attributes(x) + 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. From 2ba57b3e20f5b89f15bc89d757a123665ea40477 Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Tue, 13 Aug 2024 16:54:28 +0200 Subject: [PATCH 059/246] refactor: Get rid of non-sparse predicate matrices :construction: --- R/checks.R | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/R/checks.R b/R/checks.R index f6ea1f0e..e5183a68 100644 --- a/R/checks.R +++ b/R/checks.R @@ -237,7 +237,8 @@ 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 @@ -264,11 +265,14 @@ is_single_string = function(x) { nodes_in_edge_boundaries = function(x) { boundary_points = edge_boundary_points(x) boundary_nodes = edge_boundary_nodes(x) - # Test for each edge : + # 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)) + equals = st_equals(boundary_points, boundary_nodes) + 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 From f87628c106aa5e0f27c0abdd07eae4493a8729c3 Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Tue, 13 Aug 2024 20:55:58 +0200 Subject: [PATCH 060/246] refactor: Add new internal function to correct edge geometries :construction: --- R/utils.R | 55 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/R/utils.R b/R/utils.R index fad94d1b..4d9a9f91 100644 --- a/R/utils.R +++ b/R/utils.R @@ -492,6 +492,61 @@ edge_boundary_point_indices = function(x, focused = FALSE, matrix = FALSE) { if (matrix) t(matrix(idxs_vct, nrow = 2)) else idxs_vct } +#' Correct edge geometries to match their boundary nodes +#' +#' @param x An object of class \code{\link{sfnetwork}}. +#' +#' @return An object of class \code{\link{sfnetwork}} with corrected edge +#' geometries. +#' +#' @importFrom sf st_crs st_crs<- st_precision st_precision<- +#' @importFrom sfheaders sfc_linestring sfc_to_df +#' @noRd +correct_edge_geometries = 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_boundary_nodes(x) + # Decompose the edges into the points that shape them. + # Convert the corret 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_startpoint = ! duplicated(E$linestring_id) + is_endpoint = ! duplicated(E$linestring_id, fromLast = TRUE) + is_boundary = is_startpoint | is_endpoint + # 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_boundary] = N$x + E_new$x = x_new + } + if (! is.null(E$y)) { + y_new = E$y + y_new[is_boundary] = N$y + E_new$y = y_new + } + if (! is.null(E$z)) { + z_new = E$z + z_new[is_boundary] = N$z + E_new$z = z_new + } + if (! is.null(E$m)) { + m_new = E$m + m_new[is_boundary] = N$m + E_new$m = m_new + } + E_new$id = E$linestring_id + # Create the new edge geometries. + new_geoms = sfc_linestring(as.data.frame(E_new), linestring_id = "id") + st_crs(new_geoms) = st_crs(edges) + st_precision(new_geoms) = st_precision(edges) + # Update the geometries of the edges table. + mutate_edge_geom(x, new_geoms) +} + #' Make edges spatially explicit #' #' @param x An object of class \code{\link{sfnetwork}}. From 572772006286b2d6aeb30df2485176e9bd102ec1 Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Tue, 13 Aug 2024 21:29:56 +0200 Subject: [PATCH 061/246] refactor: Tidy validation of edge-node matching :construction: --- R/checks.R | 12 ++++++------ R/validate.R | 10 +++++----- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/R/checks.R b/R/checks.R index e5183a68..cc1392f8 100644 --- a/R/checks.R +++ b/R/checks.R @@ -258,7 +258,7 @@ is_single_string = function(x) { #' #' @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 -#' contain the geometries of both its boundary nodes. +#' contains the geometries of both its boundary nodes. #' #' @importFrom sf st_equals #' @noRd @@ -279,13 +279,13 @@ nodes_in_edge_boundaries = function(x) { #' #' @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 start point of the edge geometry -#' is equal to the geometry of its start node, and the end point of the edge -#' geometry is equal to the geometry of its end node. +#' @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) { +nodes_equal_edge_boundaries = function(x) { boundary_points = edge_boundary_points(x) boundary_nodes = edge_boundary_nodes(x) # Test if the boundary geometries are equal to their corresponding nodes. diff --git a/R/validate.R b/R/validate.R index 73e306c7..bbbb93c8 100644 --- a/R/validate.R +++ b/R/validate.R @@ -46,14 +46,14 @@ validate_network = function(x, message = TRUE) { # 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 match start node. - # End point should match end node. - if (! all(nodes_match_edge_boundaries(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 match either start or end node. - # End point should match either start or end node. + # 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") } From 846e28d69b5635f0490fcd6098b53324c1afed25 Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Wed, 14 Aug 2024 18:42:37 +0200 Subject: [PATCH 062/246] refactor: Organize utils and restructure modules :construction: --- R/agr.R | 113 ------ R/attrs.R | 277 ++++++-------- R/blend.R | 4 +- R/create.R | 10 +- R/data.R | 184 +++++++++ R/edge.R | 308 ++++++++++++++- R/geom.R | 6 +- R/morphers.R | 26 +- R/nb.R | 72 ++++ R/nearest.R | 98 +++++ R/plot.R | 4 +- R/sf.R | 37 +- R/spatstat.R | 33 ++ R/summarize.R | 56 +++ R/{convert.R => tibble.R} | 51 --- R/utils.R | 766 ++++++++------------------------------ man/as.linnet.Rd | 2 +- man/as_s2_geography.Rd | 2 +- man/as_tibble.Rd | 2 +- man/data.Rd | 2 +- man/ids.Rd | 2 +- man/n.Rd | 2 +- man/nearest.Rd | 2 +- man/nearest_ids.Rd | 2 +- man/sf.Rd | 6 +- 25 files changed, 1084 insertions(+), 983 deletions(-) delete mode 100644 R/agr.R create mode 100644 R/data.R create mode 100644 R/nb.R create mode 100644 R/nearest.R create mode 100644 R/spatstat.R create mode 100644 R/summarize.R rename R/{convert.R => tibble.R} (65%) diff --git a/R/agr.R b/R/agr.R deleted file mode 100644 index 139189e7..00000000 --- a/R/agr.R +++ /dev/null @@ -1,113 +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_invalid_active(active) - ) -} - -#' @name agr -#' @importFrom igraph vertex_attr -#' @noRd -node_agr = function(x) { - agr = attr(vertex_attr(x), "agr") - colnames = node_attribute_names(x, geom = FALSE) - make_agr_valid(agr, names = colnames) -} - -#' @name agr -#' @importFrom igraph edge_attr -#' @noRd -edge_agr = function(x) { - agr = attr(edge_attr(x), "agr") - colnames = edge_attribute_names(x, idxs = TRUE, geom = FALSE) - make_agr_valid(agr, names = colnames) -} - -#' @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_invalid_active(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 48168fd1..1a76887d 100644 --- a/R/attrs.R +++ b/R/attrs.R @@ -30,6 +30,120 @@ sf_attr = function(x, name, active = NULL) { ) } +#' 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_invalid_active(active) + ) +} + +#' @name agr +#' @importFrom igraph vertex_attr +#' @noRd +node_agr = function(x) { + agr = attr(vertex_attr(x), "agr") + colnames = node_colnames(x, geom = FALSE) + make_agr_valid(agr, names = colnames) +} + +#' @name agr +#' @importFrom igraph edge_attr +#' @noRd +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 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_invalid_active(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 +} + #' Preserve the attributes of the original network and its elements #' #' @param new An object of class \code{\link{sfnetwork}}. @@ -104,166 +218,3 @@ sf_attr = function(x, name, active = NULL) { } new } - -#' Get attribute column names from the active element of a sfnetwork -#' -#' @param x An object of class \code{\link{sfnetwork}}. -#' -#' @param active Either 'nodes' or 'edges'. If \code{NULL}, the currently -#' active element of x will be used. -#' -#' @param idxs Should the columns storing indices of start and end nodes in the -#' edges table (i.e. the from and to columns) be considered attribute columns? -#' Defaults to \code{FALSE}. -#' -#' @param geom Should the geometry column be considered an attribute column? -#' Defaults to \code{TRUE}. -#' -#' @return A character vector. -#' -#' @name attr_names -#' @noRd -attribute_names = function(x, active = NULL, idxs = FALSE, geom = TRUE) { - if (is.null(active)) { - active = attr(x, "active") - } - switch( - active, - nodes = node_attribute_names(x, geom = geom), - edges = edge_attribute_names(x, idxs = idxs, geom = geom), - raise_invalid_active(active) - ) -} - -#' @name attr_names -#' @noRd -#' @importFrom igraph vertex_attr_names -node_attribute_names = function(x, geom = TRUE) { - attrs = vertex_attr_names(x) - if (! geom) { - attrs = attrs[attrs != node_geom_colname(x)] - } - attrs -} - -#' @name attr_names -#' @noRd -#' @importFrom igraph edge_attr_names -edge_attribute_names = 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 -} - -#' Set or replace attribute column values of the active element of a sfnetwork -#' -#' @param x An object of class \code{\link{sfnetwork}}. -#' -#' @param active Either 'nodes' or 'edges'. If \code{NULL}, the currently -#' active element of x will be used. -#' -#' @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}}. -#' -#' @return An object of class \code{\link{sfnetwork}} with updated attributes. -#' -#' @details For these functions, 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. -#' -#' @name attr_values -#' @noRd -`attribute_values<-` = function(x, active = NULL, value) { - if (is.null(active)) { - active = attr(x, "active") - } - switch( - active, - nodes = `node_attribute_values<-`(x, value), - edges = `edge_attribute_values<-`(x, value), - raise_invalid_active(active) - ) -} - -#' @name attr_values -#' @noRd -#' @importFrom igraph vertex_attr<- -`node_attribute_values<-` = function(x, value) { - vertex_attr(x) = as.list(value) - x -} - -#' @name attr_values -#' @noRd -#' @importFrom igraph edge_attr<- -`edge_attribute_values<-` = function(x, value) { - edge_attr(x) = as.list(value[, !names(value) %in% c("from", "to")]) - x -} - -#' Get the specified summary function for an attribute column. -#' -#' @param attr Name of the attribute. -#' -#' @param spec Specification of the summary function belonging to each -#' attribute. -#' -#' @return A function that takes a vector of attribute values as input and -#' returns a single value. -#' -#' @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" - } - } - } - } - if (is.function(func)) { - func - } else { - summariser(func) - } -} - -#' @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_summariser(name) - ) -} diff --git a/R/blend.R b/R/blend.R index 9c5601f6..6a4c9802 100644 --- a/R/blend.R +++ b/R/blend.R @@ -423,9 +423,9 @@ blend_ = function(x, y, tolerance) { # 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) + edge_pts[is_boundary, ]$node_id = edge_boundary_node_ids(x) } else { - edge_pts[is_boundary, ]$node_id = edge_boundary_point_indices(x) + edge_pts[is_boundary, ]$node_id = edge_boundary_point_ids(x) } # Update this vector of original node indices by: # --> Adding a new, unique node index to each of the split points. diff --git a/R/create.R b/R/create.R index 93b79814..27d8654d 100644 --- a/R/create.R +++ b/R/create.R @@ -141,7 +141,7 @@ sfnetwork = function(nodes, edges = NULL, directed = TRUE, node_key = "name", edge_agr(x_sfn) = attr(edges, "agr") # Remove edge geometries if requested. if (isFALSE(edges_as_lines)) { - x_sfn = implicitize_edges(x_sfn) + x_sfn = drop_edge_geom(x_sfn) } # Run validity check after implicitizing edges. if (! force) validate_network(x_sfn, message = message) @@ -150,7 +150,7 @@ sfnetwork = function(nodes, edges = NULL, directed = TRUE, node_key = "name", if (! force) validate_network(x_sfn, message = message) # Add edge geometries if requested. if (isTRUE(edges_as_lines)) { - x_sfn = explicitize_edges(x_sfn) + x_sfn = construct_edge_geometries(x_sfn) } } if (compute_length) { @@ -639,14 +639,14 @@ create_from_spatial_points = function(x, connections = "complete", } else { nblist = custom_neighbors(x, connections) } - nb2net(nblist, x, directed, edges_as_lines, compute_length) + nb_to_sfnetwork(nblist, x, directed, edges_as_lines, compute_length) } #' @importFrom cli cli_abort custom_neighbors = function(x, connections) { if (is.matrix(connections)) { require_valid_adjacency_matrix(connections, x) - adj2nb(connections) + adj_to_nb(connections) } else if (inherits(connections, c("sgbp", "nb", "list"))) { require_valid_neighbor_list(connections, x) connections @@ -668,7 +668,7 @@ complete_neighbors = function(x) { connections = matrix(TRUE, ncol = n_nodes, nrow = n_nodes) diag(connections) = FALSE # No loop edges. # Return as neighbor list. - adj2nb(connections) + adj_to_nb(connections) } #' @importFrom sf st_geometry diff --git a/R/data.R b/R/data.R new file mode 100644 index 00000000..615c1f5f --- /dev/null +++ b/R/data.R @@ -0,0 +1,184 @@ +#' 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}. +#' +#' @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) + } +} + +#' Extract the 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 An 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)) + } +} + +#' 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 column names of the nodes or edges table of of a sfnetwork +#' +#' @param x An object of class \code{\link{sfnetwork}}. +#' +#' @param idxs Should the names of the columns storing indices of start and end +#' 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 +} + +#' 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 +} diff --git a/R/edge.R b/R/edge.R index 9aae6180..fe469bdc 100644 --- a/R/edge.R +++ b/R/edge.R @@ -133,7 +133,7 @@ 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, focused = TRUE, matrix = TRUE) + idxs = edge_boundary_node_ids(x, focused = TRUE, matrix = TRUE) # Calculate distances pairwise. st_distance(nodes[idxs[, 1]], nodes[idxs[, 2]], by_element = TRUE) } @@ -339,4 +339,308 @@ edge_is_nearest = function(y) { evaluate_edge_predicate = function(predicate, x, y, ...) { E = pull_edge_geom(x, focused = TRUE) lengths(predicate(E, y, sparse = TRUE, ...)) > 0 -} \ No newline at end of file +} + +#' Get the geometries of the boundary nodes of edges in an sfnetwork +#' +#' @param x An object of class \code{\link{sfnetwork}}. +#' +#' @param focused Should only the boundary nodes of edges that are in focus be +#' extracted? 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 If list is \code{FALSE}, 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, ...]. If list is \code{TRUE}, a list with the first element being +#' the start nodes of the edges as object of class \code{\link[sf]{sfc}} with +#' \code{POINT} geometries, and the second element being the end nodes of the +#' edges as object of class \code{\link[sf]{sfc}} with \code{POINT} geometries. +#' +#' @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 directed network structure boundary points +#' should be equal to boundary nodes. In a valid undirected network structure +#' boundary points should contain the boundary nodes. +#' +#' @importFrom igraph ends +#' @noRd +edge_boundary_nodes = 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))] + } +} + +#' Get the geometries of the start nodes of edges in an sfnetwork +#' +#' @param x An object of class \code{\link{sfnetwork}}. +#' +#' @param focused Should only the start nodes of edges that are in focus be +#' extracted? Defaults to \code{FALSE}. See \code{\link[tidygraph]{focus}} for +#' more information on focused networks. +#' +#' @return An object of class \code{\link[sf]{sfc}} with \code{POINT} +#' geometries, of length equal to the number of edges in x. +#' +#' @importFrom igraph ends +#' @noRd +edge_start_nodes = function(x, focused = FALSE) { + nodes = pull_node_geom(x) + id_mat = ends(x, edge_ids(x, focused = focused), names = FALSE) + nodes[id_mat[, 1]] +} + +#' Get the geometries of the end nodes of edges in an sfnetwork +#' +#' @param x An object of class \code{\link{sfnetwork}}. +#' +#' @param focused Should only the end nodes of edges that are in focus be +#' extracted? Defaults to \code{FALSE}. See \code{\link[tidygraph]{focus}} for +#' more information on focused networks. +#' +#' @return An object of class \code{\link[sf]{sfc}} with \code{POINT} +#' geometries, of length equal to the number of edges in x. +#' +#' @importFrom igraph ends +#' @noRd +edge_end_nodes = function(x, focused = FALSE) { + nodes = pull_node_geom(x) + id_mat = ends(x, edge_ids(x, focused = focused), names = FALSE) + nodes[id_mat[, 2]] +} + +#' Get the indices of the boundary nodes of edges in an sfnetwork +#' +#' @param x An object of class \code{\link{sfnetwork}}. +#' +#' @param focused Should only the indices of boundary nodes of edges that are +#' in focus be extracted? 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 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 +#' second column contains the indices of the end nodes of the edges. +#' +#' @importFrom igraph ends +#' @noRd +edge_boundary_node_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)) +} + +#' Get the indices of the start nodes of edges in an sfnetwork +#' +#' @param x An object of class \code{\link{sfnetwork}}. +#' +#' @param focused Should only the indices of start nodes of edges that are +#' in focus be extracted? Defaults to \code{FALSE}. See +#' \code{\link[tidygraph]{focus}} for more information on focused networks. +#' +#' @return A numeric vector of length equal to the number of edges in x. +#' +#' @importFrom igraph ends +#' @noRd +edge_start_node_ids = function(x, focused = FALSE, matrix = FALSE) { + ends(x, edge_ids(x, focused = focused), names = FALSE)[, 1] +} + +#' Get the indices of the end nodes of edges in an sfnetwork +#' +#' @param x An object of class \code{\link{sfnetwork}}. +#' +#' @param focused Should only the indices of end nodes of edges that are +#' in focus be extracted? Defaults to \code{FALSE}. See +#' \code{\link[tidygraph]{focus}} for more information on focused networks. +#' +#' @return A numeric vector of length equal to the number of edges in x. +#' +#' @importFrom igraph ends +#' @noRd +edge_end_node_ids = function(x, focused = FALSE, matrix = FALSE) { + ends(x, edge_ids(x, focused = focused), names = FALSE)[, 2] +} + +#' Get the geometries of the boundary points of edges in an sfnetwork +#' +#' @param x An object of class \code{\link{sfnetwork}}. +#' +#' @param focused Should only the boundary points of edges that are in focus be +#' extracted? 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 If list is \code{FALSE}, 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, ...]. If list is \code{TRUE}, a list with the first element being +#' the start points of the edges as object of class \code{\link[sf]{sfc}} with +#' \code{POINT} geometries, and the second element being the end points of the +#' edges as object of class \code{\link[sf]{sfc}} with \code{POINT} geometries. +#' +#' @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 directed network structure boundary points +#' should be equal to boundary nodes. In a valid undirected network structure +#' boundary points should contain the boundary nodes. +#' +#' @noRd +edge_boundary_points = 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 + } +} + +#' Get the node indices of the boundary points of edges in an sfnetwork +#' +#' @param x An object of class \code{\link{sfnetwork}}. +#' +#' @param focused Should only the indices of boundary points of edges that are +#' in focus be extracted? Defaults to \code{FALSE}. See +#' \code{\link[tidygraph]{focus}} for more informatio +#' +#' @param matrix Should te result be returned as a two-column matrix? Defaults +#' to \code{FALSE}. +#' +#' @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. +#' +#' @importFrom sf st_equals +#' @noRd +edge_boundary_point_ids = function(x, focused = FALSE, matrix = FALSE) { + nodes = pull_node_geom(x) + edges = edges_as_sf(x, focused = focused) + 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) != n_edges(x, focused = focused) * 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 +} + +#' Correct edge geometries to match their boundary nodes +#' +#' This function makes invalid edge geometries valid by replacing their +#' boundary points with the geometries of the nodes that should be at their +#' boundary according to the specified from and to indices. +#' +#' @param x An object of class \code{\link{sfnetwork}}. +#' +#' @return An object of class \code{\link{sfnetwork}} with corrected edge +#' geometries. +#' +#' @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. +#' +#' @importFrom sfheaders sfc_to_df +#' @noRd +correct_edge_geometries = 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_boundary_nodes(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 = data.frame() + 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(E_new, edges, id_col = "id")) +} + +#' Construct edge geometries for spatially implicit networks +#' +#' This function turns spatially implicit networks into spatially explicit +#' networks by adding a geometry column to the edges data containing straight +#' lines between the start and end nodes. +#' +#' @param x An object of class \code{\link{sfnetwork}}. +#' +#' @return An object of class \code{\link{sfnetwork}} with spatially explicit +#' edges. If \code{x} was already spatially explicit it is returned unmodified. +#' +#' @importFrom sf st_crs st_sfc +#' @noRd +construct_edge_geometries = 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 draw straight lines between the boundary nodes of edges. + bounds = edge_boundary_nodes(x, list = TRUE) + mutate_edge_geom(x, draw_lines(bounds[[1]], bounds[[2]])) +} diff --git a/R/geom.R b/R/geom.R index af6d7648..152d2fa0 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}}. #' @@ -171,7 +171,7 @@ mutate_node_geom = function(x, y, focused = FALSE) { } else { st_geometry(nodes) = y } - node_attribute_values(x) = nodes + node_data(x) = nodes x } @@ -185,7 +185,7 @@ mutate_edge_geom = function(x, y, focused = FALSE) { } else { st_geometry(edges) = y } - edge_attribute_values(x) = edges + edge_data(x) = edges x } diff --git a/R/morphers.R b/R/morphers.R index 9818040c..2ed38889 100644 --- a/R/morphers.R +++ b/R/morphers.R @@ -163,7 +163,7 @@ to_spatial_contracted = function(x, ..., simplify = FALSE, } # Update the nodes table of the contracted network. new_nodes = st_as_sf(new_nodes, sf_column_name = node_geomcol) - node_attribute_values(x_new) = new_nodes + node_data(x_new) = new_nodes # Convert in a sfnetwork. x_new = tbg_to_sfn(x_new) ## =============================================================== @@ -209,7 +209,7 @@ to_spatial_contracted = function(x, ..., simplify = FALSE, 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) + bounds = edge_boundary_node_ids(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 @@ -316,7 +316,7 @@ to_spatial_contracted = function(x, ..., simplify = FALSE, } # Update the edges table of the contracted network. st_geometry(new_edges) = new_edge_geoms - edge_attribute_values(x_new) = new_edges + edge_data(x_new) = new_edges } # Return in a list. list( @@ -340,7 +340,7 @@ to_spatial_directed = function(x) { 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) + idxs = edge_boundary_point_ids(x, matrix = TRUE) from = idxs[, 1] to = idxs[, 2] # Update the from and to columns of the edges such that: @@ -375,9 +375,9 @@ to_spatial_explicit = function(x, ...) { edges = edge_data(x, focused = FALSE) new_edges = st_as_sf(edges, ...) x_new = x - edge_attribute_values(x_new) = new_edges + edge_data(x_new) = new_edges } else { - x_new = explicitize_edges(x) + x_new = construct_edge_geometries(x) } # Return in a list. list( @@ -522,7 +522,7 @@ to_spatial_simple = function(x, remove_multiple = TRUE, remove_loops = TRUE, 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) - edge_attribute_values(x_new) = new_edges + edge_data(x_new) = new_edges } # If requested, original edge data should be stored in a .orig_data column. if (store_original_data) { @@ -531,7 +531,7 @@ to_spatial_simple = function(x, remove_multiple = TRUE, remove_loops = TRUE, new_edges = edge_data(x, focused = FALSE_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 + edge_data(x_new) = new_edges } # Return in a list. list( @@ -655,10 +655,10 @@ to_spatial_smooth = function(x, # 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) + require_equal = edge_colnames(x, geom = FALSE) } else { # Check if all given attributes exist in the edges table of x. - attr_exists = require_equal %in% edge_attribute_names(x) + attr_exists = require_equal %in% edge_colnames(x, geom = FALSE) if (! all(attr_exists)) { unknown_attrs = paste(require_equal[!attr_exists], collapse = ", ") cli_abort(c( @@ -951,7 +951,7 @@ to_spatial_smooth = function(x, 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 + edge_data(x_new) = new_edges } # Return in a list. list( @@ -1099,9 +1099,9 @@ to_spatial_subdivision = function(x) { # 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) + node_idxs[is_boundary] = edge_boundary_node_ids(x) } else { - node_idxs[is_boundary] = edge_boundary_point_indices(x) + node_idxs[is_boundary] = edge_boundary_point_ids(x) } # 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. diff --git a/R/nb.R b/R/nb.R new file mode 100644 index 00000000..275ad744 --- /dev/null +++ b/R/nb.R @@ -0,0 +1,72 @@ +#' Convert a neighbor list into a sfnetwork +#' +#' Neighbor lists are sparse adjacency matrices in list format that specify for +#' each node to which other nodes it is adjacent. +#' +#' @param neighbors A list with one element per node, holding the indices of +#' the nodes it is adjacent to. +#' +#' @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}. +#' +#' @return An object of class \code{\link{sfnetwork}}. +#' +#' @importFrom tibble tibble +#' @noRd +nb_to_sfnetwork = function(neighbors, nodes, directed = TRUE, edges_as_lines = TRUE, + compute_length = FALSE) { + # 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(neighbors)), lengths(neighbors)), + do.call("c", neighbors) + ) + 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 + ) +} + +#' 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) { + if (! is.logical(x)) { + apply(x, 1, \(x) which(as.logical(x)), simplify = FALSE) + } else { + apply(x, 1, which, simplify = FALSE) + } +} \ No newline at end of file diff --git a/R/nearest.R b/R/nearest.R new file mode 100644 index 00000000..215076ac --- /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)) +#' +#' 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)) +#' +#' 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)) +} \ No newline at end of file diff --git a/R/plot.R b/R/plot.R index a877f9b1..976520dc 100644 --- a/R/plot.R +++ b/R/plot.R @@ -70,7 +70,7 @@ plot.sfnetwork = function(x, draw_lines = TRUE, dots = list(...) # Plot the edges. if (draw_lines && is.null(edge_geoms)) { - bids = edge_boundary_node_indices(x, matrix = TRUE) + bids = edge_boundary_node_ids(x, matrix = TRUE) edge_geoms = draw_lines(node_geoms[bids[, 1]], node_geoms[bids[, 2]]) } if (! is.null(edge_geoms)) { @@ -102,7 +102,7 @@ plot.sfnetwork = function(x, draw_lines = TRUE, #' #' @name autoplot autoplot.sfnetwork = function(object, ...) { - object = explicitize_edges(object) + object = construct_edge_geometries(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/sf.R b/R/sf.R index 29984ebd..7111727e 100644 --- a/R/sf.R +++ b/R/sf.R @@ -87,13 +87,6 @@ edges_as_sf = function(x, focused = FALSE, ...) { ) } -#' @name sf -#' @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), ...) -} - # ============================================================================= # Geometries # ============================================================================= @@ -164,6 +157,30 @@ st_is_valid.sfnetwork = function(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 ... 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 +#' @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 # ============================================================================= @@ -442,7 +459,7 @@ spatial_join_nodes = function(x, y, ...) { } # Update node attributes of the original network. n_new$.sfnetwork_index = NULL - node_attribute_values(x) = n_new + node_data(x) = n_new x } @@ -671,7 +688,7 @@ spatial_clip_edges = function(x, y, ..., .operator = sf::st_intersection) { # --> 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) + bound_pts = linestring_boundary_points(e_new) # Retrieve the nodes at the ends of each edge. # According to the from and to indices. bound_nds = edge_boundary_nodes(x_tmp) @@ -684,7 +701,7 @@ spatial_clip_edges = function(x, y, ..., .operator = sf::st_intersection) { 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 = edge_boundary_node_ids(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)] diff --git a/R/spatstat.R b/R/spatstat.R new file mode 100644 index 00000000..f07718f4 --- /dev/null +++ b/R/spatstat.R @@ -0,0 +1,33 @@ +#' 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 +#' \pkg{spatstat} package. +#' +#' @param X An object of class \code{\link{sfnetwork}} with a projected CRS. +#' +#' @param ... Arguments passed to \code{\link[spatstat.linnet]{linnet}}. +#' +#' @return An object of class \code{\link[spatstat.linnet]{linnet}}. +#' +#' @seealso \code{\link{as_sfnetwork}} to convert objects of class +#' \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_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. + E = as.matrix(edges_as_regular_tibble(X)[, c("from", "to")]) + # Build linnet. + spatstat.linnet::linnet(vertices = V, edges = E, ...) +} diff --git a/R/summarize.R b/R/summarize.R new file mode 100644 index 00000000..0c3f477d --- /dev/null +++ b/R/summarize.R @@ -0,0 +1,56 @@ +#' Get the specified summary function for an attribute column. +#' +#' @param attr Name of the attribute. +#' +#' @param spec Specification of the summary function belonging to each +#' attribute. +#' +#' @return A function that takes a vector of attribute values as input and +#' returns a single value. +#' +#' @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" + } + } + } + } + if (is.function(func)) { + func + } else { + summariser(func) + } +} + +#' @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_summariser(name) + ) +} diff --git a/R/convert.R b/R/tibble.R similarity index 65% rename from R/convert.R rename to R/tibble.R index ebf458ce..461bc00e 100644 --- a/R/convert.R +++ b/R/tibble.R @@ -105,54 +105,3 @@ nodes_as_regular_tibble = function(x, focused = FALSE, ...) { edges_as_regular_tibble = function(x, focused = FALSE, ...) { as_tibble(as_tbl_graph(x), active = "edges", focused = focused, ...) } - -#' 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 ... 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), ...) -} - -#' 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 -#' \pkg{spatstat} package. -#' -#' @param X An object of class \code{\link{sfnetwork}} with a projected CRS. -#' -#' @param ... Arguments passed to \code{\link[spatstat.linnet]{linnet}}. -#' -#' @return An object of class \code{\link[spatstat.linnet]{linnet}}. -#' -#' @seealso \code{\link{as_sfnetwork}} to convert objects of class -#' \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_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. - E = as.matrix(edges_as_regular_tibble(X)[, c("from", "to")]) - # Build linnet. - spatstat.linnet::linnet(vertices = V, edges = E, ...) -} diff --git a/R/utils.R b/R/utils.R index 4d9a9f91..148663d1 100644 --- a/R/utils.R +++ b/R/utils.R @@ -1,320 +1,167 @@ -#' Count the number of nodes or edges in a network +#' Convert a sfheaders data frame into sfc point geometries #' -#' @param x An object of class \code{\link{sfnetwork}}, or any other network -#' object inheriting from \code{\link[igraph]{igraph}}. +#' @param x_df An object of class \code{\link{data.frame}} as constructed by +#' the \pkg{sfheaders} package. #' -#' @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. +#' @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 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) - } -} - -#' 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}. -#' -#' @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}. +#' @return An object of class \code{\link[sf]{sfc}} with \code{POINT} +#' geometries. #' -#' @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) - } +#' @importFrom sf st_crs st_crs<- st_precision st_precision<- +#' @importFrom sfheaders sfc_point +#' @noRd +df_to_points = function(x_df, x_sf) { + pts = sfc_point(x_df[, names(x_df) %in% c("x", "y", "z", "m")]) + st_crs(pts) = st_crs(x_sf) + st_precision(pts) = st_precision(x_sf) + pts } -#' Extract the node or edge indices from a spatial network -#' -#' @param x An object of class \code{\link{sfnetwork}}. +#' Convert a sfheaders data frame into sfc linestring geometries #' -#' @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. +#' @param x_df An object of class \code{\link{data.frame}} as constructed by +#' the \pkg{sfheaders} package. #' -#' @details The indices in these objects are always integers that correspond to -#' rownumbers in respectively the nodes or edges table. +#' @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 An vector of integers. +#' @param id_col The name of the column in \code{x_df} that identifies which +#' row belongs to which linestring. #' -#' @examples -#' net = as_sfnetwork(roxel[1:10, ]) -#' node_ids(net) -#' edge_ids(net) +#' @return An object of class \code{\link[sf]{sfc}} with \code{LINESTRING} +#' geometries. #' -#' @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)) - } +#' @importFrom sf st_crs st_crs<- st_precision st_precision<- +#' @importFrom sfheaders sfc_linestring +#' @noRd +df_to_lines = function(x_df, x_sf, id_col = "linestring_id") { + lns = sfc_linestring( + x_df[, names(x_df) %in% c("x", "y", "z", "m", id_col)], + linestring_id = id_col + ) + st_crs(lns) = st_crs(x_sf) + st_precision(lns) = st_precision(x_sf) + lns } -#' 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)) -#' -#' 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)) +#' Get the boundary points of linestring geometries #' -#' 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) +#' @param x An object of class \code{\link[sf]{sf}} or \code{\link[sf]{sfc}} +#' with \code{LINESTRING} geometries. #' -#' 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) +#' @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, ...]. #' -#' par(oldpar) +#' @details With boundary points we mean the points at the start and end of +#' a linestring. #' -#' @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), ] +#' @importFrom sf st_geometry +#' @importFrom sfheaders sfc_to_df +#' @noRd +linestring_boundary_points = function(x) { + 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 + df_to_points(coords[is_bound, ], x) } -#' Extract the indices of nearest nodes or edges to given spatial features +#' Get the start points of linestring geometries #' -#' @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)) +#' @param x An object of class \code{\link[sf]{sf}} or \code{\link[sf]{sfc}} +#' with \code{LINESTRING} geometries. #' -#' nearest_node_ids(net, pts) -#' nearest_edge_ids(net, pts) +#' @return An object of class \code{\link[sf]{sfc}} with \code{POINT} +#' geometries, of length equal to the number of lines in x. #' -#' @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)) +#' @importFrom sf st_geometry +#' @importFrom sfheaders sfc_to_df +#' @noRd +linestring_start_points = function(x) { + coords = sfc_to_df(st_geometry(x)) + is_start = !duplicated(coords[["linestring_id"]]) + df_to_points(coords[is_start, ], x) } -#' 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. +#' Get the end points of linestring geometries #' -#' @param x An adjacency matrix of class \code{\link{matrix}}. Non-logical -#' matrices are first converted into logical matrices using -#' \code{\link{as.logical}}. +#' @param x An object of class \code{\link[sf]{sf}} or \code{\link[sf]{sfc}} +#' with \code{LINESTRING} geometries. #' -#' @return The sparse adjacency matrix as object of class \code{\link{list}}. +#' @return An object of class \code{\link[sf]{sfc}} with \code{POINT} +#' geometries, of length equal to the number of lines in x. #' +#' @importFrom sf st_geometry +#' @importFrom sfheaders sfc_to_df #' @noRd -adj2nb = function(x) { - if (! is.logical(x)) { - apply(x, 1, \(x) which(as.logical(x)), simplify = FALSE) - } else { - apply(x, 1, which, simplify = FALSE) - } +linestring_end_points = function(x) { + coords = sfc_to_df(st_geometry(x)) + is_end = !duplicated(coords[["linestring_id"]], fromLast = TRUE) + df_to_points(coords[is_end, ], x) } -#' Convert a neighbor list into a sfnetwork -#' -#' Neighbor lists are sparse adjacency matrices in list format that specify for -#' each node to which other nodes it is adjacent. -#' -#' @param neighbors A list with one element per node, holding the indices of -#' the nodes it is adjacent to. -#' -#' @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}. +#' Get the segments of linestring geometries #' -#' @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 x An object of class \code{\link[sf]{sf}} or \code{\link[sf]{sfc}} +#' with \code{LINESTRING} geometries. #' -#' @param compute_length Should the geographic length of the edges be stored in -#' a column named \code{length}? Defaults to \code{FALSE}. +#' @return An object of class \code{\link[sf]{sfc}} with \code{LINESTRING} +#' geometries. #' -#' @return An object of class \code{\link{sfnetwork}}. +#' @details With a line segment we mean a linestring geometry that has no +#' interior points. #' -#' @importFrom tibble tibble +#' @importFrom sf st_geometry +#' @importFrom sfheaders sfc_to_df #' @noRd -nb2net = function(neighbors, nodes, directed = TRUE, edges_as_lines = TRUE, - compute_length = FALSE) { - # 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(neighbors)), lengths(neighbors)), - do.call("c", neighbors) - ) - 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 - ) +linestring_segments = function(x) { + # Decompose lines into the points that shape them. + 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_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. + 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_points = rbind(segment_starts, segment_ends) + segment_points = segment_points[order(segment_points$segment_id), ] + df_to_lines(segment_points, x, id_col = "segment_id") } -#' 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. +#' Forcefully cast multilinestrings to single linestrings. #' -#' @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)) -} - -#' Get the last element of a vector +#' @param x An object of class \code{\link[sf]{sf}} or \code{\link[sf]{sfc}} +#' with \code{MULTILINESTRING} geometries or a combination of +#' \code{LINESTRING} geometries and \code{MULTILINESTRING} geometries. #' -#' @param x A vector. +#' @return An object of class \code{\link[sf]{sfc}} with \code{LINESTRING} +#' geometries. #' -#' @return The last element of \code{x}. +#' @details This may create invalid linestrings according to the simple feature +#' standard, e.g. linestrings may cross themselves. #' +#' @importFrom sf st_geometry +#' @importFrom sfheaders sfc_to_df #' @noRd -last_element = function(x) { - n = length(x) - if (n > 0) { - x[n] - } else { - x[1] - } +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] + # (Re)create linestring geometries. + df_to_lines(pts, x, id_col = "linestring_id") } #' Draw lines between two sets of points, row-wise @@ -336,12 +183,9 @@ last_element = function(x) { #' @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 + 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") } #' Merge multiple linestring geometries into one linestring @@ -367,333 +211,6 @@ merge_lines = function(x) { } } -#' Get the geometries of the boundary nodes of edges in an sfnetwork -#' -#' @param x An object of class \code{\link{sfnetwork}}. -#' -#' @param focused Should only the boundary nodes of edges that are in focus be -#' extracted? Defaults to \code{FALSE}. See \code{\link[tidygraph]{focus}} for -#' more information on focused networks. -#' -#' @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 ends -#' @noRd -edge_boundary_nodes = function(x, focused = FALSE) { - nodes = pull_node_geom(x) - id_mat = ends(x, edge_ids(x, focused = focused), names = FALSE) - id_vct = as.vector(t(id_mat)) - nodes[id_vct] -} - -#' Get the indices of the boundary nodes of edges in an sfnetwork -#' -#' @param x An object of class \code{\link{sfnetwork}}. -#' -#' @param focused Should only the indices of boundary nodes of edges that are -#' in focus be extracted? 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 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. -#' -#' @importFrom igraph ends -#' @noRd -edge_boundary_node_indices = function(x, focused = FALSE, matrix = FALSE) { - ends = ends(x, edge_ids(x, focused = focused), names = FALSE) - if (matrix) ends else as.vector(t(ends)) -} - -#' Get the geometries of the boundary points of edges in an sfnetwork -#' -#' @param x An object of class \code{\link{sfnetwork}}. -#' -#' @param focused Should only the boundary points of edges that are in focus be -#' extracted? Defaults to \code{FALSE}. See \code{\link[tidygraph]{focus}} for -#' more information on focused networks. -#' -#' @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 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. -#' -#' @noRd -edge_boundary_points = function(x, focused = FALSE) { - edges = pull_edge_geom(x, focused = focused) - linestring_boundary_points(edges) -} - -#' Get the node indices of the boundary points of edges in an sfnetwork -#' -#' @param x An object of class \code{\link{sfnetwork}}. -#' -#' @param focused Should only the indices of boundary points of edges that are -#' in focus be extracted? Defaults to \code{FALSE}. See -#' \code{\link[tidygraph]{focus}} for more informatio -#' -#' @param matrix Should te result be returned as a two-column matrix? Defaults -#' to \code{FALSE}. -#' -#' @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. -#' -#' @importFrom sf st_equals -#' @noRd -edge_boundary_point_indices = function(x, focused = FALSE, matrix = FALSE) { - nodes = pull_node_geom(x) - edges = edges_as_sf(x, focused = focused) - 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) != n_edges(x, focused = focused) * 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 -} - -#' Correct edge geometries to match their boundary nodes -#' -#' @param x An object of class \code{\link{sfnetwork}}. -#' -#' @return An object of class \code{\link{sfnetwork}} with corrected edge -#' geometries. -#' -#' @importFrom sf st_crs st_crs<- st_precision st_precision<- -#' @importFrom sfheaders sfc_linestring sfc_to_df -#' @noRd -correct_edge_geometries = 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_boundary_nodes(x) - # Decompose the edges into the points that shape them. - # Convert the corret 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_startpoint = ! duplicated(E$linestring_id) - is_endpoint = ! duplicated(E$linestring_id, fromLast = TRUE) - is_boundary = is_startpoint | is_endpoint - # 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_boundary] = N$x - E_new$x = x_new - } - if (! is.null(E$y)) { - y_new = E$y - y_new[is_boundary] = N$y - E_new$y = y_new - } - if (! is.null(E$z)) { - z_new = E$z - z_new[is_boundary] = N$z - E_new$z = z_new - } - if (! is.null(E$m)) { - m_new = E$m - m_new[is_boundary] = N$m - E_new$m = m_new - } - E_new$id = E$linestring_id - # Create the new edge geometries. - new_geoms = sfc_linestring(as.data.frame(E_new), linestring_id = "id") - st_crs(new_geoms) = st_crs(edges) - st_precision(new_geoms) = st_precision(edges) - # Update the geometries of the edges table. - mutate_edge_geom(x, new_geoms) -} - -#' Make edges spatially explicit -#' -#' @param x An object of class \code{\link{sfnetwork}}. -#' -#' @return An object of class \code{\link{sfnetwork}} with spatially explicit -#' edges. -#' -#' @importFrom sf st_crs st_sfc -#' @noRd -explicitize_edges = function(x) { - if (has_explicit_edges(x)) { - x - } else { - # Add empty geometry column if there are no edges. - if (n_edges(x) == 0) { - return(mutate_edge_geom(x, st_sfc(crs = st_crs(x)))) - } - # 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)) - } -} - -#' Make edges spatially implicit -#' -#' @param x An object of class \code{\link{sfnetwork}}. - -#' @return An object of class \code{\link{sfnetwork}} with spatially implicit -#' edges. -#' -#' @noRd -implicitize_edges = function(x) { - if (has_explicit_edges(x)) { - drop_edge_geom(x) - } else { - x - } -} - -#' Get the boundary 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, ...]. -#' -#' @details With boundary points we mean the points at the start and end of -#' a linestring. -#' -#' @importFrom sf st_crs st_crs<- st_geometry st_precision st_precision<- -#' @importFrom sfheaders sfc_point sfc_to_df -#' @noRd -linestring_boundary_points = function(x) { - # Extract coordinates. - 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 -} - -#' Get the segments 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{LINESTRING} -#' geometries. -#' -#' @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 -#' @noRd -linestring_segments = function(x) { - # Decompose lines into the points that shape them. - pts = 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")] - # 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)) - # 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 -} - -#' 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 -#' \code{LINESTRING} geometries and \code{MULTILINESTRING} geometries. -#' -#' @return An object of class \code{\link[sf]{sfc}} with \code{LINESTRING} -#' geometries. -#' -#' @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 -#' @noRd -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 -} - #' Merge two spatial bounding box objects #' #' @param a An object of class \code{\link[sf:st_bbox]{bbox}}. @@ -783,3 +300,36 @@ st_match = function(x) { idxs = do.call("c", lapply(st_equals(x), `[`, 1)) match(idxs, unique(idxs)) } + +#' 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)) +} + +#' Get the last element of a vector +#' +#' @param x A vector. +#' +#' @return The last element of \code{x}. +#' +#' @noRd +last_element = function(x) { + n = length(x) + if (n > 0) { + x[n] + } else { + x[1] + } +} \ No newline at end of file diff --git a/man/as.linnet.Rd b/man/as.linnet.Rd index 2832a967..b5fcb20e 100644 --- a/man/as.linnet.Rd +++ b/man/as.linnet.Rd @@ -1,5 +1,5 @@ % Generated by roxygen2: do not edit by hand -% Please edit documentation in R/convert.R +% Please edit documentation in R/spatstat.R \name{as.linnet} \alias{as.linnet} \alias{as.linnet.sfnetwork} diff --git a/man/as_s2_geography.Rd b/man/as_s2_geography.Rd index 2b7b6e2a..983de6bb 100644 --- a/man/as_s2_geography.Rd +++ b/man/as_s2_geography.Rd @@ -1,5 +1,5 @@ % Generated by roxygen2: do not edit by hand -% Please edit documentation in R/convert.R +% Please edit documentation in R/sf.R \name{as_s2_geography} \alias{as_s2_geography} \alias{as_s2_geography.sfnetwork} diff --git a/man/as_tibble.Rd b/man/as_tibble.Rd index 5b1a6782..cb6526da 100644 --- a/man/as_tibble.Rd +++ b/man/as_tibble.Rd @@ -1,5 +1,5 @@ % Generated by roxygen2: do not edit by hand -% Please edit documentation in R/convert.R +% Please edit documentation in R/tibble.R \name{as_tibble} \alias{as_tibble} \alias{as_tibble.sfnetwork} diff --git a/man/data.Rd b/man/data.Rd index 94b56a5f..6aa516f3 100644 --- a/man/data.Rd +++ b/man/data.Rd @@ -1,5 +1,5 @@ % Generated by roxygen2: do not edit by hand -% Please edit documentation in R/utils.R +% Please edit documentation in R/data.R \name{data} \alias{data} \alias{node_data} diff --git a/man/ids.Rd b/man/ids.Rd index f0aeabbd..26d886b9 100644 --- a/man/ids.Rd +++ b/man/ids.Rd @@ -1,5 +1,5 @@ % Generated by roxygen2: do not edit by hand -% Please edit documentation in R/utils.R +% Please edit documentation in R/data.R \name{ids} \alias{ids} \alias{node_ids} diff --git a/man/n.Rd b/man/n.Rd index cabb94a5..179ff00d 100644 --- a/man/n.Rd +++ b/man/n.Rd @@ -1,5 +1,5 @@ % Generated by roxygen2: do not edit by hand -% Please edit documentation in R/utils.R +% Please edit documentation in R/data.R \name{n} \alias{n} \alias{n_nodes} diff --git a/man/nearest.Rd b/man/nearest.Rd index 8acff067..49c901a7 100644 --- a/man/nearest.Rd +++ b/man/nearest.Rd @@ -1,5 +1,5 @@ % Generated by roxygen2: do not edit by hand -% Please edit documentation in R/utils.R +% Please edit documentation in R/nearest.R \name{nearest} \alias{nearest} \alias{nearest_nodes} diff --git a/man/nearest_ids.Rd b/man/nearest_ids.Rd index 84f260bf..c2953219 100644 --- a/man/nearest_ids.Rd +++ b/man/nearest_ids.Rd @@ -1,5 +1,5 @@ % Generated by roxygen2: do not edit by hand -% Please edit documentation in R/utils.R +% Please edit documentation in R/nearest.R \name{nearest_ids} \alias{nearest_ids} \alias{nearest_node_ids} diff --git a/man/sf.Rd b/man/sf.Rd index 5e62a3e5..22259186 100644 --- a/man/sf.Rd +++ b/man/sf.Rd @@ -3,7 +3,6 @@ \name{sf} \alias{sf} \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} @@ -44,8 +44,6 @@ \usage{ \method{st_as_sf}{sfnetwork}(x, active = NULL, focused = TRUE, ...) -\method{st_as_s2}{sfnetwork}(x, active = NULL, focused = TRUE, ...) - \method{st_geometry}{sfnetwork}(obj, active = NULL, focused = TRUE, ...) \method{st_geometry}{sfnetwork}(x) <- value @@ -60,6 +58,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 From 907f8bd23987ffbf5117668788f66ad52b55783a Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Wed, 14 Aug 2024 18:43:33 +0200 Subject: [PATCH 063/246] fix: Set namespace when calling tri2nb :wrench: --- R/create.R | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/R/create.R b/R/create.R index 27d8654d..701e88f0 100644 --- a/R/create.R +++ b/R/create.R @@ -705,7 +705,7 @@ mst_neighbors = function(x, directed = TRUE, edges_as_lines = TRUE) { #' @importFrom sf st_geometry delaunay_neighbors = function(x) { check_installed("spdep") # Package spdep is required for this function. - tri2nb(st_geometry(x)) + spdep::tri2nb(st_geometry(x)) } #' @importFrom rlang check_installed From e3d677361d249e405bdc4651b692b0be328c4d61 Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Wed, 14 Aug 2024 19:05:32 +0200 Subject: [PATCH 064/246] refactor: Add new internal function to direct reversed edges in undirected nets. Refs #259 :construction: --- R/edge.R | 41 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/R/edge.R b/R/edge.R index fe469bdc..cab63f50 100644 --- a/R/edge.R +++ b/R/edge.R @@ -561,11 +561,11 @@ edge_boundary_point_ids = function(x, focused = FALSE, matrix = FALSE) { if (matrix) t(matrix(idxs_vct, nrow = 2)) else idxs_vct } -#' Correct edge geometries to match their boundary nodes +#' Correct edge geometries to match their boundary node locations #' #' This function makes invalid edge geometries valid by replacing their #' boundary points with the geometries of the nodes that should be at their -#' boundary according to the specified from and to indices. +#' boundary according to the specified *from* and *to* indices. #' #' @param x An object of class \code{\link{sfnetwork}}. #' @@ -622,6 +622,43 @@ correct_edge_geometries = function(x) { mutate_edge_geom(x, df_to_lines(E_new, edges, id_col = "id")) } +#' Match the direction of edge geometries to their specified boundary 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}}. +#' +#' @return An object of class \code{\link{sfnetwork}} with updated edge +#' geometries. +#' +#' @details In undirected spatial networks it is required that the boundary of +#' edge geometries contain their boundary 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 boundary node geometries. In +#' directed networks, there will be no change. +#' +#' @importFrom sf st_reverse +#' @noRd +direct_edge_geometries = 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_start_nodes(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) +} + #' Construct edge geometries for spatially implicit networks #' #' This function turns spatially implicit networks into spatially explicit From c15eea5cd246318a4583f6a058b8233938ee9948 Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Wed, 14 Aug 2024 20:52:44 +0200 Subject: [PATCH 065/246] refactor: Explicitly set argument names when calling tbl_graph :construction: --- R/create.R | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/R/create.R b/R/create.R index 701e88f0..6c7882ab 100644 --- a/R/create.R +++ b/R/create.R @@ -128,7 +128,7 @@ sfnetwork = function(nodes, edges = NULL, directed = TRUE, node_key = "name", ) } # Create network. - x_tbg = tbl_graph(nodes, edges, directed, node_key) + 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. @@ -168,7 +168,7 @@ sfnetwork = function(nodes, edges = NULL, directed = TRUE, node_key = "name", #' @importFrom tidygraph tbl_graph sfnetwork_ = function(nodes, edges = NULL, directed = TRUE) { - x_tbg = tbl_graph(nodes, edges, directed) + 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") From da6dc3a2ead00a9639ac2d370addfaf5a12a9184 Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Wed, 14 Aug 2024 20:53:27 +0200 Subject: [PATCH 066/246] fix: Avoid filling empty data.frame in correct_edge_geometries :wrench: --- R/edge.R | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/R/edge.R b/R/edge.R index cab63f50..45b105b3 100644 --- a/R/edge.R +++ b/R/edge.R @@ -596,7 +596,7 @@ correct_edge_geometries = function(x) { 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 = data.frame() + E_new = list() if (! is.null(E$x)) { x_new = E$x x_new[is_bound] = N$x @@ -619,7 +619,7 @@ correct_edge_geometries = function(x) { } E_new$id = E$linestring_id # Update the geometries of the edges table. - mutate_edge_geom(x, df_to_lines(E_new, edges, id_col = "id")) + mutate_edge_geom(x, df_to_lines(as.data.frame(E_new), edges, id_col = "id")) } #' Match the direction of edge geometries to their specified boundary nodes From 7e411358da8a976200963d7636133afbb375faca Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Wed, 14 Aug 2024 20:53:51 +0200 Subject: [PATCH 067/246] refactor: Add new internal function to correct node geometries :construction: --- R/node.R | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/R/node.R b/R/node.R index 88bf0bd6..3933788a 100644 --- a/R/node.R +++ b/R/node.R @@ -254,4 +254,54 @@ node_is_nearest = function(y) { evaluate_node_predicate = function(predicate, x, y, ...) { N = pull_node_geom(x, focused = TRUE) lengths(predicate(N, y, sparse = TRUE, ...)) > 0 +} + +#' Correct node geometries to match edge boundary locations +#' +#' This function makes invalid edge geometries valid by adding their boundary +#' point as a new node to the network whenever it does not equal the location +#' of their boundary node as specified by the *from* or *to* indices. It +#' subsequently updates the *from* and *to* columns of the edges to correspond +#' to the new nodes. +#' +#' @param x An object of class \code{\link{sfnetwork}}. +#' +#' @return An object of class \code{\link{sfnetwork}} with corrected node +#' geometries. +#' +#' @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. +#' +#' @importFrom dplyr bind_rows +#' @importFrom igraph is_directed +#' @importFrom sf st_geometry st_sf +#' @noRd +correct_node_geometries = 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_boundary_node_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 } \ No newline at end of file From f775edb48736c6f96084d07c367413fcb66529fd Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Wed, 14 Aug 2024 20:54:29 +0200 Subject: [PATCH 068/246] feat: Export st_duplicated and st_match. Refs #165 :gift: --- NAMESPACE | 2 ++ R/utils.R | 75 +++++++++++++++++++++++++++----------------- man/st_duplicated.Rd | 28 +++++++++++++++++ man/st_match.Rd | 28 +++++++++++++++++ 4 files changed, 105 insertions(+), 28 deletions(-) create mode 100644 man/st_duplicated.Rd create mode 100644 man/st_match.Rd diff --git a/NAMESPACE b/NAMESPACE index 13f13a7e..53c304f3 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -109,6 +109,8 @@ export(node_touches) export(play_spatial) export(sf_attr) export(sfnetwork) +export(st_duplicated) +export(st_match) export(st_network_bbox) export(st_network_blend) export(st_network_cost) diff --git a/R/utils.R b/R/utils.R index 148663d1..665c0859 100644 --- a/R/utils.R +++ b/R/utils.R @@ -1,3 +1,50 @@ +#' Determine duplicated geometries +#' +#' @param x An object of class \code{\link[sf]{sf}} or \code{\link[sf]{sfc}}. +#' +#' @return A logical vector specifying for each feature in \code{x} if its +#' geometry is equal to a previous feature in \code{x}. +#' +#' @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 +} + +#' Geometry matching +#' +#' @param x An object of class \code{\link[sf]{sf}} or \code{\link[sf]{sfc}}. +#' +#' @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. +#' +#' @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)) +#' +#' @importFrom sf st_equals +#' @export +st_match = function(x) { + idxs = do.call("c", lapply(st_equals(x), `[`, 1)) + match(idxs, unique(idxs)) +} + #' Convert a sfheaders data frame into sfc point geometries #' #' @param x_df An object of class \code{\link{data.frame}} as constructed by @@ -273,34 +320,6 @@ merge_mranges = function(a, b) { ab } -#' Determine duplicated geometries -#' -#' @param x An object of class \code{\link[sf]{sf}} or \code{\link[sf]{sfc}}. -#' -#' @return A logical vector of the same length as \code{x}. -#' -#' @importFrom sf st_equals st_geometry -#' @noRd -st_duplicated = function(x) { - dup = rep(FALSE, length(st_geometry(x))) - dup[unique(do.call("c", lapply(st_equals(x), `[`, - 1)))] = TRUE - dup -} - -#' Geometry matching -#' -#' @param x An object of class \code{\link[sf]{sf}} or \code{\link[sf]{sfc}}. -#' -#' @return A numeric vector giving for each feature in x the row number of the -#' first feature in x that has equal coordinates. -#' -#' @importFrom sf st_equals -#' @noRd -st_match = function(x) { - idxs = do.call("c", lapply(st_equals(x), `[`, 1)) - match(idxs, unique(idxs)) -} - #' List-column friendly version of bind_rows #' #' @param ... Tables to be row-binded. diff --git a/man/st_duplicated.Rd b/man/st_duplicated.Rd new file mode 100644 index 00000000..b821768a --- /dev/null +++ b/man/st_duplicated.Rd @@ -0,0 +1,28 @@ +% 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)) + +} diff --git a/man/st_match.Rd b/man/st_match.Rd new file mode 100644 index 00000000..1b5c186e --- /dev/null +++ b/man/st_match.Rd @@ -0,0 +1,28 @@ +% 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)) + +} From db40fcde27e3b66183958e0a11bf0691ef87a56a Mon Sep 17 00:00:00 2001 From: loreabad6 Date: Wed, 14 Aug 2024 23:00:16 +0200 Subject: [PATCH 069/246] refactor: Update roxel dataset :construction: --- R/roxel.R | 4 ++-- data-raw/roxel.R | 51 +++++++++++++++++++++++++++++++++++++++++++++++ data/roxel.rda | Bin 29372 -> 33868 bytes man/roxel.Rd | 4 ++-- 4 files changed, 55 insertions(+), 4 deletions(-) create mode 100644 data-raw/roxel.R diff --git a/R/roxel.R b/R/roxel.R index 12d5b535..bba73fc7 100644 --- a/R/roxel.R +++ b/R/roxel.R @@ -2,8 +2,8 @@ #' #' 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: diff --git a/data-raw/roxel.R b/data-raw/roxel.R new file mode 100644 index 00000000..17fe0b6b --- /dev/null +++ b/data-raw/roxel.R @@ -0,0 +1,51 @@ +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") + +# 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) + +# 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 datta +usethis::use_data(roxel, overwrite = TRUE) diff --git a/data/roxel.rda b/data/roxel.rda index ad47eba0288bdb792720a42c477e89006abe7b52..8c5971bf88f6fa7cbf183ef0457db277e7613b58 100644 GIT binary patch literal 33868 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 literal 29372 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 Hb4A9$r98=N diff --git a/man/roxel.Rd b/man/roxel.Rd index 2e9ad8f0..fdb66407 100644 --- a/man/roxel.Rd +++ b/man/roxel.Rd @@ -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} From 69b59290c384ef0500ee79f9cfc5945bdd9273b5 Mon Sep 17 00:00:00 2001 From: loreabad6 Date: Wed, 14 Aug 2024 23:00:51 +0200 Subject: [PATCH 070/246] feat: Add mozart dataset :gift: --- R/mozart.R | 19 ++++++++++++++ data-raw/mozart.R | 65 ++++++++++++++++++++++++++++++++++++++++++++++ data/mozart.rda | Bin 0 -> 2007 bytes man/mozart.Rd | 31 ++++++++++++++++++++++ 4 files changed, 115 insertions(+) create mode 100644 R/mozart.R create mode 100644 data-raw/mozart.R create mode 100644 data/mozart.rda create mode 100644 man/mozart.Rd diff --git a/R/mozart.R b/R/mozart.R new file mode 100644 index 00000000..bbd7e698 --- /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 851 features and three 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/data-raw/mozart.R b/data-raw/mozart.R new file mode 100644 index 00000000..6ea62adb --- /dev/null +++ b/data-raw/mozart.R @@ -0,0 +1,65 @@ +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", + "Pizza Mozart", + "Mozartkugel", + "Altstadthotel Kasererbraeu Mozartkino GmbH", + "Mozartsteg/Imbergstraße", + "Mozartsteg/Rudolfskai", + "W. A. Mozart", + "Lesessaal Mozarteum" + )) |> + 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() +mpls = mozart_query$osm_multipolygons |> + filter(str_detect(name, "Mozart")) |> + 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, mpls) |> + 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) + +# save as lazy data +usethis::use_data(mozart, overwrite = TRUE) diff --git a/data/mozart.rda b/data/mozart.rda new file mode 100644 index 0000000000000000000000000000000000000000..fb41eebd28baa0bb1c087d907ab36f24b1dd75e3 GIT binary patch literal 2007 zcmb7;`#;l*1I9nIHN=?fnYlEX*+|FbGbf_P*KJrWmt0aYm*!TE5^)k5tz6pXlC9E= zu_2M$TPRS3DqVEd(Vy^ret5s0*Xwzn*XwZ!q1#(K2jl#LwW2T>0N(L) zUDIEKSAVYkd^7!K-G6cWNxrdq^m+zr|M#>buMJwC!cVD`*(~c{E*&upujog6j`7mS zv;HeSvivjRN{Ug@INgX{IsOrxI!zI!*1Y7Q;r|=AEAkMn_-Y*W6XUic3j_g~9+U*E3t|0>bO4?SKq_z)oQGKKq$C?%1V1W@ zA^{otR|esO@LbZbo(x*w3FrU-$u*0!^@vlXp zbF^Ku+=-_-i;$zrBJ6?f2$4zB%j4feEHlMn>?nmW!V6evo$oCruP%}5mdxlYsv{hi zc|zU_^FiNIU6@o7b!7?kF`G4#gddB>bZRsJN1HoEGYwk?mumAnPL64?Nxq zY*Y7LZ+&e%_HM}Cbc<=^O-^a{Ot1}fe}!~c=fNXqArGkF6F+V57S4Q2@1@~~%Y_P7 zY4s>&xQdIng94<S*wqUURtUrzVX_vEMaY32FoNydl z4yts})oUY3kj?Lb!Las|L8ntkH8SoQRx*j-!31tVzUN7~raJd(C71Zwwzio6%w5#wn>(53)$1;Ot%I~D{C%wXxA`xYvi8OZp?XvJ6s< zst0i&m%BPtj9j%+Y__V1RqnDQ)4aGOPtFffvdb{v*YuHn{^Ymn z+b^p80~?7&hfHoqnwtL6P#eEOaP#T@5gNycoQ+S4V}BY{(30cJ!n`y!D2zGK(GOXO znl4nJm61qOcESelvx^1p9G0WdOQ}NJpVGXo#MrCU2SI}cN?ZbG~mstAhn;MLt zobMjqF;~*mP<25G$?FBDuUBRgw*6HqBzrpliQN!$q@krAUSOaAP* z`{4fchIwRR`sqdW!RRY&w0u(^;pBkL*t?2HlBt?2>23*>2XK?Ao8lc9Xn)}yhfV|A zfzBjp6MSz?b+R_BAGOd(`!Q^_@2P}y%cb{sjWhXKO>rTUn*4DFw0^;p85k+4yJq=8 zYPDG-DI8-N@j0t5o$GI^Ss*LAL+s=ufNM+4rz)pj-Df6aZM)AXe5tYtaOjE3GG1t* zxd%L3_W0+Ov7V0C!<`p~XyWHn&C!i|IA=fIFQ$U)0e!31Bo-S{4P@WwT>?KWm6^!Z zP>{uor&wE<$dWXSi#G8E!9EyMvfG^ur3z7lUEP=}7!qy`+ticX4p)-oN#=TI51;!u zdnoXdaQH%Bdgwq&!m7X{#V>&yN@z25l5$4(wqd3kon)0mVs7VTOUS?bv)t4MlxwdC zt`&ryU`x~fEiOGG>#-$$)j98=xJy?TJt7zwR%E;uK2X4U0=s<%;i>W0A_XH;NVVEO z-8wtm)hCoabPmnjQ5)~W8eyNj9jqVohhR`roFlbxLk*b(ExEcQT{*AzL4nQ~Hp~Ad zeH+1FSV3W6k@lY3Gd*r@JrN#&M_G*)W1L6b*vY{Qb4oj;xfQ4(M&D$(qm>#1Ph+GA zhL|}%Jo8(UhDK>u;zHM#C#;8%pxI%=#R&9|)ckL-$G2YU;sRMwu8Ez481)S~!%4M4 zVl#&`clnIi2yta5(3!GPRFT6nZEID@0?NZAz(B_KZlkMsl*nuZmX%f*;yHdKDo0nN}Yoak*W^3k5T zaCAZ;@us#e!fh5W!4%AEc{>_i#MS48ZV365#Wwhr}jN>mdy$dYYT&j%_mX< zqe|DpJZ*=F_tuhLM~BV~@$ggBNX7AHbsY7|jk{rW7cQU2yuJ2(>tD99w)JYS4xG&p z-!K)|sN$(#-&yW-S|Apq)5A^VYcZUb`<&w)jpb5v8n7jt2RAkeUQ7J+_osUopB#Ml hc;+Hj1u{vFFAhOI!YJ$+A#eKJ#Ub=EGA|M!z<*$Ki=Y4i literal 0 HcmV?d00001 diff --git a/man/mozart.Rd b/man/mozart.Rd new file mode 100644 index 00000000..51f4ad08 --- /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 851 features and three 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} From 430bf29c5452c2708c61dbdc3caf455f5b48750b Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Mon, 19 Aug 2024 18:13:54 +0200 Subject: [PATCH 071/246] refactor: Reorganize edge utility functions :construction: --- R/create.R | 2 +- R/edge.R | 110 ++++++++++++++++++++++++++++++++++++--------------- R/morphers.R | 2 +- R/node.R | 50 ----------------------- R/plot.R | 2 +- 5 files changed, 82 insertions(+), 84 deletions(-) diff --git a/R/create.R b/R/create.R index 6c7882ab..639d08d9 100644 --- a/R/create.R +++ b/R/create.R @@ -150,7 +150,7 @@ sfnetwork = function(nodes, edges = NULL, directed = TRUE, node_key = "name", if (! force) validate_network(x_sfn, message = message) # Add edge geometries if requested. if (isTRUE(edges_as_lines)) { - x_sfn = construct_edge_geometries(x_sfn) + x_sfn = make_edges_explicit(x_sfn) } } if (compute_length) { diff --git a/R/edge.R b/R/edge.R index 45b105b3..1bae0dbe 100644 --- a/R/edge.R +++ b/R/edge.R @@ -561,27 +561,75 @@ edge_boundary_point_ids = function(x, focused = FALSE, matrix = FALSE) { if (matrix) t(matrix(idxs_vct, nrow = 2)) else idxs_vct } -#' Correct edge geometries to match their boundary node locations +#' Match edge geometries to their boundary node locations #' -#' This function makes invalid edge geometries valid by replacing their -#' boundary points with the geometries of the nodes that should be at their -#' boundary according to the specified *from* and *to* indices. +#' This function makes invalid edges valid by making sure that the boundary +#' points of their linestring geometry match the geometries of the nodes that +#' are specified through the *from* and *to* indices. #' #' @param x An object of class \code{\link{sfnetwork}}. #' +#' @param preserve_geometries Should the edge geometries remain unmodified? +#' Defaults to \code{FALSE}. See Details. +#' #' @return An object of class \code{\link{sfnetwork}} with corrected edge #' geometries. #' +#' @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. +#' reversed before running this function. Use +#' \code{\link{make_edges_follow_indices}} for this. #' -#' @importFrom sfheaders sfc_to_df #' @noRd -correct_edge_geometries = function(x) { +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_boundary_node_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. @@ -622,6 +670,29 @@ correct_edge_geometries = function(x) { mutate_edge_geom(x, df_to_lines(as.data.frame(E_new), edges, id_col = "id")) } +#' Construct edge geometries for spatially implicit networks +#' +#' This function turns spatially implicit networks into spatially explicit +#' networks by adding a geometry column to the edges data containing straight +#' lines between the start and end nodes. +#' +#' @param x An object of class \code{\link{sfnetwork}}. +#' +#' @return An object of class \code{\link{sfnetwork}} with spatially explicit +#' edges. If \code{x} was already spatially explicit it is returned unmodified. +#' +#' @importFrom sf st_crs st_sfc +#' @noRd +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 draw straight lines between the boundary nodes of edges. + bounds = edge_boundary_nodes(x, list = TRUE) + mutate_edge_geom(x, draw_lines(bounds[[1]], bounds[[2]])) +} + #' Match the direction of edge geometries to their specified boundary nodes #' #' This function updates edge geometries in undirected networks such that they @@ -647,7 +718,7 @@ correct_edge_geometries = function(x) { #' #' @importFrom sf st_reverse #' @noRd -direct_edge_geometries = function(x) { +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) @@ -658,26 +729,3 @@ direct_edge_geometries = function(x) { edges[to_be_reversed] = st_reverse(edges[to_be_reversed]) mutate_edge_geom(x, edges) } - -#' Construct edge geometries for spatially implicit networks -#' -#' This function turns spatially implicit networks into spatially explicit -#' networks by adding a geometry column to the edges data containing straight -#' lines between the start and end nodes. -#' -#' @param x An object of class \code{\link{sfnetwork}}. -#' -#' @return An object of class \code{\link{sfnetwork}} with spatially explicit -#' edges. If \code{x} was already spatially explicit it is returned unmodified. -#' -#' @importFrom sf st_crs st_sfc -#' @noRd -construct_edge_geometries = 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 draw straight lines between the boundary nodes of edges. - bounds = edge_boundary_nodes(x, list = TRUE) - mutate_edge_geom(x, draw_lines(bounds[[1]], bounds[[2]])) -} diff --git a/R/morphers.R b/R/morphers.R index 2ed38889..a9cf2b47 100644 --- a/R/morphers.R +++ b/R/morphers.R @@ -377,7 +377,7 @@ to_spatial_explicit = function(x, ...) { x_new = x edge_data(x_new) = new_edges } else { - x_new = construct_edge_geometries(x) + x_new = make_edges_explicit(x) } # Return in a list. list( diff --git a/R/node.R b/R/node.R index 3933788a..f763a8d6 100644 --- a/R/node.R +++ b/R/node.R @@ -255,53 +255,3 @@ evaluate_node_predicate = function(predicate, x, y, ...) { N = pull_node_geom(x, focused = TRUE) lengths(predicate(N, y, sparse = TRUE, ...)) > 0 } - -#' Correct node geometries to match edge boundary locations -#' -#' This function makes invalid edge geometries valid by adding their boundary -#' point as a new node to the network whenever it does not equal the location -#' of their boundary node as specified by the *from* or *to* indices. It -#' subsequently updates the *from* and *to* columns of the edges to correspond -#' to the new nodes. -#' -#' @param x An object of class \code{\link{sfnetwork}}. -#' -#' @return An object of class \code{\link{sfnetwork}} with corrected node -#' geometries. -#' -#' @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. -#' -#' @importFrom dplyr bind_rows -#' @importFrom igraph is_directed -#' @importFrom sf st_geometry st_sf -#' @noRd -correct_node_geometries = 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_boundary_node_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 -} \ No newline at end of file diff --git a/R/plot.R b/R/plot.R index 976520dc..408d536d 100644 --- a/R/plot.R +++ b/R/plot.R @@ -102,7 +102,7 @@ plot.sfnetwork = function(x, draw_lines = TRUE, #' #' @name autoplot autoplot.sfnetwork = function(object, ...) { - object = construct_edge_geometries(object) # Make sure edges are explicit. + 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)) From b5d6724d9e2b61baa2f997962231f098a12f1f70 Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Mon, 26 Aug 2024 16:19:20 +0200 Subject: [PATCH 072/246] fix: Avoid floating point equality testing in st_network_join :wrench: --- NAMESPACE | 1 + R/join.R | 41 ++++++++++++++++++++++++++++++----------- 2 files changed, 31 insertions(+), 11 deletions(-) diff --git a/NAMESPACE b/NAMESPACE index 53c304f3..cfa971eb 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -138,6 +138,7 @@ importFrom(dplyr,group_by) importFrom(dplyr,group_indices) importFrom(dplyr,group_size) importFrom(dplyr,group_split) +importFrom(dplyr,join_by) importFrom(dplyr,mutate) importFrom(graphics,plot) importFrom(igraph,"edge_attr<-") diff --git a/R/join.R b/R/join.R index 2daf3f87..db63f144 100644 --- a/R/join.R +++ b/R/join.R @@ -73,17 +73,36 @@ st_network_join.sfnetwork = function(x, y, ...) { 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(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 } From 97fb3fde563940ce63e8b366321ec2bd1fef5a5d Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Mon, 26 Aug 2024 17:38:09 +0200 Subject: [PATCH 073/246] feat: Export conversion between sfnetwork and nb lists :gift: --- NAMESPACE | 2 ++ R/create.R | 14 ++------ R/nb.R | 57 +++++++++++++++++++++++++------ man/create_from_spatial_points.Rd | 2 +- man/nb.Rd | 57 +++++++++++++++++++++++++++++++ 5 files changed, 110 insertions(+), 22 deletions(-) create mode 100644 man/nb.Rd diff --git a/NAMESPACE b/NAMESPACE index cfa971eb..63a13408 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -88,6 +88,7 @@ export(is.sfnetwork) export(is_sfnetwork) export(n_edges) export(n_nodes) +export(nb_to_sfnetwork) export(nearest_edge_ids) export(nearest_edges) export(nearest_node_ids) @@ -109,6 +110,7 @@ export(node_touches) export(play_spatial) export(sf_attr) export(sfnetwork) +export(sfnetwork_to_nb) export(st_duplicated) export(st_match) export(st_network_bbox) diff --git a/R/create.R b/R/create.R index 639d08d9..838d3132 100644 --- a/R/create.R +++ b/R/create.R @@ -485,7 +485,7 @@ create_from_spatial_lines = function(x, directed = TRUE, #' #' @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. +#' describing a specific method to define the connections. See Details. #' #' @param directed Should the constructed network be directed? Defaults to #' \code{TRUE}. @@ -678,17 +678,9 @@ sequential_neighbors = function(x) { lapply(c(1:(n_nodes - 1)), \(x) x + 1) } -#' @importFrom igraph as_edgelist graph_from_adjacency_matrix igraph_opt -#' igraph_options mst as_adj_list +#' @importFrom igraph as_edgelist graph_from_adjacency_matrix mst #' @importFrom sf st_distance st_geometry mst_neighbors = function(x, directed = TRUE, edges_as_lines = TRUE) { - # 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)) # Create a complete graph. n_nodes = length(st_geometry(x)) connections = upper.tri(matrix(FALSE, ncol = n_nodes, nrow = n_nodes)) @@ -698,7 +690,7 @@ mst_neighbors = function(x, directed = TRUE, edges_as_lines = TRUE) { # Compute minimum spanning tree of the weighted complete graph. mst = mst(net, weights = dists) # Return as a neighbor list. - as_adj_list(mst) + sfnetwork_to_nb(mst) } #' @importFrom rlang check_installed diff --git a/R/nb.R b/R/nb.R index 275ad744..2a034141 100644 --- a/R/nb.R +++ b/R/nb.R @@ -1,10 +1,14 @@ -#' Convert a neighbor list into a sfnetwork +#' 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. +#' each node to which other nodes it is adjacent. They occur for example in the +#' \code{\pkg{sf}} package as \code{\link[sf]{sgbp}} objects, and are also +#' frequently used in the \code{\pkg{spdep}} package. #' -#' @param neighbors A list with one element per node, holding the indices of -#' the nodes it is adjacent to. +#' @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. @@ -19,17 +23,33 @@ #' @param compute_length Should the geographic length of the edges be stored in #' a column named \code{length}? Defaults to \code{FALSE}. #' -#' @return An object of class \code{\link{sfnetwork}}. +#' @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 -#' @noRd -nb_to_sfnetwork = function(neighbors, nodes, directed = TRUE, edges_as_lines = TRUE, - compute_length = FALSE) { +#' @export +nb_to_sfnetwork = function(x, nodes, directed = TRUE, edges_as_lines = TRUE, + compute_length = FALSE) { # 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(neighbors)), lengths(neighbors)), - do.call("c", neighbors) + rep(c(1:length(x)), lengths(x)), + do.call("c", x) ) if (! directed && length(edges) > 0) { # If the network is undirected: @@ -48,6 +68,23 @@ nb_to_sfnetwork = function(neighbors, nodes, directed = TRUE, edges_as_lines = T ) } +#' @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 diff --git a/man/create_from_spatial_points.Rd b/man/create_from_spatial_points.Rd index 300fc373..b0d1ec53 100644 --- a/man/create_from_spatial_points.Rd +++ b/man/create_from_spatial_points.Rd @@ -19,7 +19,7 @@ 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.} +describing a specific method to define the connections. See Details.} \item{directed}{Should the constructed network be directed? Defaults to \code{TRUE}.} diff --git a/man/nb.Rd b/man/nb.Rd new file mode 100644 index 00000000..da2e3115 --- /dev/null +++ b/man/nb.Rd @@ -0,0 +1,57 @@ +% 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 +) + +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{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 +\code{\pkg{sf}} package as \code{\link[sf]{sgbp}} objects, and are also +frequently used in the \code{\pkg{spdep}} package. +} From 16adc2afdf81fdb566b6bbfb56e406f561b2496d Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Mon, 26 Aug 2024 17:45:09 +0200 Subject: [PATCH 074/246] docs: Use mozart dataset in network from points creation :books: --- R/create.R | 32 ++++++++++--------------------- man/as_sfnetwork.Rd | 21 ++++++-------------- man/create_from_spatial_points.Rd | 11 ++++------- 3 files changed, 20 insertions(+), 44 deletions(-) diff --git a/R/create.R b/R/create.R index 838d3132..210de2a9 100644 --- a/R/create.R +++ b/R/create.R @@ -230,29 +230,20 @@ as_sfnetwork.default = function(x, ...) { #' # From an sf object with LINESTRING geometries. #' library(sf, quietly = TRUE) #' -#' 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) -#' #' # From an sf object with POINT geometries. -#' # For more examples see create_from_spatial_points. -#' library(sf, quietly = TRUE) +#' # For more examples see ?create_from_spatial_points. +#' as_sfnetwork(mozart) #' -#' pts = st_centroid(roxel[10:15, ]) -#' -#' as_sfnetwork(pts) -#' -#' oldpar = par(no.readonly = TRUE) -#' par(mar = c(1,1,1,1), mfrow = c(1,2)) -#' -#' plot(st_geometry(pts)) -#' plot(as_sfnetwork(pts)) +#' plot(st_geometry(mozart)) +#' plot(as_sfnetwork(mozart)) #' #' par(oldpar) #' @@ -574,10 +565,7 @@ create_from_spatial_lines = function(x, directed = TRUE, #' oldpar = par(no.readonly = TRUE) #' par(mar = c(1,1,1,1)) #' -#' pts = roxel[seq(1, 100, by = 10),] |> -#' st_geometry() |> -#' st_centroid() |> -#' st_transform(3035) +#' pts = st_transform(mozart, 3035) #' #' # Using an adjacency matrix #' adj = matrix(c(rep(TRUE, 10), rep(FALSE, 90)), nrow = 10) @@ -586,9 +574,9 @@ create_from_spatial_lines = function(x, directed = TRUE, #' plot(net) #' #' # Using a sparse adjacency matrix from a spatial predicate -#' dst = units::set_units(500, "m") -#' adj = st_is_within_distance(pts, dist = dst) -#' net = as_sfnetwork(pts, connections = adj) +#' 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) #' diff --git a/man/as_sfnetwork.Rd b/man/as_sfnetwork.Rd index 48f0d422..a3906010 100644 --- a/man/as_sfnetwork.Rd +++ b/man/as_sfnetwork.Rd @@ -100,29 +100,20 @@ through the \code{directed} argument. # From an sf object with LINESTRING geometries. library(sf, quietly = TRUE) -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) - # From an sf object with POINT geometries. -# For more examples see create_from_spatial_points. -library(sf, quietly = TRUE) - -pts = st_centroid(roxel[10:15, ]) - -as_sfnetwork(pts) - -oldpar = par(no.readonly = TRUE) -par(mar = c(1,1,1,1), mfrow = c(1,2)) +# For more examples see ?create_from_spatial_points. +as_sfnetwork(mozart) -plot(st_geometry(pts)) -plot(as_sfnetwork(pts)) +plot(st_geometry(mozart)) +plot(as_sfnetwork(mozart)) par(oldpar) diff --git a/man/create_from_spatial_points.Rd b/man/create_from_spatial_points.Rd index b0d1ec53..5d4bf719 100644 --- a/man/create_from_spatial_points.Rd +++ b/man/create_from_spatial_points.Rd @@ -113,10 +113,7 @@ library(sf, quietly = TRUE) oldpar = par(no.readonly = TRUE) par(mar = c(1,1,1,1)) -pts = roxel[seq(1, 100, by = 10),] |> - st_geometry() |> - st_centroid() |> - st_transform(3035) +pts = st_transform(mozart, 3035) # Using an adjacency matrix adj = matrix(c(rep(TRUE, 10), rep(FALSE, 90)), nrow = 10) @@ -125,9 +122,9 @@ net = as_sfnetwork(pts, connections = adj) plot(net) # Using a sparse adjacency matrix from a spatial predicate -dst = units::set_units(500, "m") -adj = st_is_within_distance(pts, dist = dst) -net = as_sfnetwork(pts, connections = adj) +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) From 7f242ff9db49c4df376bebc18ba775d44feb62de Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Mon, 26 Aug 2024 18:33:34 +0200 Subject: [PATCH 075/246] refactor: Improve neighbor list validation :construction: --- NAMESPACE | 1 + R/create.R | 59 ++++++++++++++++---------- R/nb.R | 62 +++++++++++++++++++++++++-- R/require.R | 69 ------------------------------- man/create_from_spatial_points.Rd | 7 +++- man/nb.Rd | 9 +++- 6 files changed, 110 insertions(+), 97 deletions(-) diff --git a/NAMESPACE b/NAMESPACE index 63a13408..d3fb938d 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -201,6 +201,7 @@ importFrom(rlang,eval_tidy) importFrom(rlang,expr) importFrom(rlang,has_name) importFrom(rlang,is_installed) +importFrom(rlang,try_fetch) importFrom(sf,"st_agr<-") importFrom(sf,"st_crs<-") importFrom(sf,"st_geometry<-") diff --git a/R/create.R b/R/create.R index 210de2a9..6549b57c 100644 --- a/R/create.R +++ b/R/create.R @@ -103,6 +103,7 @@ #' @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 @@ -116,7 +117,7 @@ sfnetwork = function(nodes, edges = NULL, directed = TRUE, node_key = "name", # --> 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( + nodes = try_fetch( st_as_sf(nodes, ...), error = function(e) { sferror = sub(".*:", "", e) @@ -509,11 +510,14 @@ create_from_spatial_lines = function(x, directed = TRUE, #' \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}}. +#' are first converted into logical matrices using \code{\link{as.logical}}, +#' whenever possible. #' #' The provided adjacency matrix may also be 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. +#' 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 @@ -608,35 +612,46 @@ create_from_spatial_points = function(x, connections = "complete", directed = TRUE, edges_as_lines = TRUE, compute_length = FALSE, k = 1) { if (is_single_string(connections)) { - nblist = 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) + 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 { - nblist = custom_neighbors(x, connections) + nb_to_sfnetwork( + custom_neighbors(x, connections), + nodes = x, + directed = directed, + edges_as_lines = edges_as_lines, + compute_length = compute_length, + force = FALSE + ) } - nb_to_sfnetwork(nblist, x, directed, edges_as_lines, compute_length) } #' @importFrom cli cli_abort custom_neighbors = function(x, connections) { if (is.matrix(connections)) { - require_valid_adjacency_matrix(connections, x) adj_to_nb(connections) } else if (inherits(connections, c("sgbp", "nb", "list"))) { - require_valid_neighbor_list(connections, x) connections } else { cli_abort(c( diff --git a/R/nb.R b/R/nb.R index 2a034141..8d362329 100644 --- a/R/nb.R +++ b/R/nb.R @@ -23,6 +23,12 @@ #' @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 @@ -44,7 +50,8 @@ NULL #' @importFrom tibble tibble #' @export nb_to_sfnetwork = function(x, nodes, directed = TRUE, edges_as_lines = TRUE, - compute_length = FALSE) { + 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( @@ -100,10 +107,59 @@ sfnetwork_to_nb = function(x, direction = "out") { #' @return The sparse adjacency matrix as object of class \code{\link{list}}. #' #' @noRd -adj_to_nb = function(x) { +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) } -} \ No newline at end of file +} + +#' 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) 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/require.R b/R/require.R index e9851a1c..14805080 100644 --- a/R/require.R +++ b/R/require.R @@ -39,72 +39,3 @@ require_active_edges <- function() { require_explicit_edges = function(x) { if (! has_explicit_edges(x)) raise_require_explicit() } - - -#' Proceed only if the given object is a valid adjacency matrix -#' -#' 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. -#' -#' @param x Object to be checked. -#' -#' @param nodes The nodes that are referenced in the matrix 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 adjacency matrix -#' referencing the given nodes, an error message otherwise. -#' -#' @importFrom cli cli_abort -#' @importFrom sf st_geometry -#' @noRd -require_valid_adjacency_matrix = function(x, nodes) { - n_nodes = length(st_geometry(nodes)) - if (! (nrow(x) == n_nodes && ncol(x) == n_nodes)) { - cli_abort( - c( - "The dimensions of the matrix should match the number of nodes.", - "x" = paste( - "The provided matrix has dimensions {nrow(x)} x {ncol(x)},", - "while there are {n_nodes} nodes." - ) - ) - ) - } -} - -#' Proceed only if the given object is a valid 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 checked. -#' -#' @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, and error message afterwards. -#' -#' @importFrom cli cli_abort -#' @importFrom sf st_geometry -#' @noRd -require_valid_neighbor_list = function(x, nodes) { - n_nodes = length(st_geometry(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." - ) - ) - ) - } - if (! all(vapply(x, is.integer, FUN.VALUE = logical(1)))) { - cli_abort("The sparse matrix should contain integer node indices.") - } -} diff --git a/man/create_from_spatial_points.Rd b/man/create_from_spatial_points.Rd index 5d4bf719..7b0c9208 100644 --- a/man/create_from_spatial_points.Rd +++ b/man/create_from_spatial_points.Rd @@ -59,11 +59,14 @@ n x n matrix with n being the number of nodes, and element Aij holding 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}}. +are first converted into logical matrices using \code{\link{as.logical}}, +whenever possible. The provided adjacency matrix may also be 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. +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 diff --git a/man/nb.Rd b/man/nb.Rd index da2e3115..2b43b352 100644 --- a/man/nb.Rd +++ b/man/nb.Rd @@ -11,7 +11,8 @@ nb_to_sfnetwork( nodes, directed = TRUE, edges_as_lines = TRUE, - compute_length = FALSE + compute_length = FALSE, + force = FALSE ) sfnetwork_to_nb(x, direction = "out") @@ -34,6 +35,12 @@ 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 From bda53d7072a59f453c52604652ab0d024468abe8 Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Mon, 26 Aug 2024 18:36:56 +0200 Subject: [PATCH 076/246] docs: Add custom adj matrix example to create_from_spatial_points :books: --- R/create.R | 6 ++++++ man/create_from_spatial_points.Rd | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/R/create.R b/R/create.R index 6549b57c..77212734 100644 --- a/R/create.R +++ b/R/create.R @@ -577,6 +577,12 @@ create_from_spatial_lines = function(x, directed = TRUE, #' #' plot(net) #' +#' # Using a custom adjacency matrix +#' adj = matrix(c(rep(1, 21), rep(rep(0, 21), 20)), nrow = 21) +#' 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) diff --git a/man/create_from_spatial_points.Rd b/man/create_from_spatial_points.Rd index 7b0c9208..49e8cf3e 100644 --- a/man/create_from_spatial_points.Rd +++ b/man/create_from_spatial_points.Rd @@ -124,6 +124,12 @@ net = as_sfnetwork(pts, connections = adj) plot(net) +# Using a custom adjacency matrix +adj = matrix(c(rep(1, 21), rep(rep(0, 21), 20)), nrow = 21) +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) From 82d3bca31288b38f828071593e9d0fd683413713 Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Mon, 26 Aug 2024 18:43:39 +0200 Subject: [PATCH 077/246] fix: Always plot whole network even if nodes are isolated :wrench: --- R/plot.R | 1 + 1 file changed, 1 insertion(+) diff --git a/R/plot.R b/R/plot.R index 408d536d..e7815a9b 100644 --- a/R/plot.R +++ b/R/plot.R @@ -75,6 +75,7 @@ plot.sfnetwork = function(x, draw_lines = TRUE, } if (! is.null(edge_geoms)) { edge_args = c(edge_args, dots) + if (is.null(edge_args$extent)) edge_args$extent = node_geoms do.call(plot, c(list(edge_geoms), edge_args)) } # Plot the nodes. From eb8b854262f514d59f97b9b11b925acf549328c8 Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Mon, 26 Aug 2024 18:57:14 +0200 Subject: [PATCH 078/246] feat: Support sparse matrices from Matrix pkg in create_from_spatial_points :gift: --- R/create.R | 18 +++++++++++------- man/create_from_spatial_points.Rd | 12 +++++++----- 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/R/create.R b/R/create.R index 77212734..ad96236e 100644 --- a/R/create.R +++ b/R/create.R @@ -513,11 +513,13 @@ create_from_spatial_lines = function(x, directed = TRUE, #' are first converted into logical matrices using \code{\link{as.logical}}, #' whenever possible. #' -#' The provided adjacency matrix may also be 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. +#' The provided adjacency matrix may also be sparse. This can be an object of +#' one of the sparse matrix classes from the \code{\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 @@ -657,14 +659,16 @@ create_from_spatial_points = function(x, connections = "complete", 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 list-formatted", - "sparse matrix, or a single character." + "Connections should be specified as a matrix, a sparse matrix,", + "or a single character." ) )) } diff --git a/man/create_from_spatial_points.Rd b/man/create_from_spatial_points.Rd index 49e8cf3e..d936b8b3 100644 --- a/man/create_from_spatial_points.Rd +++ b/man/create_from_spatial_points.Rd @@ -62,11 +62,13 @@ 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 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. +The provided adjacency matrix may also be sparse. This can be an object of +one of the sparse matrix classes from the \code{\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 From 98a661a3aae9aefd45725404a7acd9c8b81f28c9 Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Tue, 27 Aug 2024 09:56:55 +0200 Subject: [PATCH 079/246] refactor: Reorganize index extraction functions :construction: --- R/blend.R | 4 +- R/checks.R | 18 ++-- R/data.R | 44 +--------- R/edge.R | 232 ++------------------------------------------------- R/geom.R | 75 +++++++++++++++++ R/ids.R | 119 ++++++++++++++++++++++++++ R/morphers.R | 8 +- R/plot.R | 2 +- R/sf.R | 4 +- man/ids.Rd | 2 +- 10 files changed, 221 insertions(+), 287 deletions(-) create mode 100644 R/ids.R diff --git a/R/blend.R b/R/blend.R index 6a4c9802..4344bf57 100644 --- a/R/blend.R +++ b/R/blend.R @@ -423,9 +423,9 @@ blend_ = function(x, y, tolerance) { # 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_ids(x) + edge_pts[is_boundary, ]$node_id = edge_incident_ids(x) } else { - edge_pts[is_boundary, ]$node_id = edge_boundary_point_ids(x) + edge_pts[is_boundary, ]$node_id = edge_boundary_ids(x) } # Update this vector of original node indices by: # --> Adding a new, unique node index to each of the split points. diff --git a/R/checks.R b/R/checks.R index cc1392f8..c7d10a39 100644 --- a/R/checks.R +++ b/R/checks.R @@ -252,22 +252,22 @@ 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 boundary nodes. +#' 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) + 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 boundary nodes. - equals = st_equals(boundary_points, boundary_nodes) + # 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 @@ -286,10 +286,10 @@ nodes_in_edge_boundaries = function(x) { #' #' @noRd nodes_equal_edge_boundaries = function(x) { - boundary_points = edge_boundary_points(x) - boundary_nodes = edge_boundary_nodes(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 diff --git a/R/data.R b/R/data.R index 615c1f5f..27e0e0e8 100644 --- a/R/data.R +++ b/R/data.R @@ -32,46 +32,6 @@ edge_data = function(x, focused = TRUE, require_sf = FALSE) { } } -#' Extract the 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 An 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)) - } -} - #' Count the number of nodes or edges in a network #' #' @param x An object of class \code{\link{sfnetwork}}, or any other network @@ -116,8 +76,8 @@ n_edges = function(x, focused = FALSE) { #' #' @param x An object of class \code{\link{sfnetwork}}. #' -#' @param idxs Should the names of the columns storing indices of start and end -#' nodes in the edges table (i.e. the from and to columns) be included? +#' @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}. diff --git a/R/edge.R b/R/edge.R index 1bae0dbe..aea1d9d8 100644 --- a/R/edge.R +++ b/R/edge.R @@ -47,7 +47,7 @@ NULL edge_azimuth = function(degrees = FALSE) { require_active_edges() x = .G() - bounds = edge_boundary_nodes(x, focused = TRUE) + 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 @@ -133,7 +133,7 @@ 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_ids(x, focused = TRUE, 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) } @@ -341,226 +341,6 @@ evaluate_edge_predicate = function(predicate, x, y, ...) { lengths(predicate(E, y, sparse = TRUE, ...)) > 0 } -#' Get the geometries of the boundary nodes of edges in an sfnetwork -#' -#' @param x An object of class \code{\link{sfnetwork}}. -#' -#' @param focused Should only the boundary nodes of edges that are in focus be -#' extracted? 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 If list is \code{FALSE}, 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, ...]. If list is \code{TRUE}, a list with the first element being -#' the start nodes of the edges as object of class \code{\link[sf]{sfc}} with -#' \code{POINT} geometries, and the second element being the end nodes of the -#' edges as object of class \code{\link[sf]{sfc}} with \code{POINT} geometries. -#' -#' @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 directed network structure boundary points -#' should be equal to boundary nodes. In a valid undirected network structure -#' boundary points should contain the boundary nodes. -#' -#' @importFrom igraph ends -#' @noRd -edge_boundary_nodes = 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))] - } -} - -#' Get the geometries of the start nodes of edges in an sfnetwork -#' -#' @param x An object of class \code{\link{sfnetwork}}. -#' -#' @param focused Should only the start nodes of edges that are in focus be -#' extracted? Defaults to \code{FALSE}. See \code{\link[tidygraph]{focus}} for -#' more information on focused networks. -#' -#' @return An object of class \code{\link[sf]{sfc}} with \code{POINT} -#' geometries, of length equal to the number of edges in x. -#' -#' @importFrom igraph ends -#' @noRd -edge_start_nodes = function(x, focused = FALSE) { - nodes = pull_node_geom(x) - id_mat = ends(x, edge_ids(x, focused = focused), names = FALSE) - nodes[id_mat[, 1]] -} - -#' Get the geometries of the end nodes of edges in an sfnetwork -#' -#' @param x An object of class \code{\link{sfnetwork}}. -#' -#' @param focused Should only the end nodes of edges that are in focus be -#' extracted? Defaults to \code{FALSE}. See \code{\link[tidygraph]{focus}} for -#' more information on focused networks. -#' -#' @return An object of class \code{\link[sf]{sfc}} with \code{POINT} -#' geometries, of length equal to the number of edges in x. -#' -#' @importFrom igraph ends -#' @noRd -edge_end_nodes = function(x, focused = FALSE) { - nodes = pull_node_geom(x) - id_mat = ends(x, edge_ids(x, focused = focused), names = FALSE) - nodes[id_mat[, 2]] -} - -#' Get the indices of the boundary nodes of edges in an sfnetwork -#' -#' @param x An object of class \code{\link{sfnetwork}}. -#' -#' @param focused Should only the indices of boundary nodes of edges that are -#' in focus be extracted? 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 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 -#' second column contains the indices of the end nodes of the edges. -#' -#' @importFrom igraph ends -#' @noRd -edge_boundary_node_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)) -} - -#' Get the indices of the start nodes of edges in an sfnetwork -#' -#' @param x An object of class \code{\link{sfnetwork}}. -#' -#' @param focused Should only the indices of start nodes of edges that are -#' in focus be extracted? Defaults to \code{FALSE}. See -#' \code{\link[tidygraph]{focus}} for more information on focused networks. -#' -#' @return A numeric vector of length equal to the number of edges in x. -#' -#' @importFrom igraph ends -#' @noRd -edge_start_node_ids = function(x, focused = FALSE, matrix = FALSE) { - ends(x, edge_ids(x, focused = focused), names = FALSE)[, 1] -} - -#' Get the indices of the end nodes of edges in an sfnetwork -#' -#' @param x An object of class \code{\link{sfnetwork}}. -#' -#' @param focused Should only the indices of end nodes of edges that are -#' in focus be extracted? Defaults to \code{FALSE}. See -#' \code{\link[tidygraph]{focus}} for more information on focused networks. -#' -#' @return A numeric vector of length equal to the number of edges in x. -#' -#' @importFrom igraph ends -#' @noRd -edge_end_node_ids = function(x, focused = FALSE, matrix = FALSE) { - ends(x, edge_ids(x, focused = focused), names = FALSE)[, 2] -} - -#' Get the geometries of the boundary points of edges in an sfnetwork -#' -#' @param x An object of class \code{\link{sfnetwork}}. -#' -#' @param focused Should only the boundary points of edges that are in focus be -#' extracted? 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 If list is \code{FALSE}, 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, ...]. If list is \code{TRUE}, a list with the first element being -#' the start points of the edges as object of class \code{\link[sf]{sfc}} with -#' \code{POINT} geometries, and the second element being the end points of the -#' edges as object of class \code{\link[sf]{sfc}} with \code{POINT} geometries. -#' -#' @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 directed network structure boundary points -#' should be equal to boundary nodes. In a valid undirected network structure -#' boundary points should contain the boundary nodes. -#' -#' @noRd -edge_boundary_points = 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 - } -} - -#' Get the node indices of the boundary points of edges in an sfnetwork -#' -#' @param x An object of class \code{\link{sfnetwork}}. -#' -#' @param focused Should only the indices of boundary points of edges that are -#' in focus be extracted? Defaults to \code{FALSE}. See -#' \code{\link[tidygraph]{focus}} for more informatio -#' -#' @param matrix Should te result be returned as a two-column matrix? Defaults -#' to \code{FALSE}. -#' -#' @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. -#' -#' @importFrom sf st_equals -#' @noRd -edge_boundary_point_ids = function(x, focused = FALSE, matrix = FALSE) { - nodes = pull_node_geom(x) - edges = edges_as_sf(x, focused = focused) - 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) != n_edges(x, focused = focused) * 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 -} - #' Match edge geometries to their boundary node locations #' #' This function makes invalid edges valid by making sure that the boundary @@ -608,7 +388,7 @@ add_invalid_edge_boundaries = function(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_boundary_node_ids(x) + 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: @@ -633,7 +413,7 @@ 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_boundary_nodes(x) + 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) @@ -689,7 +469,7 @@ make_edges_explicit = function(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 draw straight lines between the boundary nodes of edges. - bounds = edge_boundary_nodes(x, list = TRUE) + bounds = edge_incident_geoms(x, list = TRUE) mutate_edge_geom(x, draw_lines(bounds[[1]], bounds[[2]])) } @@ -723,7 +503,7 @@ make_edges_follow_indices = function(x) { 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_start_nodes(x) + 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]) diff --git a/R/geom.R b/R/geom.R index 152d2fa0..18ceff7c 100644 --- a/R/geom.R +++ b/R/geom.R @@ -233,3 +233,78 @@ drop_edge_geom = function(x) { edge_geom_colname(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/ids.R b/R/ids.R new file mode 100644 index 00000000..a3486b4c --- /dev/null +++ b/R/ids.R @@ -0,0 +1,119 @@ +#' Extract the 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 An 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)) + } +} + +#' 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) + 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) != n_edges(x, focused = focused) * 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 +} diff --git a/R/morphers.R b/R/morphers.R index a9cf2b47..46b3a0fd 100644 --- a/R/morphers.R +++ b/R/morphers.R @@ -209,7 +209,7 @@ to_spatial_contracted = function(x, ..., simplify = FALSE, 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_ids(x_new, matrix = TRUE) + bounds = edge_incident_ids(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 @@ -340,7 +340,7 @@ to_spatial_directed = function(x) { 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_ids(x, matrix = TRUE) + idxs = edge_boundary_ids(x, matrix = TRUE) from = idxs[, 1] to = idxs[, 2] # Update the from and to columns of the edges such that: @@ -1099,9 +1099,9 @@ to_spatial_subdivision = function(x) { # 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_ids(x) + node_idxs[is_boundary] = edge_incident_ids(x) } else { - node_idxs[is_boundary] = edge_boundary_point_ids(x) + node_idxs[is_boundary] = edge_boundary_ids(x) } # 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. diff --git a/R/plot.R b/R/plot.R index e7815a9b..58ac7b0f 100644 --- a/R/plot.R +++ b/R/plot.R @@ -70,7 +70,7 @@ plot.sfnetwork = function(x, draw_lines = TRUE, dots = list(...) # Plot the edges. if (draw_lines && is.null(edge_geoms)) { - bids = edge_boundary_node_ids(x, matrix = TRUE) + bids = edge_incident_ids(x, matrix = TRUE) edge_geoms = draw_lines(node_geoms[bids[, 1]], node_geoms[bids[, 2]]) } if (! is.null(edge_geoms)) { diff --git a/R/sf.R b/R/sf.R index 7111727e..a28da2f4 100644 --- a/R/sf.R +++ b/R/sf.R @@ -691,7 +691,7 @@ spatial_clip_edges = function(x, y, ..., .operator = sf::st_intersection) { bound_pts = linestring_boundary_points(e_new) # Retrieve the nodes at the ends of each edge. # According to the from and to indices. - bound_nds = edge_boundary_nodes(x_tmp) + bound_nds = edge_incident_geoms(x_tmp) # Check if linestring boundaries match their corresponding nodes. matches = have_equal_geometries(bound_pts, bound_nds) # For boundary points that do not match their corresponding node: @@ -701,7 +701,7 @@ spatial_clip_edges = function(x, y, ..., .operator = sf::st_intersection) { 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_ids(x_tmp) + idxs = edge_incident_ids(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)] diff --git a/man/ids.Rd b/man/ids.Rd index 26d886b9..35af42cc 100644 --- a/man/ids.Rd +++ b/man/ids.Rd @@ -1,5 +1,5 @@ % Generated by roxygen2: do not edit by hand -% Please edit documentation in R/data.R +% Please edit documentation in R/ids.R \name{ids} \alias{ids} \alias{node_ids} From da80cb168a6d8e155f099361f011d2dc7194f45c Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Tue, 27 Aug 2024 10:04:55 +0200 Subject: [PATCH 080/246] feat: Export internal edge geometry modification functions :gift: --- NAMESPACE | 3 +++ R/edge.R | 23 +++++++++--------- man/make_edges_explicit.Rd | 20 ++++++++++++++++ man/make_edges_follow_indices.Rd | 33 +++++++++++++++++++++++++ man/make_edges_valid.Rd | 41 ++++++++++++++++++++++++++++++++ 5 files changed, 109 insertions(+), 11 deletions(-) create mode 100644 man/make_edges_explicit.Rd create mode 100644 man/make_edges_follow_indices.Rd create mode 100644 man/make_edges_valid.Rd diff --git a/NAMESPACE b/NAMESPACE index d3fb938d..0d0dd2c8 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -86,6 +86,9 @@ export(edge_overlaps) export(edge_touches) export(is.sfnetwork) export(is_sfnetwork) +export(make_edges_explicit) +export(make_edges_follow_indices) +export(make_edges_valid) export(n_edges) export(n_nodes) export(nb_to_sfnetwork) diff --git a/R/edge.R b/R/edge.R index aea1d9d8..c9ea2709 100644 --- a/R/edge.R +++ b/R/edge.R @@ -341,11 +341,12 @@ evaluate_edge_predicate = function(predicate, x, y, ...) { lengths(predicate(E, y, sparse = TRUE, ...)) > 0 } -#' Match edge geometries to their boundary node locations +#' Match edge geometries to their incident node locations #' -#' This function makes invalid edges valid by making sure that the boundary -#' points of their linestring geometry match the geometries of the nodes that -#' are specified through the *from* and *to* indices. +#' 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}}. #' @@ -370,7 +371,7 @@ evaluate_edge_predicate = function(predicate, x, y, ...) { #' reversed before running this function. Use #' \code{\link{make_edges_follow_indices}} for this. #' -#' @noRd +#' @export make_edges_valid = function(x, preserve_geometries = FALSE) { if (preserve_geometries) { add_invalid_edge_boundaries(x) @@ -454,7 +455,7 @@ replace_invalid_edge_boundaries = function(x) { #' #' This function turns spatially implicit networks into spatially explicit #' networks by adding a geometry column to the edges data containing straight -#' lines between the start and end nodes. +#' lines between the source and target nodes. #' #' @param x An object of class \code{\link{sfnetwork}}. #' @@ -462,7 +463,7 @@ replace_invalid_edge_boundaries = function(x) { #' edges. If \code{x} was already spatially explicit it is returned unmodified. #' #' @importFrom sf st_crs st_sfc -#' @noRd +#' @export make_edges_explicit = function(x) { # Return x unmodified if edges are already spatially explicit. if (has_explicit_edges(x)) return(x) @@ -473,7 +474,7 @@ make_edges_explicit = function(x) { mutate_edge_geom(x, draw_lines(bounds[[1]], bounds[[2]])) } -#' Match the direction of edge geometries to their specified boundary nodes +#' 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 @@ -485,7 +486,7 @@ make_edges_explicit = function(x) { #' geometries. #' #' @details In undirected spatial networks it is required that the boundary of -#' edge geometries contain their boundary node geometries. However, it is not +#' 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 @@ -493,11 +494,11 @@ make_edges_explicit = function(x) { #' #' 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 boundary node geometries. In +#' that edge boundary points exactly match their incident node geometries. In #' directed networks, there will be no change. #' #' @importFrom sf st_reverse -#' @noRd +#' @export make_edges_follow_indices = function(x) { # Extract geometries of edges and subsequently their start points. edges = pull_edge_geom(x) diff --git a/man/make_edges_explicit.Rd b/man/make_edges_explicit.Rd new file mode 100644 index 00000000..1d8227f0 --- /dev/null +++ b/man/make_edges_explicit.Rd @@ -0,0 +1,20 @@ +% 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}}.} +} +\value{ +An object of class \code{\link{sfnetwork}} with spatially explicit +edges. If \code{x} was already spatially explicit it is returned unmodified. +} +\description{ +This function turns spatially implicit networks into spatially explicit +networks by adding a geometry column to the edges data containing straight +lines between the source and target nodes. +} 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_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. +} From 99e67a219ad08af5a39ee144557e1560a615ce32 Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Fri, 13 Sep 2024 19:22:12 +0200 Subject: [PATCH 081/246] fix: Use network bbox for plot extent :wrench: --- R/plot.R | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/R/plot.R b/R/plot.R index 58ac7b0f..8dd1954a 100644 --- a/R/plot.R +++ b/R/plot.R @@ -75,7 +75,7 @@ plot.sfnetwork = function(x, draw_lines = TRUE, } if (! is.null(edge_geoms)) { edge_args = c(edge_args, dots) - if (is.null(edge_args$extent)) edge_args$extent = node_geoms + 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. From a5efec3a4804dc7512f6897e26a7ab4e41896bdd Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Fri, 13 Sep 2024 19:22:45 +0200 Subject: [PATCH 082/246] breaking: Rewrite to_spatial_contracted morpher :warning: --- NAMESPACE | 1 - R/morphers.R | 224 ++++++++-------------------------------- man/spatial_morphers.Rd | 12 ++- 3 files changed, 54 insertions(+), 183 deletions(-) diff --git a/NAMESPACE b/NAMESPACE index 0d0dd2c8..08393da1 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -142,7 +142,6 @@ importFrom(dplyr,full_join) importFrom(dplyr,group_by) importFrom(dplyr,group_indices) importFrom(dplyr,group_size) -importFrom(dplyr,group_split) importFrom(dplyr,join_by) importFrom(dplyr,mutate) importFrom(graphics,plot) diff --git a/R/morphers.R b/R/morphers.R index 46b3a0fd..b928e896 100644 --- a/R/morphers.R +++ b/R/morphers.R @@ -66,9 +66,9 @@ 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 -#' geometries are updated accordingly such that the valid spatial network +#' create the groups. The centroid of the group of nodes 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}}. #' @@ -80,52 +80,44 @@ NULL #' \code{TRUE} also removes multiple edges and loop edges that already #' existed before contraction. Defaults to \code{FALSE}. #' -#' @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 +#' @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 +#' @importFrom igraph contract delete_edges delete_vertex_attr is_directed +#' which_loop which_multiple +#' @importFrom sf st_as_sf st_centroid st_combine st_drop_geometry st_geometry #' @importFrom tibble as_tibble #' @importFrom tidygraph as_tbl_graph #' @export to_spatial_contracted = function(x, ..., simplify = FALSE, + compute_centroids = TRUE, summarise_attributes = "ignore", store_original_data = FALSE) { - if (will_assume_projected(x)) raise_assume_projected("to_spatial_contracted") # Retrieve nodes from the network. + # Extract specific information from them. nodes = nodes_as_sf(x) + node_data = st_drop_geometry(nodes) + node_geom = st_geometry(nodes) node_geomcol = 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, ...) + node_data = group_by(node_data, ...) + group_ids = group_indices(node_data) # 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] + if (all(group_size(node_data) == 1)) return(list(contracted = x)) ## =========================== - # STEP III: CONTRACT THE NODES + # STEP II: 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, node_geomcol) # Update the attribute summary instructions. - # During morphing tidygraph add the tidygraph node index column. + # During morphing tidygraph adds 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. @@ -133,10 +125,14 @@ to_spatial_contracted = function(x, ..., simplify = FALSE, summarise_attributes = list(summarise_attributes) } summarise_attributes[".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, all_group_idxs, summarise_attributes)) + x_new = as_tbl_graph(contract(x_tmp, group_ids, summarise_attributes)) ## ====================================================== - # STEP IV: UPDATE THE NODE DATA OF THE CONTRACTED NETWORK + # STEP III: 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. @@ -144,32 +140,31 @@ to_spatial_contracted = function(x, ..., simplify = FALSE, # Extract the nodes from the contracted network. new_nodes = as_tibble(x_new, "nodes", focused = FALSE) # 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)) + # 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) ifelse(length(i) > 1, st_centroid(st_combine(i)), i) + grouped_geoms = split(node_geom, group_ids) + new_node_geom = do.call("c", lapply(grouped_geoms, centroid)) + } else { + new_node_geom = node_geom[!duplicated(group_ids)] } - cnt_node_geoms = do.call("c", lapply(cnt_groups, get_centroid)) - new_node_geoms[cnt_group_idxs] = cnt_node_geoms - new_nodes[node_geomcol] = list(new_node_geoms) + new_nodes[node_geomcol] = list(new_node_geom) # 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) + grouped_data = split(nodes, group_ids) + new_nodes$.orig_data = lapply(grouped_data, drop_index) } # Update the nodes table of the contracted network. new_nodes = st_as_sf(new_nodes, sf_column_name = node_geomcol) node_data(x_new) = new_nodes - # Convert in a sfnetwork. + # Convert to a sfnetwork. x_new = tbg_to_sfn(x_new) ## =============================================================== - # STEP V: RECONNECT THE EDGE GEOMETRIES OF THE CONTRACTED NETWORK + # STEP IV: 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. + # This means the edge geometries of their incident edges 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. @@ -182,141 +177,12 @@ to_spatial_contracted = function(x, ..., simplify = FALSE, x_new = x_new %preserve_all_attrs% x_new } # Secondly we will update the geometries of the remaining affected edges. + # The boundaries of the edges will be replaced by the new node geometries. 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_incident_ids(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 + if (! is_directed(x)) { + x_new = make_edges_follow_indices(x_new) } - # 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_data(x_new) = new_edges + x_new = make_edges_valid(x_new) } # Return in a list. list( diff --git a/man/spatial_morphers.Rd b/man/spatial_morphers.Rd index 13863fe6..fc35677a 100644 --- a/man/spatial_morphers.Rd +++ b/man/spatial_morphers.Rd @@ -18,6 +18,7 @@ to_spatial_contracted( x, ..., simplify = FALSE, + compute_centroids = TRUE, summarise_attributes = "ignore", store_original_data = FALSE ) @@ -66,6 +67,11 @@ 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{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{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 @@ -140,9 +146,9 @@ of \code{\link[tidygraph]{morph}} for the requirements for custom morphers. \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 -geometries are updated accordingly such that the valid spatial network +create the groups. The centroid of the group of nodes 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}}. From da37cb13dbbfb03b7bbf2d8a9dfccdbe8c3a15c3 Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Fri, 13 Sep 2024 19:25:53 +0200 Subject: [PATCH 083/246] breaking: Change default of simplify in to_spatial_contracted to TRUE :warning: --- R/morphers.R | 16 ++++++++-------- man/spatial_morphers.Rd | 16 ++++++++-------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/R/morphers.R b/R/morphers.R index b928e896..866a60f8 100644 --- a/R/morphers.R +++ b/R/morphers.R @@ -72,13 +72,13 @@ NULL #' 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 @@ -92,7 +92,7 @@ NULL #' @importFrom tibble as_tibble #' @importFrom tidygraph as_tbl_graph #' @export -to_spatial_contracted = function(x, ..., simplify = FALSE, +to_spatial_contracted = function(x, ..., simplify = TRUE, compute_centroids = TRUE, summarise_attributes = "ignore", store_original_data = FALSE) { diff --git a/man/spatial_morphers.Rd b/man/spatial_morphers.Rd index fc35677a..d91e82af 100644 --- a/man/spatial_morphers.Rd +++ b/man/spatial_morphers.Rd @@ -17,7 +17,7 @@ to_spatial_contracted( x, ..., - simplify = FALSE, + simplify = TRUE, compute_centroids = TRUE, summarise_attributes = "ignore", store_original_data = FALSE @@ -59,13 +59,13 @@ 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{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 From ccfa9b6e25684b7b7e816b5065ef9ad47a91b1f4 Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Fri, 13 Sep 2024 20:01:22 +0200 Subject: [PATCH 084/246] fix: Preserve CRS when computing centroid in to_spatial_contracted :wrench: --- R/morphers.R | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/R/morphers.R b/R/morphers.R index 866a60f8..259477b8 100644 --- a/R/morphers.R +++ b/R/morphers.R @@ -143,7 +143,7 @@ to_spatial_contracted = function(x, ..., simplify = TRUE, # 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) ifelse(length(i) > 1, st_centroid(st_combine(i)), i) + centroid = function(i) if (length(i) > 1) st_centroid(st_combine(i)) else i grouped_geoms = split(node_geom, group_ids) new_node_geom = do.call("c", lapply(grouped_geoms, centroid)) } else { From 36bf90bde02fd8b0ab3f9a2ff32e9d1929ebfda6 Mon Sep 17 00:00:00 2001 From: loreabad6 Date: Sat, 14 Sep 2024 13:18:32 +0200 Subject: [PATCH 085/246] repo: Update contributing guide :package: --- CONTRIBUTING.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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: From 160296433b89bdb7ee08a0ca594d155eea751e5f Mon Sep 17 00:00:00 2001 From: loreabad6 Date: Sat, 14 Sep 2024 13:54:16 +0200 Subject: [PATCH 086/246] cicd: Update pkgdown action :robot: --- .github/workflows/pkgdown.yaml | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) 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 From 0818aef819ea42eb5accf2d395f05a1cdefe17f3 Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Sat, 14 Sep 2024 14:12:04 +0200 Subject: [PATCH 087/246] feat: Update spatial clip functions to work with undirected networks :gift: --- R/sf.R | 58 ++++++++++------------------------------------------------ 1 file changed, 10 insertions(+), 48 deletions(-) diff --git a/R/sf.R b/R/sf.R index a28da2f4..ad4f3cf2 100644 --- a/R/sf.R +++ b/R/sf.R @@ -617,26 +617,16 @@ spatial_clip_nodes = function(x, y, ..., .operator = sf::st_intersection) { delete_vertices(x, drop) %preserve_all_attrs% x } -#' @importFrom cli cli_warn -#' @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) { + # 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) { - cli_warn( - "Clipping edges does not give correct results in undirected networks" - ) - } - x_sf = edges_as_sf(x) - y_sf = st_geometry(y) - ## =========================== - # 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. - e_new = .operator(x_sf, y_sf, ...) + 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. @@ -673,40 +663,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 = linestring_boundary_points(e_new) - # Retrieve the nodes at the ends of each edge. - # According to the from and to indices. - bound_nds = edge_incident_geoms(x_tmp) - # Check if linestring boundaries match their corresponding nodes. - matches = have_equal_geometries(bound_pts, bound_nds) - # 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_incident_ids(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, preserve_geometries = TRUE) } find_indices_to_drop = function(x, y, ..., .operator = sf::st_filter) { From 58066c14aa0a09a3663a06dd0b7671fef6be391d Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Sat, 14 Sep 2024 14:19:50 +0200 Subject: [PATCH 088/246] feat: Add new morpher to_spatial_unique. Refs #131 :gift: --- NAMESPACE | 1 + R/morphers.R | 56 ++++++++++++++++++++++++++++++++++++++++- man/spatial_morphers.Rd | 11 ++++++++ 3 files changed, 67 insertions(+), 1 deletion(-) diff --git a/NAMESPACE b/NAMESPACE index 08393da1..f9102c27 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -131,6 +131,7 @@ export(to_spatial_smooth) export(to_spatial_subdivision) export(to_spatial_subset) export(to_spatial_transformed) +export(to_spatial_unique) export(validate_network) importFrom(cli,cli_abort) importFrom(cli,cli_alert) diff --git a/R/morphers.R b/R/morphers.R index 259477b8..d01e78d0 100644 --- a/R/morphers.R +++ b/R/morphers.R @@ -84,7 +84,7 @@ NULL #' 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 #' @importFrom igraph contract delete_edges delete_vertex_attr is_directed #' which_loop which_multiple @@ -1045,3 +1045,57 @@ 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}}. +#' +#' @importFrom igraph contract delete_vertex_attr +#' @importFrom sf st_as_sf st_geometry +#' @importFrom tibble as_tibble +#' @importFrom tidygraph as_tbl_graph +#' @export +to_spatial_unique = function(x, summarise_attributes = "ignore", + store_original_data = FALSE) { + # Retrieve nodes from the network. + # Extract specific information from them. + nodes = nodes_as_sf(x) + node_geoms = st_geometry(nodes) + node_geomcol = attr(nodes, "sf_column") + # Define which nodes have equal geometries. + matches = st_match(node_geoms) + # Update the attribute summary instructions. + # During morphing tidygraph adds 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" + # 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, matches, summarise_attributes)) + # Extract the nodes from the contracted network. + new_nodes = as_tibble(x_new, "nodes", focused = FALSE) + # Add geometries to the new nodes. + # These are simply the original node geometries with duplicates removed. + new_node_geoms = node_geoms[!duplicated(matches)] + new_nodes[node_geomcol] = 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 } + grouped_data = split(nodes, matches) + new_nodes$.orig_data = lapply(grouped_data, drop_index) + } + # Update the nodes table of the contracted network. + new_nodes = st_as_sf(new_nodes, sf_column_name = node_geomcol) + node_data(x_new) = new_nodes + # Return new network as sfnetwork object in a list. + list( + unique = tbg_to_sfn(x_new %preserve_network_attrs% x) + ) +} diff --git a/man/spatial_morphers.Rd b/man/spatial_morphers.Rd index d91e82af..11ab537a 100644 --- a/man/spatial_morphers.Rd +++ b/man/spatial_morphers.Rd @@ -12,6 +12,7 @@ \alias{to_spatial_subdivision} \alias{to_spatial_subset} \alias{to_spatial_transformed} +\alias{to_spatial_unique} \title{Morph spatial networks into a different structure} \usage{ to_spatial_contracted( @@ -52,6 +53,12 @@ to_spatial_subdivision(x) to_spatial_subset(x, ..., subset_by = NULL) to_spatial_transformed(x, ...) + +to_spatial_unique( + x, + summarise_attributes = "ignore", + store_original_data = FALSE +) } \arguments{ \item{x}{An object of class \code{\link{sfnetwork}}.} @@ -230,6 +237,10 @@ 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}}. + }} \examples{ library(sf, quietly = TRUE) From 61c0b4b865e1437c2e18114da12c46bbbf1a61d6 Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Sat, 14 Sep 2024 14:28:44 +0200 Subject: [PATCH 089/246] refactor: Simplify implementation of st_reverse method :construction: --- R/sf.R | 5 ----- 1 file changed, 5 deletions(-) diff --git a/R/sf.R b/R/sf.R index ad4f3cf2..b4cfb6b3 100644 --- a/R/sf.R +++ b/R/sf.R @@ -342,12 +342,7 @@ st_agr.sfnetwork = function(x, active = NULL, ...) { st_reverse.sfnetwork = function(x, ...) { active = attr(x, "active") if (active == "edges") { - require_explicit_edges(x) if (is_directed(x)) { - cli_warn(paste( - "{.fn st_reverse} swaps {.field from} and {.field to} columns", - "in directed networks." - )) x = reverse_edges(x, eids = edge_ids(x)) %preserve_all_attrs% x } } else { From 0c8b24b04d1a787ea9d6872173d69500422ba3d0 Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Sat, 14 Sep 2024 14:45:35 +0200 Subject: [PATCH 090/246] feat: Add a method for st_segmentize :gift: --- NAMESPACE | 2 ++ R/sf.R | 25 +++++++++++++++++++++++++ man/sf.Rd | 3 +++ 3 files changed, 30 insertions(+) diff --git a/NAMESPACE b/NAMESPACE index f9102c27..6aa53365 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -50,6 +50,7 @@ S3method(st_normalize,sfnetwork) S3method(st_precision,sfnetwork) 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) @@ -253,6 +254,7 @@ importFrom(sf,st_overlaps) importFrom(sf,st_precision) importFrom(sf,st_reverse) importFrom(sf,st_sample) +importFrom(sf,st_segmentize) importFrom(sf,st_set_precision) importFrom(sf,st_sf) importFrom(sf,st_sfc) diff --git a/R/sf.R b/R/sf.R index b4cfb6b3..68eff6ad 100644 --- a/R/sf.R +++ b/R/sf.R @@ -354,6 +354,31 @@ st_reverse.sfnetwork = function(x, ...) { geom_unary_ops(st_reverse, x, active,...) } +#' @name sf +#' @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 #' @importFrom sf st_simplify #' @export diff --git a/man/sf.Rd b/man/sf.Rd index 22259186..32b4f380 100644 --- a/man/sf.Rd +++ b/man/sf.Rd @@ -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} @@ -88,6 +89,8 @@ \method{st_reverse}{sfnetwork}(x, ...) +\method{st_segmentize}{sfnetwork}(x, ...) + \method{st_simplify}{sfnetwork}(x, ...) \method{st_join}{sfnetwork}(x, y, ...) From bbd31675ca406484fc16e29d98a1b8a658caca72 Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Sat, 14 Sep 2024 16:41:47 +0200 Subject: [PATCH 091/246] feat: Allow more flexibility in geometry replacements :gift: --- R/sf.R | 45 +++++++++++++++++++++++++++++++++++++++------ 1 file changed, 39 insertions(+), 6 deletions(-) diff --git a/R/sf.R b/R/sf.R index 68eff6ad..7bbb3371 100644 --- a/R/sf.R +++ b/R/sf.R @@ -106,16 +106,49 @@ st_geometry.sfnetwork = function(obj, active = NULL, focused = TRUE, ...) { } #' @name sf +#' @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, focused = TRUE) - validate_network(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) } - x_new } #' @name sf From 466b5fae793064e1199cfdb522205520ba5e3012 Mon Sep 17 00:00:00 2001 From: loreabad6 Date: Sat, 14 Sep 2024 17:07:29 +0200 Subject: [PATCH 092/246] deps: Add quarto to suggest and vignette builder :couple: --- DESCRIPTION | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/DESCRIPTION b/DESCRIPTION index b542f018..f2e8181d 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -54,6 +54,7 @@ Suggests: ggplot2 (>= 3.0.0), knitr, purrr, + quarto, rmarkdown, s2 (>= 1.0.1), spatstat.geom, @@ -61,7 +62,8 @@ Suggests: testthat, TSP VignetteBuilder: - knitr + knitr, + quarto ByteCompile: true Encoding: UTF-8 LazyData: true From 715f3195c4c48a5ee0a50ba4758824a517cd5465 Mon Sep 17 00:00:00 2001 From: loreabad6 Date: Sat, 14 Sep 2024 17:08:20 +0200 Subject: [PATCH 093/246] docs: Convert vignettes to quarto :books: --- .Rbuildignore | 1 + vignettes/.gitignore | 2 + ...fn01_structure.Rmd => sfn01_structure.qmd} | 161 ++++++----- ...s_clean.Rmd => sfn02_preprocess_clean.qmd} | 147 ++++++----- ..._join_filter.Rmd => sfn03_join_filter.qmd} | 150 +++++++---- .../{sfn04_routing.Rmd => sfn04_routing.qmd} | 218 ++++++++------- ...{sfn05_morphers.Rmd => sfn05_morphers.qmd} | 249 ++++++++++-------- 7 files changed, 552 insertions(+), 376 deletions(-) create mode 100644 vignettes/.gitignore rename vignettes/{sfn01_structure.Rmd => sfn01_structure.qmd} (85%) rename vignettes/{sfn02_preprocess_clean.Rmd => sfn02_preprocess_clean.qmd} (93%) rename vignettes/{sfn03_join_filter.Rmd => sfn03_join_filter.qmd} (91%) rename vignettes/{sfn04_routing.Rmd => sfn04_routing.qmd} (89%) rename vignettes/{sfn05_morphers.Rmd => sfn05_morphers.qmd} (86%) diff --git a/.Rbuildignore b/.Rbuildignore index 68067501..ef743ca0 100644 --- a/.Rbuildignore +++ b/.Rbuildignore @@ -17,3 +17,4 @@ ^hexlogo\.R$ ^\.todo$ ^vignettes/.*\.html$ +^vignettes/*_files$ 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/sfn01_structure.Rmd b/vignettes/sfn01_structure.qmd similarity index 85% rename from vignettes/sfn01_structure.Rmd rename to vignettes/sfn01_structure.qmd index 8b6c3647..50716d0f 100644 --- a/vignettes/sfn01_structure.Rmd +++ b/vignettes/sfn01_structure.qmd @@ -1,25 +1,33 @@ --- title: "1. The sfnetwork data structure" date: "`r Sys.Date()`" -output: rmarkdown::html_vignette vignette: > %\VignetteIndexEntry{1. The sfnetwork data structure} - %\VignetteEngine{knitr::rmarkdown} + %\VignetteEngine{quarto:html} %\VignetteEncoding{UTF-8} +format: + html: + toc: true +knitr: + opts_chunk: + collapse: true + comment: '#>' + opts_knit: + global.par: true --- -```{r setup, include=FALSE} -knitr::opts_chunk$set( - collapse = TRUE, - comment = "#>" -) -knitr::opts_knit$set(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 plot, echo=FALSE, results='asis'} +```{r} +#| label: plot +#| echo: false +#| results: asis # plot margins oldpar = par(no.readonly = TRUE) par(mar = c(1, 1, 1, 1)) @@ -34,11 +42,11 @@ old_hooks = fansi::set_knit_hooks( ) ``` -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. +The core of the sfnetworks package is the sfnetwork data structure. It inherits the tbl_graph class from the `{tidygraph}` package, which itself inherits the igraph class from the `{igraph}` . 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. +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 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. -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} +```{r} +#| message: false library(sfnetworks) library(sf) library(tidygraph) @@ -103,20 +111,25 @@ 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%'} +```{r} +#| layout-ncol: 2 +#| fig-cap: +#| - "Original geometries" +#| - "Straight lines" 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") +plot(net, cex = 2, lwd = 2) +plot(other_net, cex = 2, lwd = 2) ``` 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} +```{r} +#| error: true st_geometry(edges) = st_sfc(c(l2, l3, l1), crs = 4326) net = sfnetwork(nodes, edges) @@ -132,23 +145,25 @@ It works as follows: the provided lines form the edges of the network, and nodes 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} +```{r} +#| fig-width: 5 +#| fig-height: 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. +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") %>% +net |> + activate("edges") |> + mutate(weight = edge_length()) |> + activate("nodes") |> mutate(bc = centrality_betweenness(weights = weight, directed = FALSE)) ``` @@ -161,8 +176,8 @@ Neither all sf functions nor all tidyverse verbs can be directly applied to a sf 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") %>% +net |> + activate("nodes") |> st_as_sf() ``` @@ -176,31 +191,37 @@ st_as_sf(net, "edges") 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} +```{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") +```{r} +#| message: false +#| fig-width: 5 +#| fig-height: 5 +#| fig-cap: "Road network of MΓΌnster Roxel" +autoplot(net) ``` 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") %>% +```{r} +#| fig-width: 5 +#| fig-height: 5 +#| fig-cap: "Betweenness centrality in MΓΌnster Roxel" +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") + geom_sf(data = st_as_sf(net, "nodes"), aes(col = bc, size = bc)) ``` -*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 @@ -208,8 +229,8 @@ ggplot() + 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") %>% +net |> + activate("nodes") |> st_geometry() ``` @@ -217,23 +238,28 @@ Geometries can be replaced using either `st_geometry(x) = value` or the pipe-fri 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") +```{r} +#| layout-ncol: 2 +#| fig-cap: +#| - "Edges without geometries" +#| - "Nodes without geometries" +net |> + activate("edges") |> + st_set_geometry(NULL) |> + plot(draw_lines = FALSE) + +net |> + activate("nodes") |> + st_set_geometry(NULL) |> + plot(vertex.color = "black") ``` 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") %>% +```{r} +#| eval: !expr geos37 +as_sfnetwork(roxel, directed = TRUE) |> + activate("edges") |> st_reverse() ``` @@ -242,8 +268,8 @@ as_sfnetwork(roxel, directed = TRUE) %>% 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") %>% +node_coords = net |> + activate("nodes") |> st_coordinates() node_coords[1:4, ] @@ -264,8 +290,8 @@ 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") %>% +net |> + st_zm(drop = FALSE, what = "Z") |> mutate(X = node_X(), Y = node_Y(), Z = node_Z(), M = node_M()) ``` @@ -294,8 +320,8 @@ 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) %>% +net |> + st_set_precision(1) |> st_precision() ``` @@ -304,14 +330,18 @@ net %>% 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") %>% +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%"} +```{r} +#| layout-ncol: 2 +#| fig-cap: +#| - "Element bounding boxes" +#| - "Network bounding box" node1 = st_point(c(8, 51)) node2 = st_point(c(7, 51.5)) node3 = st_point(c(8, 52)) @@ -329,10 +359,10 @@ 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(small_net, lwd = 2, cex = 4) 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(small_net, lwd = 2, cex = 4) plot(net_bbox, border = "red", lty = 2, lwd = 4, add = TRUE) ``` @@ -343,15 +373,16 @@ In sf objects there is the possibility to store information about how attributes 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")) %>% +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} +```{r} +#| include: false par(oldpar) options(oldoptions) ``` diff --git a/vignettes/sfn02_preprocess_clean.Rmd b/vignettes/sfn02_preprocess_clean.qmd similarity index 93% rename from vignettes/sfn02_preprocess_clean.Rmd rename to vignettes/sfn02_preprocess_clean.qmd index ea14920f..13240ff2 100644 --- a/vignettes/sfn02_preprocess_clean.Rmd +++ b/vignettes/sfn02_preprocess_clean.qmd @@ -1,25 +1,35 @@ --- 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} + %\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 setup, include=FALSE} -knitr::opts_chunk$set( - collapse = TRUE, - comment = "#>" -) -knitr::opts_knit$set(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 plot, echo=FALSE, results='asis'} +```{r} +#| label: plot +#| echo: false +#| results: asis # plot margins oldpar = par(no.readonly = TRUE) par(mar = c(1, 1, 1, 1)) @@ -36,7 +46,8 @@ old_hooks = fansi::set_knit_hooks( 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} +```{r} +#| message: false library(sfnetworks) library(sf) library(tidygraph) @@ -68,8 +79,8 @@ as_sfnetwork(edges) ```{r} # Round coordinates to 0 digits. -st_geometry(edges) = st_geometry(edges) %>% - lapply(function(x) round(x, 0)) %>% +st_geometry(edges) = st_geometry(edges) |> + lapply(function(x) round(x, 0)) |> st_sfc(crs = st_crs(edges)) # The edges are connected. @@ -85,7 +96,8 @@ Unfortunately, neither `igraph` nor `tidygraph` provides an interface for such n 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} +```{r} +#| eval: !expr geos37 p1 = st_point(c(7, 51)) p2 = st_point(c(7, 52)) p3 = st_point(c(8, 52)) @@ -112,7 +124,9 @@ The `sfnetworks` package contains a set of spatial network specific cleaning fun Before presenting the cleaning functions that are currently implemented, lets create a network to be cleaned. -```{r, fig.height=5, fig.width=5} +```{r} +#| fig-width: 5 +#| fig-height: 5 p1 = st_point(c(0, 1)) p2 = st_point(c(1, 1)) p3 = st_point(c(2, 1)) @@ -164,10 +178,11 @@ A network may contain sets of edges that connect the same pair of nodes. Such ed 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()) %>% +```{r} +#| layout-ncol: 2 +simple = net |> + activate("edges") |> + filter(!edge_is_multiple()) |> filter(!edge_is_loop()) plot(st_geometry(net, "edges"), col = edge_colors(net), lwd = 4) @@ -178,11 +193,12 @@ 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()) %>% +```{r} +#| layout-ncol: 2 +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) @@ -204,24 +220,24 @@ The analogue of this in tidyverse terms is a `dplyr::group_by()` operation follo 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%'} +```{r} # 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()) %>% +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)) %>% +net |> + activate("edges") |> + filter(edge_is_between(6, 7)) |> st_as_sf() # Define how we want to combine the attributes. # For the flows: @@ -240,10 +256,14 @@ combinations = list( 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)) %>% +simple |> + activate("edges") |> + filter(edge_is_between(6, 7)) |> st_as_sf() +``` + +```{r} +#| layout-ncol: 2 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) @@ -273,7 +293,8 @@ Note that an edge is *not* subdivided when it crosses another edge at a location For our example network, this means: -```{r, fig.show='hold', out.width = '50%'} +```{r} +#| layout-ncol: 2 subdivision = convert(simple, to_spatial_subdivision) plot(st_geometry(simple, "edges"), col = edge_colors(simple), lwd = 4) @@ -289,7 +310,8 @@ In graph theory terms this process is the opposite of subdivision and also calle 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%'} +```{r} +#| layout-ncol: 2 smoothed = convert(subdivision, to_spatial_smooth) plot(st_geometry(subdivision, "edges"), col = edge_colors(subdivision), lwd = 4) @@ -302,9 +324,9 @@ There are different ways in which the smoothing process can be tuned. Firstly, i ```{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)) %>% +subdivision |> + activate("edges") |> + filter(edge_is_incident(2) | edge_is_incident(9)) |> st_as_sf() # Define how we want to combine the attributes. @@ -322,16 +344,18 @@ combinations = list( 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)) %>% +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%'} +```{r} +#| layout-ncol: 2 +#| message: false other_smoothed = convert(subdivision, to_spatial_smooth, require_equal = "type") plot(st_geometry(subdivision, "edges"), col = edge_colors(subdivision), lwd = 4) @@ -341,7 +365,9 @@ 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%'} +```{r} +#| layout-ncol: 2 +#| message: false other_smoothed = convert(subdivision, to_spatial_smooth, protect = 2) plot(st_geometry(subdivision, "edges"), col = edge_colors(subdivision), lwd = 4) @@ -362,8 +388,8 @@ Grouping variables are internally forwarded to `dplyr::group_by()`. That means y ```{r} # Retrieve the coordinates of the nodes. -node_coords = smoothed %>% - activate("nodes") %>% +node_coords = smoothed |> + activate("nodes") |> st_coordinates() # Cluster the nodes with the DBSCAN spatial clustering algorithm. @@ -374,15 +400,15 @@ node_coords = smoothed %>% clusters = dbscan(node_coords, eps = 0.5, minPts = 1)$cluster # Add the cluster information to the nodes of the network. -clustered = smoothed %>% - activate("nodes") %>% +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 %>% +clustered = clustered |> mutate(cmp = group_components()) select(clustered, cls, cmp) @@ -392,7 +418,8 @@ The combination of the cluster index and the component index can now be used to 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%'} +```{r} +#| layout-ncol: 2 contracted = convert( clustered, to_spatial_contracted, @@ -412,8 +439,8 @@ An example: ```{r} # Add some additional attribute columns to the nodes table. -clustered = clustered %>% - activate("nodes") %>% +clustered = clustered |> + activate("nodes") |> mutate(is_priority = sample( c(TRUE, FALSE), vcount(clustered), @@ -422,9 +449,9 @@ clustered = clustered %>% # 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) %>% +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: @@ -446,9 +473,9 @@ contracted = convert( ) # Inspect our contracted group of nodes. -contracted %>% - activate("nodes") %>% - slice(4) %>% +contracted |> + activate("nodes") |> + slice(4) |> st_as_sf() ``` @@ -456,14 +483,16 @@ contracted %>% 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%'} +```{r} +#| layout-ncol: 2 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} +```{r} +#| include: false par(oldpar) options(oldoptions) ``` diff --git a/vignettes/sfn03_join_filter.Rmd b/vignettes/sfn03_join_filter.qmd similarity index 91% rename from vignettes/sfn03_join_filter.Rmd rename to vignettes/sfn03_join_filter.qmd index b50e9c91..1fefc6df 100644 --- a/vignettes/sfn03_join_filter.Rmd +++ b/vignettes/sfn03_join_filter.qmd @@ -1,22 +1,33 @@ --- title: "3. Spatial joins and filters" date: "`r Sys.Date()`" -output: rmarkdown::html_vignette vignette: > %\VignetteIndexEntry{3. Spatial joins and filters} - %\VignetteEngine{knitr::rmarkdown} + %\VignetteEngine{quarto:html} %\VignetteEncoding{UTF-8} +format: + html: + toc: true +knitr: + opts_chunk: + collapse: true + comment: '#>' + opts_knit: + global.par: true --- -```{r setup, include=FALSE} -knitr::opts_chunk$set( - collapse = TRUE, - comment = "#>" -) -knitr::opts_knit$set(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 plot, echo=FALSE, results='asis'} +```{r} +#| label: plot +#| echo: false +#| results: asis # plot margins oldpar = par(no.readonly = TRUE) par(mar = c(1, 1, 1, 1)) @@ -31,11 +42,13 @@ old_hooks = fansi::set_knit_hooks( ) ``` + 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} +```{r} +#| message: false library(sfnetworks) library(sf) library(tidygraph) @@ -53,17 +66,18 @@ When applying `sf::st_filter()` to a sfnetwork, it is internally applied to the 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%'} +```{r} +#| layout-ncol: 2 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") %>% +poly = st_multipoint(c(p1, p2, p3, p4)) |> + st_cast("POLYGON") |> st_sfc(crs = 3035) -net = as_sfnetwork(roxel) %>% +net = as_sfnetwork(roxel) |> st_transform(3035) filtered = st_filter(net, poly, .pred = st_intersects) @@ -74,9 +88,10 @@ plot(net, col = "grey") plot(filtered, add = TRUE) ``` -```{r, fig.show='hold', out.width = '50%'} -filtered = net %>% - activate("edges") %>% +```{r} +#| layout-ncol: 2 +filtered = net |> + activate("edges") |> st_filter(poly, .pred = st_intersects) plot(net, col = "grey") @@ -87,11 +102,12 @@ 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") %>% +```{r} +#| layout-ncol: 2 +filtered = net |> + activate("edges") |> + st_filter(poly, .pred = st_intersects) |> + activate("nodes") |> filter(!node_is_isolated()) plot(net, col = "grey") @@ -102,11 +118,12 @@ plot(filtered, add = TRUE) Filtering can also be done with other predicates. -```{r, fig.show='hold', out.width = '50%'} +```{r} +#| layout-ncol: 2 point = st_centroid(st_combine(net)) -filtered = net %>% - activate("nodes") %>% +filtered = net |> + activate("nodes") |> st_filter(point, .predicate = st_is_within_distance, dist = 500) plot(net, col = "grey") @@ -123,11 +140,12 @@ In `tidygraph`, filtering information from networks is done by using specific no 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") %>% +```{r} +#| layout-ncol: 2 +filtered = net |> + activate("edges") |> + filter(edge_intersects(poly)) |> + activate("nodes") |> filter(!node_is_isolated()) plot(net, col = "grey") @@ -141,26 +159,27 @@ A nice application of this in road networks is to find underpassing and overpass 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") %>% +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 %>% +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%'} +```{r} +#| layout-ncol: 2 v = 4152000 l = st_linestring(rbind(c(v, st_bbox(net)["ymin"]), c(v, st_bbox(net)["ymax"]))) -filtered_by_coords = net %>% - activate("nodes") %>% +filtered_by_coords = net |> + activate("nodes") |> filter(node_X() > v) plot(net, col = "grey") @@ -175,11 +194,12 @@ Filtering returns a subset of the original geometries, but leaves those geometri 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") %>% +```{r} +#| layout-ncol: 2 +clipped = net |> + activate("edges") |> + st_intersection(poly) |> + activate("nodes") |> filter(!node_is_isolated()) plot(net, col = "grey") @@ -199,14 +219,18 @@ When applying `sf::st_join()` to a sfnetwork, it is internally applied to the ac 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() %>% +```{r} +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 +``` + +```{r} +#| layout-ncol: 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, cex = 2) @@ -219,7 +243,7 @@ In the example above, the polygons are spatially distinct. Hence, each node can 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)) %>% +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. @@ -227,8 +251,8 @@ two_equal_polys = st_as_sf(c(poly, poly)) %>% 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") %>% +net |> + activate("edges") |> st_join(two_equal_polys, join = st_intersects) ``` @@ -238,7 +262,8 @@ For non-spatial joins based on attribute columns, simply use a join function fro 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%'} +```{r} +#| layout-ncol: 2 # Create a network. node1 = st_point(c(0, 0)) node2 = st_point(c(1, 0)) @@ -248,14 +273,14 @@ 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)) %>% + 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 %>% +snapped_pois = pois |> st_set_geometry(st_geometry(net)[nearest_nodes]) # Plot. @@ -285,9 +310,13 @@ In the example above, it makes sense to include the information from the first P 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%'} +```{r} blended = st_network_blend(net, pois) blended +``` + +```{r} +#| layout-ncol: 2 plot_connections = function(pois) { for (i in seq_len(nrow(pois))) { connection = st_nearest_points(pois[i, ], activate(net, "edges")) @@ -303,9 +332,10 @@ 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%'} +```{r} +#| layout-ncol: 2 pois = data.frame(poi_type = c("bakery", "butcher", "bar"), - x = c(0, 0.6, 0.4), y = c(0.2, 0.2, 0.3)) %>% + 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) @@ -347,7 +377,8 @@ 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%'} +```{r} +#| layout-ncol: 2 net = as_sfnetwork(l1) p = st_intersection(l1, l2) @@ -363,7 +394,7 @@ plot(st_network_blend(net, p, tolerance = 1e-10), lwd = 2, cex = 2, add = TRUE) 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%'} +```{r} node3 = st_point(c(1, 1)) node4 = st_point(c(0, 1)) edge2 = st_sfc(st_linestring(c(node2, node3))) @@ -374,12 +405,17 @@ other_net = as_sfnetwork(c(edge2, edge3)) joined = st_network_join(net, other_net) joined +``` + +```{r} +#| layout-ncol: 2 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} +```{r} +#| include: false par(oldpar) options(oldoptions) ``` diff --git a/vignettes/sfn04_routing.Rmd b/vignettes/sfn04_routing.qmd similarity index 89% rename from vignettes/sfn04_routing.Rmd rename to vignettes/sfn04_routing.qmd index 11aba01f..f8d243d7 100644 --- a/vignettes/sfn04_routing.Rmd +++ b/vignettes/sfn04_routing.qmd @@ -1,24 +1,33 @@ --- title: "4. Routing" date: "`r Sys.Date()`" -output: rmarkdown::html_vignette vignette: > %\VignetteIndexEntry{4. Routing} + %\VignetteEngine{quarto:html} %\VignetteEncoding{UTF-8} - %\VignetteEngine{knitr::rmarkdown} -editor_options: - chunk_output_type: console +format: + html: + toc: true +knitr: + opts_chunk: + collapse: true + comment: '#>' + opts_knit: + global.par: true --- -```{r setup, include=FALSE} -knitr::opts_chunk$set( - collapse = TRUE, - comment = "#>" -) -knitr::opts_knit$set(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 plot, echo=FALSE, results='asis'} +```{r} +#| label: plot +#| echo: false +#| results: asis # plot margins oldpar = par(no.readonly = TRUE) par(mar = c(1, 1, 1, 1)) @@ -37,7 +46,8 @@ Calculating shortest paths between pairs of nodes is a core task in network anal 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} +```{r} +#| message: false library(sfnetworks) library(sf) library(tidygraph) @@ -59,47 +69,53 @@ Lets start with the most basic example of providing node indices as *from* and * 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") %>% +```{r} +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) %>% +paths |> + slice(1) |> + pull(nodes) |> unlist() -paths %>% - slice(1) %>% - pull(edge_paths) %>% +paths |> + slice(1) |> + pull(edges) |> unlist() +``` +```{r} +#| fig-width: 5 +#| fig-height: 5 plot_path = function(node_path) { - net %>% - activate("nodes") %>% - slice(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) %>% +paths |> + pull(nodes) |> walk(plot_path) -net %>% - activate("nodes") %>% - st_as_sf() %>% - slice(c(495, 121, 458)) %>% +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} +```{r} +#| fig-width: 5 +#| fig-height: 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] @@ -109,8 +125,8 @@ 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) %>% +paths |> + pull(nodes) |> walk(plot_path) plot(c(p1, p2, p3), col = colors, pch = 8, cex = 2, lwd = 2, add = TRUE) ``` @@ -119,12 +135,14 @@ Simply finding the nearest node to given points is not always the best way to go 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} +```{r} +#| fig-width: 5 +#| fig-height: 5 # Our network consists of several unconnected components. with_graph(net, graph_component_count()) -connected_net = net %>% - activate("nodes") %>% +connected_net = net |> + activate("nodes") |> filter(group_components() == 1) plot(net, col = colors[2]) @@ -140,7 +158,7 @@ The shortest paths calculation as described above is only supported for one-to-o 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") +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. @@ -149,31 +167,31 @@ If we don't provide any from and to points, `st_network_cost()` will by default # Our network has 701 nodes. with_graph(net, graph_order()) -cost_matrix = st_network_cost(net, weights = "weight") +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) %>% +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) %>% +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) %>% +net |> + convert(to_spatial_directed) |> st_network_cost( from = c(p1, p2, p3), to = c(p1, p2, p3), @@ -193,14 +211,16 @@ The purpose of closest facility analysis is, given a set of destination location 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} +```{r} +#| fig-width: 5 +#| fig-height: 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() %>% +hull = net |> + activate("nodes") |> + st_geometry() |> + st_combine() |> st_convex_hull() sites = st_sample(hull, 50, type = "random") @@ -208,9 +228,9 @@ 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) %>% +new_net = net |> + activate("nodes") |> + filter(group_components() == 1) |> st_network_blend(c(sites, facilities)) # Calculate the cost matrix. @@ -247,9 +267,9 @@ Lets first generate a set of random points within the bounding box of the networ ```{r} set.seed(403) -rdm = net %>% - st_bbox() %>% - st_as_sfc() %>% +rdm = net |> + st_bbox() |> + st_as_sfc() |> st_sample(10, type = "random") ``` @@ -283,7 +303,9 @@ Knowing the optimal order to visit all provided locations, we move back to `sfne 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} +```{r} +#| fig-width: 5 +#| fig-height: 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. @@ -295,7 +317,7 @@ tsp_paths = mapply(st_network_paths, from = from_idxs, to = to_idxs, MoreArgs = list(x = net, weights = "weight") - )["node_paths", ] %>% + )["nodes", ] |> unlist(recursive = FALSE) # Plot the results. @@ -324,11 +346,12 @@ With respect to a given point $p$ and a given travel time $t$, an isochrone is t 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} +```{r} +#| warning: false # How many edge types are there? -types = net %>% - activate("edges") %>% - pull(type) %>% +types = net |> + activate("edges") |> + pull(type) |> unique() types @@ -340,11 +363,11 @@ 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) %>% +net = net |> + activate("edges") |> + group_by(type) |> + mutate(speed = units::set_units(speeds[cur_group_id()], "m/s")) |> + mutate(time = weight / speed) |> ungroup() net @@ -352,20 +375,22 @@ 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} +```{r} +#| fig-width: 5 +#| fig-height: 5 net = activate(net, "nodes") -p = net %>% - st_geometry() %>% - st_combine() %>% +p = net |> + st_geometry() |> + st_combine() |> st_centroid() -iso = net %>% +iso = net |> filter(node_distance_from(st_nearest_feature(p, net), weights = time) <= 600) -iso_poly = iso %>% - st_geometry() %>% - st_combine() %>% +iso_poly = iso |> + st_geometry() |> + st_combine() |> st_convex_hull() plot(net, col = "grey") @@ -376,7 +401,9 @@ 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} +```{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)) @@ -403,14 +430,18 @@ 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)) %>% +```{r} +paths_sf = net |> + activate("edges") |> + slice(unlist(paths$edges)) |> st_as_sf() table(paths_sf$type) +``` +```{r} +#| fig-width: 5 +#| fig-height: 5 plot(paths_sf["type"], lwd = 4, key.pos = 4, key.width = lcm(4)) ``` @@ -430,36 +461,43 @@ weighting_profile = c( unclassified = 1 ) -weighted_net = net %>% - activate("edges") %>% - mutate(multiplier = weighting_profile[type]) %>% +weighted_net = net |> + activate("edges") |> + mutate(multiplier = ifelse(is.na(type), 1, 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%'} +```{r} 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)) %>% +weighted_paths_sf = weighted_net |> + activate("edges") |> + slice(unlist(weighted_paths$edges)) |> st_as_sf() table(weighted_paths_sf$type) +``` +```{r} +#| layout-ncol: 2 +#| fig-cap: +#| - "Distance weights" +#| - "Custom weights" plot(st_as_sf(net, "edges")["type"], lwd = 4, - key.pos = NULL, reset = FALSE, main = "Distance weights") + key.pos = NULL, reset = FALSE) plot(st_geometry(paths_sf), add = TRUE) plot(st_as_sf(net, "edges")["type"], lwd = 4, - key.pos = NULL, reset = FALSE, main = "Custom weights") + key.pos = NULL, reset = FALSE) 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} +```{r} +#| include: false par(oldpar) options(oldoptions) ``` diff --git a/vignettes/sfn05_morphers.Rmd b/vignettes/sfn05_morphers.qmd similarity index 86% rename from vignettes/sfn05_morphers.Rmd rename to vignettes/sfn05_morphers.qmd index 82ad32d3..3380e065 100644 --- a/vignettes/sfn05_morphers.Rmd +++ b/vignettes/sfn05_morphers.qmd @@ -1,22 +1,35 @@ --- title: "5. Spatial morphers" date: "`r Sys.Date()`" -output: rmarkdown::html_vignette vignette: > %\VignetteIndexEntry{5. Spatial morphers} - %\VignetteEngine{knitr::rmarkdown} %\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 setup, include=FALSE} -knitr::opts_chunk$set( - collapse = TRUE, - comment = "#>" -) -knitr::opts_knit$set(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 plot, echo=FALSE, results='asis'} +```{r} +#| label: plot +#| echo: false +#| results: asis # plot margins oldpar = par(no.readonly = TRUE) par(mar = c(1, 1, 1, 1)) @@ -33,7 +46,8 @@ old_hooks = fansi::set_knit_hooks( 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} +```{r} +#| message: false library(sfnetworks) library(sf) library(tidygraph) @@ -50,58 +64,64 @@ In network analysis, community detection algorithms allow you to discover cohesi 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) %>% +net = as_sfnetwork(roxel, directed = FALSE) |> st_transform(3035) -grouped_net = net %>% - morph(to_linegraph) %>% - mutate(group = group_louvain()) %>% +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() %>% +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} +```{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)) %>% +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()) %>% +```{r} +#| layout-ncol: 2 +#| fig-cap: +#| - "Cut nodes" +#| - "Cut edges" +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) %>% +cut_nodes = new_net |> + activate("nodes") |> + filter(is_cut) |> st_geometry() -cut_edges = new_net %>% - activate("edges") %>% - filter(is_cut) %>% +cut_edges = new_net |> + activate("edges") |> + filter(is_cut) |> st_geometry() -plot(net, col = "grey", main = "Cut nodes") +plot(net, col = "grey") plot(cut_nodes, col = "red", pch = 20, cex = 2, add = TRUE) -plot(net, col = "grey", main = "Cut edges") +plot(net, col = "grey") plot(cut_edges, col = "red", lwd = 4, add = TRUE) ``` @@ -151,12 +171,16 @@ The `to_spatial_contracted()` morpher gives the options to summarise attributes 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)) %>% +```{r} +#| layout-ncol: 2 +#| fig-cap: +#| - "Grouped nodes" +#| - "Contracted network" +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( @@ -171,9 +195,9 @@ contracted_net = convert( ) ) -plot(st_geometry(new_net, "edges"), main = "Grouped nodes") +plot(st_geometry(new_net, "edges")) 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_geometry(contracted_net, "edges")) plot( st_as_sf(contracted_net)["louvain"], cex = 2, key.pos = NULL, @@ -186,13 +210,13 @@ plot( 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) %>% +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)) ``` @@ -200,28 +224,34 @@ net %>% 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%'} +```{r} +#| layout-ncol: 2 +#| fig-cap: +#| - "Implicit edges" +#| - "Explicit edges" 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") +plot(implicit_net, draw_lines = FALSE) +plot(explicit_net) ``` ### 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} +```{r} +#| fig-width: 5 +#| fig-height: 5 # Define the origin location. -p = net %>% - st_geometry() %>% - st_combine() %>% +p = net |> + st_geometry() |> + st_combine() |> st_centroid() # Subset neighborhood. -neigh_net = net %>% - activate("edges") %>% +neigh_net = net |> + activate("edges") |> convert(to_spatial_neighborhood, p, threshold = 500, weights = edge_length()) plot(net, col = "grey") @@ -235,8 +265,8 @@ The `to_spatial_shortest_paths()` morpher limits the original network to those n 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") %>% +net |> + activate("edges") |> convert( to_spatial_shortest_paths, from = 1, to = 100, @@ -244,24 +274,26 @@ net %>% ) ``` -```{r, fig.width=5, fig.height=5} -new_net = net %>% - activate("edges") %>% +```{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) %>% + ) |> + mutate(in_paths = TRUE) |> unmorph() -new_net %>% - st_geometry() %>% +new_net |> + st_geometry() |> plot(col = "grey", lwd = 2) -new_net %>% - filter(in_paths) %>% - st_geometry() %>% +new_net |> + filter(in_paths) |> + st_geometry() |> plot(col = "red", lwd = 4, add = TRUE) ``` @@ -271,26 +303,27 @@ The `to_spatial_simple()` morpher removes loop edges from the network and merges 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%'} +```{r} +#| layout-ncol: 2 # 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") %>% +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()) %>% +a_multiple = new_net |> + filter(edge_is_multiple()) |> slice(1) -new_net %>% - filter(edge_is_between(pull(a_multiple, from), pull(a_multiple, to))) %>% +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 %>% +simple_net = new_net |> convert( to_spatial_simple, summarise_attributes = list(flow = "sum", "first") @@ -298,8 +331,8 @@ simple_net = new_net %>% # 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))) %>% +simple_net |> + filter(edge_is_between(pull(a_multiple, from), pull(a_multiple, to))) |> st_as_sf() ``` @@ -308,11 +341,15 @@ simple_net %>% 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%'} +```{r} +#| layout-ncol: 2 +#| fig-cap: +#| - "Original network" +#| - "Smoothed network" 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(net) +plot(net, col = "red", cex = 0.8, lwd = 0.1) plot(smoothed_net, col = "grey", add = TRUE) ``` @@ -338,16 +375,16 @@ paste("Number of components: ", count_components(subdivided_net)) 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() %>% +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) %>% +net |> + morph(to_spatial_subset, points, .pred = st_equals) |> + st_join(codes, join = st_intersects) |> unmorph() ``` @@ -356,13 +393,13 @@ If you want to apply the spatial filter to the edges instead of the nodes, eithe 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") %>% +net = net |> + activate("nodes") |> mutate(building = sample(c(TRUE, FALSE), n(), replace = TRUE)) -net %>% - morph(to_subgraph, building) %>% - st_join(codes, join = st_intersects) %>% +net |> + morph(to_subgraph, building) |> + st_join(codes, join = st_intersects) |> unmorph() ``` @@ -370,23 +407,25 @@ net %>% 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} +```{r} +#| error: true # Azimuth calculation fails with our projected CRS. # The function complains the coordinates are not longitude/latitude. -net %>% - activate("edges") %>% +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()) %>% +net |> + activate("edges") |> + morph(to_spatial_transformed, 4326) |> + mutate(azimuth = edge_azimuth()) |> unmorph() ``` -```{r, include = FALSE} +```{r} +#| include: false par(oldpar) options(oldoptions) ``` From a488d8b0a125172e97faf72216ea5b506916bad3 Mon Sep 17 00:00:00 2001 From: loreabad6 Date: Sat, 14 Sep 2024 17:22:22 +0200 Subject: [PATCH 094/246] fix: Attribute extraction in to_spatial_constracted() with require_equal :wrench: --- R/morphers.R | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/R/morphers.R b/R/morphers.R index 259477b8..baabd067 100644 --- a/R/morphers.R +++ b/R/morphers.R @@ -545,7 +545,7 @@ to_spatial_smooth = function(x, 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_attr(x, incident_idxs)[require_equal] + incident_attrs = edge_attr(x, require_equal, incident_idxs) # For each of these attributes: # --> Check if its value is equal for both incident edges of a pseudo node. check_equality = function(A) { From 9ba7520f6b54e4d6528fcfca460956d7db781fa1 Mon Sep 17 00:00:00 2001 From: loreabad6 Date: Sat, 14 Sep 2024 17:29:24 +0200 Subject: [PATCH 095/246] fix: Call dots_n() inside to_spatial_explicit() properly :wrench: --- R/morphers.R | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/R/morphers.R b/R/morphers.R index baabd067..ea06a6eb 100644 --- a/R/morphers.R +++ b/R/morphers.R @@ -237,7 +237,7 @@ 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. - if (dots_n > 0) { + if (dots_n() > 0) { edges = edge_data(x, focused = FALSE) new_edges = st_as_sf(edges, ...) x_new = x From 29e3b5981fff8ef3d1e1451970504fbbb158781f Mon Sep 17 00:00:00 2001 From: loreabad6 Date: Sat, 14 Sep 2024 17:36:59 +0200 Subject: [PATCH 096/246] deps: Add pillar to imports :couple: --- DESCRIPTION | 1 + 1 file changed, 1 insertion(+) diff --git a/DESCRIPTION b/DESCRIPTION index f2e8181d..a775a569 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -41,6 +41,7 @@ Imports: lifecycle, lwgeom, methods, + pillar, rlang, sf, sfheaders, From 2c8228b722cc2f75883b944df0bf297218edc4dc Mon Sep 17 00:00:00 2001 From: loreabad6 Date: Sat, 14 Sep 2024 17:37:27 +0200 Subject: [PATCH 097/246] repo: Add data-raw to build ignore :package: --- .Rbuildignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.Rbuildignore b/.Rbuildignore index ef743ca0..e1db9358 100644 --- a/.Rbuildignore +++ b/.Rbuildignore @@ -5,6 +5,7 @@ ^pkgdown$ ^revdep$ ^bench$ +^data-raw$ ^CODE_OF_CONDUCT\.md$ ^CONTRIBUTING\.md$ ^LICENSE\.md$ From 485cfda7bc1dd5a8a14ee6d5c513d40df571505c Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Sat, 14 Sep 2024 17:42:56 +0200 Subject: [PATCH 098/246] docs: Update examples for sf methods :books: --- R/sf.R | 109 +++++++++++++++++++++++++++++++++++++++++------------- man/sf.Rd | 100 +++++++++++++++++++++++++++++++++++++------------ 2 files changed, 161 insertions(+), 48 deletions(-) diff --git a/R/sf.R b/R/sf.R index 7bbb3371..71928dbb 100644 --- a/R/sf.R +++ b/R/sf.R @@ -27,30 +27,72 @@ #' @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. -#' -#' @details See the \code{\link[sf]{sf}} documentation. -#' +#' @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: +#' +#' \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. +#' \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}}. +#' +#' @name sf +NULL + #' @name 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") #' #' @importFrom sf st_as_sf @@ -93,10 +135,10 @@ edges_as_sf = function(x, focused = FALSE, ...) { #' @name sf #' @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 @@ -106,6 +148,16 @@ st_geometry.sfnetwork = function(obj, active = NULL, focused = TRUE, ...) { } #' @name sf +#' @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 @@ -152,6 +204,15 @@ st_geometry.sfnetwork = function(obj, active = NULL, focused = TRUE, ...) { } #' @name sf +#' @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, ...) { @@ -160,7 +221,7 @@ st_drop_geometry.sfnetwork = function(x, ...) { #' @name sf #' @examples -#' # Get bbox of the active network element. +#' # Get the bounding box of the active network element. #' st_bbox(net) #' #' @importFrom sf st_bbox @@ -441,11 +502,10 @@ 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) @@ -537,18 +597,17 @@ 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 diff --git a/man/sf.Rd b/man/sf.Rd index 32b4f380..a779dac6 100644 --- a/man/sf.Rd +++ b/man/sf.Rd @@ -148,41 +148,97 @@ 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.} } \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. + \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. @@ -202,11 +258,10 @@ 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) @@ -217,18 +272,17 @@ 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) } From 76ad17b70aef9037fa5eab74e6866a68aa49f489 Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Sat, 14 Sep 2024 18:48:37 +0200 Subject: [PATCH 099/246] feat: Add new method for tidygraph::reroute :gift: --- NAMESPACE | 2 ++ R/tidygraph.R | 53 +++++++++++++++++++++++++++++++++++++- man/tidygraph_methods.Rd | 55 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 109 insertions(+), 1 deletion(-) create mode 100644 man/tidygraph_methods.Rd diff --git a/NAMESPACE b/NAMESPACE index 6aa53365..13d8071b 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -17,6 +17,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) @@ -284,6 +285,7 @@ importFrom(tidygraph,as_tbl_graph) importFrom(tidygraph,graph_join) importFrom(tidygraph,morph) importFrom(tidygraph,play_geometry) +importFrom(tidygraph,reroute) importFrom(tidygraph,tbl_graph) importFrom(tidygraph,unfocus) importFrom(tidygraph,unmorph) diff --git a/R/tidygraph.R b/R/tidygraph.R index fd5c1962..44568721 100644 --- a/R/tidygraph.R +++ b/R/tidygraph.R @@ -10,6 +10,45 @@ tidygraph::active #' @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,9 +56,20 @@ as_tbl_graph.sfnetwork = function(x, ...) { x } +#' @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, .f, ...) { +morph.sfnetwork = function(.data, ...) { # Morph using tidygraphs morphing functionality. morphed = NextMethod() # If morphed data still consist of valid sfnetworks: @@ -41,6 +91,7 @@ morph.sfnetwork = function(.data, .f, ...) { } } +#' @name tidygraph_methods #' @importFrom igraph edge_attr vertex_attr #' @importFrom tibble as_tibble #' @importFrom tidygraph unmorph 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. +} +} From 26c21acfa1afd8c17a1295018817f749a34c10f8 Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Sat, 14 Sep 2024 18:49:43 +0200 Subject: [PATCH 100/246] docs: Rename sf method man page :books: --- R/sf.R | 80 ++++++++++++++++++------------------ man/{sf.Rd => sf_methods.Rd} | 4 +- 2 files changed, 42 insertions(+), 42 deletions(-) rename man/{sf.Rd => sf_methods.Rd} (99%) diff --git a/R/sf.R b/R/sf.R index 71928dbb..fae59dee 100644 --- a/R/sf.R +++ b/R/sf.R @@ -76,10 +76,10 @@ #' structure. When applying the unsupported operations, first extract the #' element of interest (nodes or edges) using \code{\link[sf]{st_as_sf}}. #' -#' @name sf +#' @name sf_methods NULL -#' @name sf +#' @name sf_methods #' #' @examples #' library(sf, quietly = TRUE) @@ -133,7 +133,7 @@ edges_as_sf = function(x, focused = FALSE, ...) { # Geometries # ============================================================================= -#' @name sf +#' @name sf_methods #' @examples #' # Get the geometry of the active network element. #' st_geometry(net) @@ -147,7 +147,7 @@ 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. @@ -203,7 +203,7 @@ st_geometry.sfnetwork = function(obj, active = NULL, focused = TRUE, ...) { } } -#' @name sf +#' @name sf_methods #' @examples #' # Drop the geometries of the edges. #' # This returns an sfnetwork with spatially implicit edges. @@ -219,7 +219,7 @@ st_drop_geometry.sfnetwork = function(x, ...) { drop_geom(x) } -#' @name sf +#' @name sf_methods #' @examples #' # Get the bounding box of the active network element. #' st_bbox(net) @@ -230,21 +230,21 @@ st_bbox.sfnetwork = function(obj, active = NULL, ...) { 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, focused = TRUE), ...) } -#' @name sf +#' @name sf_methods #' @importFrom sf st_is #' @export st_is.sfnetwork = function(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, ...) { @@ -268,7 +268,7 @@ as_s2_geography.sfnetwork = function(x, focused = TRUE, ...) { s2::as_s2_geography(pull_geom(x, focused = focused), ...) } -#' @name sf +#' @name sf_methods #' @importFrom sf st_as_s2 #' @export st_as_s2.sfnetwork = function(x, active = NULL, focused = TRUE, ...) { @@ -279,7 +279,7 @@ st_as_s2.sfnetwork = function(x, active = NULL, focused = TRUE, ...) { # Coordinates # ============================================================================= -#' @name sf +#' @name sf_methods #' @examples #' # Get CRS of the network. #' st_crs(net) @@ -290,7 +290,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) { @@ -304,14 +304,14 @@ 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)) } -#' @name sf +#' @name sf_methods #' @importFrom sf st_set_precision st_precision<- #' @export st_set_precision.sfnetwork = function(x, precision) { @@ -325,49 +325,49 @@ 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, focused = TRUE), ...) } -#' @name sf +#' @name sf_methods #' @importFrom sf st_z_range #' @export st_z_range.sfnetwork = function(obj, active = NULL, ...) { @@ -389,7 +389,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) @@ -403,7 +403,7 @@ 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) { @@ -428,7 +428,7 @@ st_agr.sfnetwork = function(x, active = NULL, ...) { # as their corresponding LINESTRING geometries in x (source and target may be # switched). -#' @name sf +#' @name sf_methods #' @importFrom cli cli_warn #' @importFrom igraph is_directed reverse_edges #' @importFrom sf st_reverse @@ -448,7 +448,7 @@ st_reverse.sfnetwork = function(x, ...) { geom_unary_ops(st_reverse, x, active,...) } -#' @name sf +#' @name sf_methods #' @importFrom cli cli_warn #' @importFrom igraph is_directed #' @importFrom sf st_segmentize @@ -473,7 +473,7 @@ st_segmentize.sfnetwork = function(x, ...) { } } -#' @name sf +#' @name sf_methods #' @importFrom sf st_simplify #' @export st_simplify.sfnetwork = function(x, ...) { @@ -492,7 +492,7 @@ geom_unary_ops = function(op, x, active, ...) { # Join and filter # ============================================================================= -#' @name sf +#' @name sf_methods #' @examples #' # Spatial join applied to the active network element. #' net = st_transform(net, 3035) @@ -524,7 +524,7 @@ st_join.sfnetwork = function(x, y, ...) { ) } -#' @name sf +#' @name sf_methods #' @importFrom sf st_join #' @export st_join.morphed_sfnetwork = function(x, y, ...) { @@ -589,7 +589,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)) @@ -624,7 +624,7 @@ st_filter.sfnetwork = function(x, y, ...) { ) } -#' @name sf +#' @name sf_methods #' @importFrom sf st_filter #' @export st_filter.morphed_sfnetwork = function(x, y, ...) { @@ -650,7 +650,7 @@ spatial_filter_edges = function(x, y, ...) { delete_edges(x, drop) %preserve_all_attrs% x } -#' @name sf +#' @name sf_methods #' @importFrom sf st_crop st_as_sfc #' @importFrom tidygraph unfocus #' @export @@ -666,7 +666,7 @@ st_crop.sfnetwork = function(x, y, ...) { ) } -#' @name sf +#' @name sf_methods #' @importFrom sf st_crop #' @export st_crop.morphed_sfnetwork = function(x, y, ...) { @@ -674,7 +674,7 @@ st_crop.morphed_sfnetwork = function(x, y, ...) { x } -#' @name sf +#' @name sf_methods #' @importFrom sf st_difference st_as_sfc #' @importFrom tidygraph unfocus #' @export @@ -689,7 +689,7 @@ st_difference.sfnetwork = function(x, y, ...) { ) } -#' @name sf +#' @name sf_methods #' @importFrom sf st_difference #' @export st_difference.morphed_sfnetwork = function(x, y, ...) { @@ -697,7 +697,7 @@ st_difference.morphed_sfnetwork = function(x, y, ...) { x } -#' @name sf +#' @name sf_methods #' @importFrom sf st_intersection st_as_sfc #' @importFrom tidygraph unfocus #' @export @@ -712,7 +712,7 @@ st_intersection.sfnetwork = function(x, y, ...) { ) } -#' @name sf +#' @name sf_methods #' @importFrom sf st_intersection #' @export st_intersection.morphed_sfnetwork = function(x, y, ...) { @@ -819,7 +819,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, ...) { @@ -830,21 +830,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/man/sf.Rd b/man/sf_methods.Rd similarity index 99% rename from man/sf.Rd rename to man/sf_methods.Rd index a779dac6..7c0d4a76 100644 --- a/man/sf.Rd +++ b/man/sf_methods.Rd @@ -1,7 +1,7 @@ % 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_geometry.sfnetwork} \alias{st_geometry<-.sfnetwork} From 9f7d77188d43f6bae4c3e2148cb77933c4234040 Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Sat, 14 Sep 2024 18:52:42 +0200 Subject: [PATCH 101/246] refactor: Use cli for message() calls :construction: --- R/morphers.R | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/R/morphers.R b/R/morphers.R index 31bc314d..be9eafae 100644 --- a/R/morphers.R +++ b/R/morphers.R @@ -1016,11 +1016,12 @@ 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) { 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, From 58cfac17c0fb568136c54ce449a08c9e1a3816ee Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Sat, 14 Sep 2024 18:56:01 +0200 Subject: [PATCH 102/246] feat: Re-export more tidygraph verbs :gift: --- NAMESPACE | 8 ++++++++ R/tidygraph.R | 20 ++++++++++++++++++++ man/reexports.Rd | 7 ++++++- 3 files changed, 34 insertions(+), 1 deletion(-) diff --git a/NAMESPACE b/NAMESPACE index 13d8071b..897b0a7c 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -65,8 +65,11 @@ export("%>%") export(activate) export(active) export(as_sfnetwork) +export(convert) export(create_from_spatial_lines) export(create_from_spatial_points) +export(crystallise) +export(crystallize) export(edge_azimuth) export(edge_circuity) export(edge_contains) @@ -91,6 +94,7 @@ export(is_sfnetwork) export(make_edges_explicit) export(make_edges_follow_indices) export(make_edges_valid) +export(morph) export(n_edges) export(n_nodes) export(nb_to_sfnetwork) @@ -134,6 +138,7 @@ export(to_spatial_subdivision) export(to_spatial_subset) export(to_spatial_transformed) export(to_spatial_unique) +export(unmorph) export(validate_network) importFrom(cli,cli_abort) importFrom(cli,cli_alert) @@ -282,6 +287,9 @@ importFrom(tidygraph,.register_graph_context) importFrom(tidygraph,activate) importFrom(tidygraph,active) importFrom(tidygraph,as_tbl_graph) +importFrom(tidygraph,convert) +importFrom(tidygraph,crystallise) +importFrom(tidygraph,crystallize) importFrom(tidygraph,graph_join) importFrom(tidygraph,morph) importFrom(tidygraph,play_geometry) diff --git a/R/tidygraph.R b/R/tidygraph.R index 44568721..d434434a 100644 --- a/R/tidygraph.R +++ b/R/tidygraph.R @@ -6,6 +6,26 @@ 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 %>% #' @export tidygraph::`%>%` diff --git a/man/reexports.Rd b/man/reexports.Rd index 0aeda644..eeba6988 100644 --- a/man/reexports.Rd +++ b/man/reexports.Rd @@ -5,6 +5,11 @@ \alias{reexports} \alias{activate} \alias{active} +\alias{morph} +\alias{unmorph} +\alias{convert} +\alias{crystallize} +\alias{crystallise} \alias{\%>\%} \title{Objects exported from other packages} \keyword{internal} @@ -13,6 +18,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}}} }} From 27306398e7d802cf83f80ae7c4752eba21c3f51a Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Sat, 14 Sep 2024 19:05:03 +0200 Subject: [PATCH 103/246] refactor: Do not use red cross in warning messages :construction: --- R/messages.R | 4 ++-- R/sf.R | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/R/messages.R b/R/messages.R index fb05d6b1..0ffea863 100644 --- a/R/messages.R +++ b/R/messages.R @@ -4,7 +4,7 @@ raise_assume_constant = function(caller) { cli_warn(c( "{.fn {caller}} assumes all attributes are constant over geometries.", - "x" = "Not all attributes are labelled as being constant.", + "!" = "Not all attributes are labelled as being constant.", "i" = "You can label attribute-geometry relations using {.fn sf::st_set_agr}." )) } @@ -13,7 +13,7 @@ raise_assume_constant = function(caller) { raise_assume_projected = function(caller) { cli_warn(c( "{.fn {caller}} assumes coordinates are projected.", - "x" = paste( + "!" = paste( "The provided coordinates are geographic,", "which may lead to inaccurate results." ), diff --git a/R/sf.R b/R/sf.R index fae59dee..3b4b797d 100644 --- a/R/sf.R +++ b/R/sf.R @@ -556,7 +556,7 @@ spatial_join_nodes = function(x, y, ...) { n_new = n_new[!duplicated_match, ] cli_warn(c( "{.fn st_join} for {.cls sfnetwork} objects only joins one feature per node.", - "x" = paste( + "!" = paste( "Multiple matches were detected for some nodes,", "of which all but the first one are ignored." ) From 0a4914c8dd8fdd0bde069af884f316eb28cfcd9b Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Sat, 14 Sep 2024 19:26:23 +0200 Subject: [PATCH 104/246] feat: Add st_network_distance as synonym for default st_network_cost :gift: --- NAMESPACE | 1 + R/paths.R | 35 +++++++++++++++++++++++++++-------- man/st_network_cost.Rd | 32 ++++++++++++++++++++++++-------- 3 files changed, 52 insertions(+), 16 deletions(-) diff --git a/NAMESPACE b/NAMESPACE index 897b0a7c..f8efcce8 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -125,6 +125,7 @@ export(st_match) export(st_network_bbox) export(st_network_blend) export(st_network_cost) +export(st_network_distance) export(st_network_join) export(st_network_paths) export(to_spatial_contracted) diff --git a/R/paths.R b/R/paths.R index 6ba7f253..54d57e43 100644 --- a/R/paths.R +++ b/R/paths.R @@ -305,8 +305,8 @@ igraph_paths = function(x, from, to, weights, type = "shortest", #' Compute a cost matrix of a spatial network #' -#' A function to compute total travel costs of shortest paths between nodes -#' in 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}}. #' @@ -349,12 +349,15 @@ igraph_paths = function(x, from, to, weights, type = "shortest", #' @param ... Additional arguments passed on to \code{\link[igraph]{distances}}. #' Instead of the \code{mode} argument, use the \code{direction} argument. #' -#' @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. +#' @details \code{st_network_cost} allows to use any set of edge weights, while +#' \code{st_network_distance} is a intuitive synonym for cost matrix computation +#' in which the edge weights are set to their geographic length. +#' +#' 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 @@ -382,6 +385,9 @@ igraph_paths = function(x, from, to, weights, type = "shortest", #' # 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))) @@ -474,3 +480,16 @@ st_network_cost.sfnetwork = function(x, from = node_ids(x), to = node_ids(x), matrix } } + +#' @name st_network_cost +#' @export +st_network_distance = function(x, from = node_ids(x), to = node_ids(x), + direction = "out", Inf_as_NaN = FALSE, ...) { + st_network_cost( + x, from, to, + weights = edge_length(), + direction = direction, + Inf_as_NaN = Inf_as_NaN, + ... + ) +} diff --git a/man/st_network_cost.Rd b/man/st_network_cost.Rd index c0260b97..8d8393e0 100644 --- a/man/st_network_cost.Rd +++ b/man/st_network_cost.Rd @@ -2,6 +2,7 @@ % Please edit documentation in R/paths.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( @@ -13,6 +14,15 @@ st_network_cost( Inf_as_NaN = FALSE, ... ) + +st_network_distance( + x, + from = node_ids(x), + to = node_ids(x), + direction = "out", + Inf_as_NaN = FALSE, + ... +) } \arguments{ \item{x}{An object of class \code{\link{sfnetwork}}.} @@ -61,16 +71,19 @@ 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{ -A function to compute total travel costs of shortest paths between nodes -in a spatial network. +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. +\code{st_network_cost} allows to use any set of edge weights, while +\code{st_network_distance} is a intuitive synonym for cost matrix computation +in which the edge weights are set to their geographic length. + +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 @@ -93,6 +106,9 @@ net = as_sfnetwork(roxel, directed = FALSE) |> # 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))) From 02a827666130de24de83234e12e925db0561f064 Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Sat, 14 Sep 2024 19:48:36 +0200 Subject: [PATCH 105/246] feat: Add new utility function st_round. Refs #213 :gift: --- NAMESPACE | 1 + R/utils.R | 44 ++++++++++++++++++++++++++++++++++++++++++++ man/autoplot.Rd | 2 +- man/st_duplicated.Rd | 3 +++ man/st_match.Rd | 3 +++ man/st_round.Rd | 35 +++++++++++++++++++++++++++++++++++ 6 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 man/st_round.Rd diff --git a/NAMESPACE b/NAMESPACE index f8efcce8..383cd3ea 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -128,6 +128,7 @@ export(st_network_cost) export(st_network_distance) export(st_network_join) export(st_network_paths) +export(st_round) export(to_spatial_contracted) export(to_spatial_directed) export(to_spatial_explicit) diff --git a/R/utils.R b/R/utils.R index 665c0859..36df9d5f 100644 --- a/R/utils.R +++ b/R/utils.R @@ -5,6 +5,8 @@ #' @return A logical vector specifying for each feature in \code{x} if its #' geometry is equal to a previous feature in \code{x}. #' +#' @seealso \code{\link{duplicated}} +#' #' @examples #' library(sf, quietly = TRUE) #' @@ -29,6 +31,8 @@ st_duplicated = function(x) { #' @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. #' +#' @seealso \code{\link{match}} +#' #' @examples #' library(sf, quietly = TRUE) #' @@ -45,6 +49,46 @@ st_match = function(x) { match(idxs, unique(idxs)) } +#' Rounding of geometry coordinates +#' +#' @param x An object of class \code{\link[sf]{sf}} or \code{\link[sf]{sfc}}. +#' +#' @param digits Integer indicating the number of decimal places to be used. +#' +#' @param ... Additional arguments passed on to \code{\link{round}}. +#' +#' @return An object of class \code{\link[sf]{sf}} or \code{\link[sf]{sfc}} +#' with rounded coordinates. +#' +#' @seealso \code{\link{round}} +#' +#' @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(c(p1, p2, p2, p3, p1), digits = 1) +#' +#' @importFrom rlang try_fetch +#' @importFrom sf st_crs st_geometry st_sfc +#' @export +st_round = function(x, digits = 0, ...) { + xg = st_geometry(x) + try_fetch( + st_sfc(lapply(xg, \(i) round(i, digits = digits, ...)), crs = st_crs(x)), + error = function(e) { + if (! (are_points(xg) | are_linestrings(xg))) { + cli_abort(c( + "Unsupported geometry types.", + "i" = "st_rounds only supports {.cls POINT} and {.cls LINESTRING}." + )) + } + } + ) +} + #' Convert a sfheaders data frame into sfc point geometries #' #' @param x_df An object of class \code{\link{data.frame}} as constructed by diff --git a/man/autoplot.Rd b/man/autoplot.Rd index 7d00e0f3..c490d106 100644 --- a/man/autoplot.Rd +++ b/man/autoplot.Rd @@ -5,7 +5,7 @@ \alias{autoplot.sfnetwork} \title{Plot sfnetwork geometries with ggplot2} \usage{ -autoplot.sfnetwork(object, ...) +\method{autoplot}{sfnetwork}(object, ...) } \arguments{ \item{object}{An object of class \code{\link{sfnetwork}}.} diff --git a/man/st_duplicated.Rd b/man/st_duplicated.Rd index b821768a..b75464dd 100644 --- a/man/st_duplicated.Rd +++ b/man/st_duplicated.Rd @@ -26,3 +26,6 @@ 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 index 1b5c186e..0a593194 100644 --- a/man/st_match.Rd +++ b/man/st_match.Rd @@ -26,3 +26,6 @@ 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_round.Rd b/man/st_round.Rd new file mode 100644 index 00000000..8c6e9e0d --- /dev/null +++ b/man/st_round.Rd @@ -0,0 +1,35 @@ +% 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.} + +\item{...}{Additional arguments passed on to \code{\link{round}}.} +} +\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(c(p1, p2, p2, p3, p1), digits = 1) + +} +\seealso{ +\code{\link{round}} +} From d357646622173f0f169cedefed400df4c4f7dd93 Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Sat, 14 Sep 2024 19:50:11 +0200 Subject: [PATCH 106/246] docs: Clarify st_round function docs :books: --- R/utils.R | 5 ++++- man/st_round.Rd | 8 ++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/R/utils.R b/R/utils.R index 36df9d5f..4b7c1285 100644 --- a/R/utils.R +++ b/R/utils.R @@ -49,7 +49,7 @@ st_match = function(x) { match(idxs, unique(idxs)) } -#' Rounding of geometry coordinates +#' Rounding of coordinates of point and linestring geometries #' #' @param x An object of class \code{\link[sf]{sf}} or \code{\link[sf]{sfc}}. #' @@ -60,6 +60,9 @@ st_match = function(x) { #' @return An object of class \code{\link[sf]{sf}} or \code{\link[sf]{sfc}} #' with rounded coordinates. #' +#' @note Currently this function only works for \code{POINT} and +#' \code{LINESTRING} geometries. +#' #' @seealso \code{\link{round}} #' #' @examples diff --git a/man/st_round.Rd b/man/st_round.Rd index 8c6e9e0d..9191b9b9 100644 --- a/man/st_round.Rd +++ b/man/st_round.Rd @@ -2,7 +2,7 @@ % Please edit documentation in R/utils.R \name{st_round} \alias{st_round} -\title{Rounding of geometry coordinates} +\title{Rounding of coordinates of point and linestring geometries} \usage{ st_round(x, digits = 0, ...) } @@ -18,7 +18,11 @@ An object of class \code{\link[sf]{sf}} or \code{\link[sf]{sfc}} with rounded coordinates. } \description{ -Rounding of geometry coordinates +Rounding of coordinates of point and linestring geometries +} +\note{ +Currently this function only works for \code{POINT} and +\code{LINESTRING} geometries. } \examples{ library(sf, quietly = TRUE) From 8a23b334c2aa580b58f93cf31334859981d4c19a Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Sat, 14 Sep 2024 20:49:57 +0200 Subject: [PATCH 107/246] feat: Add a st_geometry setter for tbl_graph and igraph objects :gift: --- NAMESPACE | 2 ++ R/sf.R | 26 ++++++++++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/NAMESPACE b/NAMESPACE index 383cd3ea..dcf5fc60 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -2,7 +2,9 @@ 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,focused_tbl_graph) S3method(as_sfnetwork,linnet) diff --git a/R/sf.R b/R/sf.R index 3b4b797d..01e95fd4 100644 --- a/R/sf.R +++ b/R/sf.R @@ -203,6 +203,32 @@ st_geometry.sfnetwork = function(obj, active = NULL, focused = 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 +} + +#' @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. From c26d9b72faf223269c83d22f4a8934ee5d63ffcf Mon Sep 17 00:00:00 2001 From: loreabad6 Date: Sun, 15 Sep 2024 15:51:37 +0200 Subject: [PATCH 108/246] feat: Add group_spatial() :gift: --- R/group.R | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 R/group.R diff --git a/R/group.R b/R/group.R new file mode 100644 index 00000000..7139726b --- /dev/null +++ b/R/group.R @@ -0,0 +1,47 @@ +#' Group nodes based on spatial distance +#' +#' @param dist distance within which nodes are clustered to each other +#' +#' @param algorithm the grouping algorithm used to perform spatial clustering. +#' Defaults to the `dbscan::dbscan()` algorithm. +#' +#' @param network_distance should the distance be based on the network distance +#' (default, uses `st_network_distance()` internally) or euclidean distance +#' (uses `sf::st_distance()` internally)? +#' +#' @param min_nodes minimum number of nodes assigned to each cluster. Defaults +#' to 1 so that every node is assigned a cluster even if it is the only member +#' of that cluster. +#' +#' @param ... other arguments passed onto the algorithm, +#' e.g. `dbscan::dbscan()` +#' +#' @importFrom dbscan dbscan +#' @export +group_spatial = function(dist, algorithm = "dbscan", + network_distance = TRUE, + min_nodes = 1, + ...) { + require_active_nodes() + if(algorithm == "dbscan") { + if (network_distance) { + distmat = as.dist(st_network_distance(.G())) + } else { + distmat = as.dist(st_distance(.G())) + } + group = dbscan(distmat, eps = dist, minPts = min_nodes, ...)$cluster + } + desc_enumeration(group) +} + + +# HELPERS ----------------------------------------------------------------- + +# 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 and so +# forth +desc_enumeration <- function(group) { + match(group, as.integer(names(sort(table(group), decreasing = TRUE)))) +} + +# git commit -m "feat: Add group_spatial() :gift:" From 5f398079427b3a1c604959f48ff30c3f23c0e483 Mon Sep 17 00:00:00 2001 From: loreabad6 Date: Sun, 15 Sep 2024 15:54:41 +0200 Subject: [PATCH 109/246] docs: Document :books: --- NAMESPACE | 2 ++ man/autoplot.Rd | 2 +- man/group_spatial.Rd | 34 ++++++++++++++++++++++++++++++++++ 3 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 man/group_spatial.Rd diff --git a/NAMESPACE b/NAMESPACE index dcf5fc60..735b82ed 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -91,6 +91,7 @@ export(edge_is_within_distance) export(edge_length) export(edge_overlaps) export(edge_touches) +export(group_spatial) export(is.sfnetwork) export(is_sfnetwork) export(make_edges_explicit) @@ -148,6 +149,7 @@ importFrom(cli,cli_abort) importFrom(cli,cli_alert) importFrom(cli,cli_alert_success) importFrom(cli,cli_warn) +importFrom(dbscan,dbscan) importFrom(dplyr,across) importFrom(dplyr,bind_rows) importFrom(dplyr,full_join) diff --git a/man/autoplot.Rd b/man/autoplot.Rd index c490d106..7d00e0f3 100644 --- a/man/autoplot.Rd +++ b/man/autoplot.Rd @@ -5,7 +5,7 @@ \alias{autoplot.sfnetwork} \title{Plot sfnetwork geometries with ggplot2} \usage{ -\method{autoplot}{sfnetwork}(object, ...) +autoplot.sfnetwork(object, ...) } \arguments{ \item{object}{An object of class \code{\link{sfnetwork}}.} diff --git a/man/group_spatial.Rd b/man/group_spatial.Rd new file mode 100644 index 00000000..ebaf032b --- /dev/null +++ b/man/group_spatial.Rd @@ -0,0 +1,34 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/group.R +\name{group_spatial} +\alias{group_spatial} +\title{Group nodes based on spatial distance} +\usage{ +group_spatial( + dist, + algorithm = "dbscan", + network_distance = TRUE, + min_nodes = 1, + ... +) +} +\arguments{ +\item{dist}{distance within which nodes are clustered to each other} + +\item{algorithm}{the grouping algorithm used to perform spatial clustering. +Defaults to the `dbscan::dbscan()` algorithm.} + +\item{network_distance}{should the distance be based on the network distance +(default, uses `st_network_distance()` internally) or euclidean distance +(uses `sf::st_distance()` internally)?} + +\item{min_nodes}{minimum number of nodes assigned to each cluster. Defaults +to 1 so that every node is assigned a cluster even if it is the only member +of that cluster.} + +\item{...}{other arguments passed onto the algorithm, +e.g. `dbscan::dbscan()`} +} +\description{ +Group nodes based on spatial distance +} From 1f15a69bafcf929b10f656191f4dcd65ec107c6d Mon Sep 17 00:00:00 2001 From: loreabad6 Date: Sun, 15 Sep 2024 15:59:11 +0200 Subject: [PATCH 110/246] deps: Add stats to imports :couple: --- DESCRIPTION | 1 + NAMESPACE | 1 + R/group.R | 3 +-- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/DESCRIPTION b/DESCRIPTION index a775a569..7ac6cdbf 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -45,6 +45,7 @@ Imports: rlang, sf, sfheaders, + stats, tibble, tidygraph, units, diff --git a/NAMESPACE b/NAMESPACE index 735b82ed..b037a704 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -282,6 +282,7 @@ importFrom(sfheaders,sf_to_df) 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,tibble) diff --git a/R/group.R b/R/group.R index 7139726b..a5dff024 100644 --- a/R/group.R +++ b/R/group.R @@ -17,6 +17,7 @@ #' e.g. `dbscan::dbscan()` #' #' @importFrom dbscan dbscan +#' @importFrom stats as.dist #' @export group_spatial = function(dist, algorithm = "dbscan", network_distance = TRUE, @@ -43,5 +44,3 @@ group_spatial = function(dist, algorithm = "dbscan", desc_enumeration <- function(group) { match(group, as.integer(names(sort(table(group), decreasing = TRUE)))) } - -# git commit -m "feat: Add group_spatial() :gift:" From 64a7cf326b76f068ea28e632df241d041b6bd230 Mon Sep 17 00:00:00 2001 From: loreabad6 Date: Sun, 15 Sep 2024 16:34:55 +0200 Subject: [PATCH 111/246] fix: st_networks_paths() returning Inf when path was found :wrench: --- R/paths.R | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/R/paths.R b/R/paths.R index 54d57e43..696facb3 100644 --- a/R/paths.R +++ b/R/paths.R @@ -229,7 +229,7 @@ st_network_paths.sfnetwork = function(x, from, to = node_ids(x), } else { costs = do.call("c", lapply(paths$edges, \(x) sum(weights[x]))) } - if (has_name(paths, "path_found")) costs[paths$path_found] = Inf + if (has_name(paths, "path_found")) costs[!paths$path_found] = Inf paths$cost = costs } # Construct path geometries of requested. From 5f57000dc2636925e9e804fe3ab7165a183f2fc4 Mon Sep 17 00:00:00 2001 From: loreabad6 Date: Sun, 15 Sep 2024 19:19:48 +0200 Subject: [PATCH 112/246] feat: Add st_network_travel() function for route optimization :gift: --- NAMESPACE | 4 ++ R/travel.R | 91 ++++++++++++++++++++++++++++++++++++++++ man/st_network_travel.Rd | 67 +++++++++++++++++++++++++++++ 3 files changed, 162 insertions(+) create mode 100644 R/travel.R create mode 100644 man/st_network_travel.Rd diff --git a/NAMESPACE b/NAMESPACE index b037a704..a9591e66 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -131,6 +131,7 @@ export(st_network_cost) export(st_network_distance) export(st_network_join) export(st_network_paths) +export(st_network_travel) export(st_round) export(to_spatial_contracted) export(to_spatial_directed) @@ -145,6 +146,9 @@ export(to_spatial_transformed) export(to_spatial_unique) export(unmorph) export(validate_network) +importFrom(TSP,ATSP) +importFrom(TSP,TSP) +importFrom(TSP,solve_TSP) importFrom(cli,cli_abort) importFrom(cli,cli_alert) importFrom(cli,cli_alert_success) diff --git a/R/travel.R b/R/travel.R new file mode 100644 index 00000000..25accc0b --- /dev/null +++ b/R/travel.R @@ -0,0 +1,91 @@ +#' Compute route optimization algorithms +#' +#' The travelling salesman problem is currently implemented +#' +#' @param pois Locations that the travelling salesman will visit. +#' Can be an integer vector specifying nodes indices or a character vector +#' specifying node names. Can also be an object of class \code{\link[sf]{sf}} +#' or \code{\link[sf]{sfc}} containing spatial features. +#' In that case, these feature will be snapped to their nearest node before +#' solving the algorithm. +#' +#' @param return_paths Should the shortest paths between `pois` be computed? +#' Defaults to `TRUE`. If `FALSE`, a vector with indices in the visiting order +#' is returned. +#' +#' @param ... Additional arguments passed on to the `TSP::solve_tsp()` function. +#' +#' @inheritParams st_network_paths +#' +#' @return An object of class \code{\link[tibble]{tbl_df}} or +#' \code{\link[sf]{sf}} with one row per path, or a vector with ordered indices +#' for `pois`. +#' +#' @importFrom stats as.dist +#' @importFrom TSP solve_TSP TSP ATSP +#' @export + +st_network_travel = function(x, pois, weights = edge_length(), + algorithm = "tsp", + return_paths = TRUE, + use_names = TRUE, + return_cost = TRUE, + return_geometry = TRUE, + ...) { + # Parse pois argument. + # --> Convert geometries to node indices. + ### check # --> Raise warnings when requirements are not met. + if (is_sf(pois) | is_sfc(pois)) pois = nearest_node_ids(x, pois) + # if (any(is.na(pois))) raise_na_values("pois") + # Parse weights argument using tidy evaluation on the network edges. + .register_graph_context(x, free = TRUE) + weights = enquo(weights) + weights = eval_tidy(weights, .E()) + if (is_single_string(weights)) { + # Allow character values for backward compatibility. + deprecate_weights_is_string("st_network_travel") + 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("st_network_travel") + weights = NA + } + # Compute cost matrix + costmat = st_network_cost(x, from = pois, to = pois, weights = weights) + # Use nearest node indices as row and column names + row.names(costmat) = pois + colnames(costmat) = pois + # Convert to tsp object + tsp_obj = switch( + algorithm, + "tsp" = TSP(as.dist(costmat)), + "atsp" = ATSP(as.dist(costmat)) + ) + # Solve TSP + tour = solve_TSP(tsp_obj, ...) + # Return only the TSP result as node indices + if(!return_paths) { + as.numeric(tour) + } else { + tour_idxs = as.numeric(names(tour)) + # 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. + bind_rows( + Map( + \(...) st_network_paths(x = net, ..., + weights = weights, + use_names = use_names, + return_cost = return_cost, + return_geometry = return_geometry), + from = from_idxs, + to = to_idxs + ) + ) + } +} diff --git a/man/st_network_travel.Rd b/man/st_network_travel.Rd new file mode 100644 index 00000000..e9e9fc64 --- /dev/null +++ b/man/st_network_travel.Rd @@ -0,0 +1,67 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/travel.R +\name{st_network_travel} +\alias{st_network_travel} +\title{Compute route optimization algorithms} +\usage{ +st_network_travel( + x, + pois, + weights = edge_length(), + algorithm = "tsp", + return_paths = TRUE, + use_names = TRUE, + return_cost = TRUE, + return_geometry = TRUE, + ... +) +} +\arguments{ +\item{x}{An object of class \code{\link{sfnetwork}}.} + +\item{pois}{Locations that the travelling salesman will visit. +Can be an integer vector specifying nodes indices or a character vector +specifying node names. Can also be an object of class \code{\link[sf]{sf}} +or \code{\link[sf]{sfc}} containing spatial features. +In that case, these feature will be snapped to their nearest node before +solving the algorithm.} + +\item{weights}{The edge weights to be used in the shortest path calculation. +Can be a numeric vector of the same length as the number of edges, a +\link[=spatial_edge_measures]{spatial edge measure function}, or a column in +the edges table of the network. Tidy evaluation is used such that column +names can be specified as if they were variables in the environment (e.g. +simply \code{length} instead of \code{igraph::edge_attr(x, "length")}). +If set to \code{NULL} or \code{NA} no edge weights are used, and the +shortest path is the path with the fewest number of edges, ignoring space. +The default is \code{\link{edge_length}}, which computes the geographic +lengths of the edges.} + +\item{return_paths}{Should the shortest paths between `pois` be computed? +Defaults to `TRUE`. If `FALSE`, a vector with indices in the visiting order +is returned.} + +\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 +not have a column named \code{name}.} + +\item{return_cost}{Should the total cost of each path be computed? Defaults +to \code{TRUE}. Ignored if \code{type = 'all_simple'}.} + +\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 if \code{type = 'all_simple'} and for networks with +spatially implicit edges.} + +\item{...}{Additional arguments passed on to the `TSP::solve_tsp()` function.} +} +\value{ +An object of class \code{\link[tibble]{tbl_df}} or +\code{\link[sf]{sf}} with one row per path, or a vector with ordered indices +for `pois`. +} +\description{ +The travelling salesman problem is currently implemented +} From 3ad7f3e023c763f083d2577c13723988dd947f23 Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Mon, 16 Sep 2024 17:37:07 +0200 Subject: [PATCH 113/246] fix: Add missing square brackets to overwrite message :wrench: --- R/messages.R | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/R/messages.R b/R/messages.R index 0ffea863..f26e999e 100644 --- a/R/messages.R +++ b/R/messages.R @@ -52,7 +52,7 @@ raise_na_values = function(arg) { #' @importFrom cli cli_warn raise_overwrite = function(value) { - cli_warn("Overwriting column {.field value}.") + cli_warn("Overwriting column {.field {value}}.") } #' @importFrom cli cli_abort From 7cad17317136e9cf75bd1bb05100d60fdfcee177 Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Mon, 16 Sep 2024 17:42:13 +0200 Subject: [PATCH 114/246] feat: New implementation of to_spatial_subdivision :gift: --- NAMESPACE | 1 + R/morphers.R | 191 ++++----------------------------------- R/subdivide.R | 192 ++++++++++++++++++++++++++++++++++++++++ R/utils.R | 31 +++++-- man/spatial_morphers.Rd | 25 +++--- 5 files changed, 247 insertions(+), 193 deletions(-) create mode 100644 R/subdivide.R diff --git a/NAMESPACE b/NAMESPACE index dcf5fc60..ab6c5f05 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -149,6 +149,7 @@ 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,group_by) diff --git a/R/morphers.R b/R/morphers.R index be9eafae..22412873 100644 --- a/R/morphers.R +++ b/R/morphers.R @@ -826,182 +826,23 @@ 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 each interior point that is equal to any other interior +#' or boundary point in the edges table. Interior points are those points that +#' shape a linestring geometry feature but are not endpoints of it, while +#' boundary points are the endpoints of the linestrings, i.e. the existing +#' nodes in het network. Returns a \code{morphed_sfnetwork} containing a single +#' element of class \code{\link{sfnetwork}}. This morpher requires edges to be +#' spatially explicit. +#' +#' @param merge_equal Should multiple subdivision points at the same location +#' be merged into a single node, and should subdivision points at the same +#' 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. +#' #' @export -to_spatial_subdivision = function(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_incident_ids(x) - } else { - node_idxs[is_boundary] = edge_boundary_ids(x) - } - # 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) - # Return in a list. +to_spatial_subdivision = function(x, merge_equal = TRUE) { + x_new = subdivide(x, merge_equal = merge_equal) list( subdivision = x_new %preserve_network_attrs% x ) diff --git a/R/subdivide.R b/R/subdivide.R new file mode 100644 index 00000000..453ec90f --- /dev/null +++ b/R/subdivide.R @@ -0,0 +1,192 @@ +#' Subdivide edges at interior points +#' +#' 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 are those points that shape a linestring geometry +#' feature but are not endpoints of it, while boundary points are the endpoints +#' of the linestrings, i.e. the existing nodes in het network. +#' +#' @param x An object of class \code{\link{sfnetwork}} with spatially explicit +#' edges. +#' +#' @param merge_equal Should multiple subdivision points at the same location +#' be merged into a single node, and should subdivision points at the same +#' 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. +#' +#' @returns A subdivision of x as object of class \code{\link{sfnetwork}}. +#' +#' @importFrom dplyr arrange bind_rows +#' @importFrom igraph is_directed +#' @importFrom sf st_as_sf st_geometry<- +#' @importFrom sfheaders sf_to_df +#' @noRd +subdivide = function(x, merge_equal = TRUE) { + 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 + # 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. + ## ======================================= + # 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. + edge_coords = edge_pts[names(edge_pts) %in% c("x", "y", "z", "m")] + edge_lids = st_match_df(edge_coords) + edge_pts$lid = edge_lids + # Define which edge points are not unique. + has_duplicate = duplicated(edge_lids) | duplicated(edge_lids, fromLast = TRUE) + # Define at which edge points to split edges. + is_split = has_duplicate & !is_boundary + ## ========================================== + # 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 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[is_split] = 2L + # Create the new set of edge points by duplicating split points. + new_edge_pts = edge_pts[rep(seq_len(n), reps), ] + # Define the total number of new edge points. + nn = nrow(new_edge_pts) + # 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, nn) + incs[which(is_split) + 1:sum(is_split)] = 1L + new_edge_ids = new_edge_pts$eid + cumsum(incs) + # Use the new edge coordinates to create their linestring geometries. + new_edge_coords = edge_coords[rep(seq_len(n), reps), ] + new_edge_coords$eid = new_edge_ids + new_edge_geoms = df_to_lines(new_edge_coords, edges, "eid", select = FALSE) + ## =================================== + # 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_equal 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_equal) { + # 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 = st_as_sf(add_node_geoms) + st_geometry(add_nodes) = attr(nodes, "sf_column") # Use same column name. + 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. + ## ============================ + sfnetwork_(new_nodes, new_edges, directed = is_directed(x)) +} diff --git a/R/utils.R b/R/utils.R index 4b7c1285..49fc43e7 100644 --- a/R/utils.R +++ b/R/utils.R @@ -49,6 +49,11 @@ st_match = function(x) { match(idxs, unique(idxs)) } +st_match_df = function(x) { + x_str = do.call(paste, x) + match(x_str, unique(x_str)) +} + #' Rounding of coordinates of point and linestring geometries #' #' @param x An object of class \code{\link[sf]{sf}} or \code{\link[sf]{sfc}}. @@ -101,14 +106,21 @@ st_round = function(x, digits = 0, ...) { #' \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. #' +#' @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 these columns in exactly that order. Defaults to +#' \code{TRUE}. +#' #' @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 -df_to_points = function(x_df, x_sf) { - pts = sfc_point(x_df[, names(x_df) %in% c("x", "y", "z", "m")]) +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 @@ -126,17 +138,22 @@ df_to_points = function(x_df, x_sf) { #' @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 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 -df_to_lines = function(x_df, x_sf, id_col = "linestring_id") { - lns = sfc_linestring( - x_df[, names(x_df) %in% c("x", "y", "z", "m", id_col)], - linestring_id = id_col - ) +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 diff --git a/man/spatial_morphers.Rd b/man/spatial_morphers.Rd index 11ab537a..11603019 100644 --- a/man/spatial_morphers.Rd +++ b/man/spatial_morphers.Rd @@ -48,7 +48,7 @@ to_spatial_smooth( store_original_data = FALSE ) -to_spatial_subdivision(x) +to_spatial_subdivision(x, merge_equal = TRUE) to_spatial_subset(x, ..., subset_by = NULL) @@ -123,6 +123,12 @@ 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{merge_equal}{Should multiple subdivision points at the same location +be merged into a single node, and should subdivision points at the same +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.} + \item{subset_by}{Whether to create subgraphs based on nodes or edges.} } \value{ @@ -213,16 +219,13 @@ 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 each interior point that is equal to any other interior +or boundary point in the edges table. Interior points are those points that +shape a linestring geometry feature but are not endpoints of it, while +boundary points are the endpoints of the linestrings, i.e. the existing +nodes in het network. 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. From a7c6de4fe5a380ee7df9dde113f37bc96d732c91 Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Mon, 16 Sep 2024 19:29:57 +0200 Subject: [PATCH 115/246] refactor: Use a new workflow for precision handling in point equality tests. Refs #130 :construction: --- R/create.R | 42 +++++++++++++ R/morphers.R | 169 +++++++++++++++++++++++++++++++++++++++++++++++++- R/subdivide.R | 2 +- R/utils.R | 63 +++++++++++++++---- 4 files changed, 263 insertions(+), 13 deletions(-) diff --git a/R/create.R b/R/create.R index ad96236e..6b390fab 100644 --- a/R/create.R +++ b/R/create.R @@ -470,6 +470,48 @@ create_from_spatial_lines = function(x, directed = TRUE, ) } +create_from_spatial_lines_v2 = function(x, directed = TRUE, + compute_length = FALSE) { + # The provided lines will form the edges of the network. + edges = st_as_sf(x) + # Get the coordinates of the boundary points of the edges. + # These will form the nodes of the network. + node_coords = linestring_boundary_points(edges, return_df = TRUE) + # Give each unique location a unique ID. + 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 = st_sf(geometry = nodes) + # Use the same sf column name in the nodes as in the edges. + geom_colname = attr(edges, "sf_column") + if (geom_colname != "geometry") { + names(nodes)[1] = geom_colname + attr(nodes, "sf_column") = geom_colname + } + # Use the same class for the nodes as for the edges. + # This mainly affects the "lower level" classes. + # For example an sf tibble instead of a sf data frame. + class(nodes) = class(edges) + # 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}} diff --git a/R/morphers.R b/R/morphers.R index 22412873..dc856ec1 100644 --- a/R/morphers.R +++ b/R/morphers.R @@ -905,7 +905,7 @@ to_spatial_unique = function(x, summarise_attributes = "ignore", node_geoms = st_geometry(nodes) node_geomcol = attr(nodes, "sf_column") # Define which nodes have equal geometries. - matches = st_match(node_geoms) + matches = st_match_points(node_geoms) # Update the attribute summary instructions. # During morphing tidygraph adds the tidygraph node index column. # Since it is added internally it is not referenced in summarise_attributes. @@ -941,3 +941,170 @@ to_spatial_unique = function(x, summarise_attributes = "ignore", unique = tbg_to_sfn(x_new %preserve_network_attrs% x) ) } + + +to_spatial_subdivision_orig = function(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_incident_ids(x) + } else { + node_idxs[is_boundary] = edge_boundary_ids(x) + } + # 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) + # Return in a list. + list( + subdivision = x_new %preserve_network_attrs% x + ) +} diff --git a/R/subdivide.R b/R/subdivide.R index 453ec90f..f0714e26 100644 --- a/R/subdivide.R +++ b/R/subdivide.R @@ -59,7 +59,7 @@ subdivide = function(x, merge_equal = TRUE) { # Compute for each edge point a unique location index. # Edge points that are spatially equal get the same location index. edge_coords = edge_pts[names(edge_pts) %in% c("x", "y", "z", "m")] - edge_lids = st_match_df(edge_coords) + edge_lids = st_match_points_df(edge_coords, st_precision(edges)) edge_pts$lid = edge_lids # Define which edge points are not unique. has_duplicate = duplicated(edge_lids) | duplicated(edge_lids, fromLast = TRUE) diff --git a/R/utils.R b/R/utils.R index 49fc43e7..259ba43b 100644 --- a/R/utils.R +++ b/R/utils.R @@ -49,9 +49,25 @@ st_match = function(x) { match(idxs, unique(idxs)) } -st_match_df = function(x) { - x_str = do.call(paste, x) - match(x_str, unique(x_str)) +#' @importFrom sf st_geometry +#' @importFrom sfheaders sfc_to_df +st_match_points = function(x, precision = attr(x, "precision")) { + x_df = sfc_to_df(st_geometry(x)) + coords = x_df[, names(x_df) %in% c("x", "y", "z", "m")] + st_match_points_df(coords, precision = precision) +} + +st_match_points_df = function(x, precision = NULL) { + x_trim = lapply(x, round, digits = precision_to_digits(precision)) + x_concat = do.call(paste, x_trim) + match(x_concat, unique(x_concat)) +} + +#' @importFrom cli cli_abort +precision_to_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") } #' Rounding of coordinates of point and linestring geometries @@ -164,9 +180,16 @@ df_to_lines = function(x_df, x_sf, id_col = "linestring_id", select = TRUE) { #' @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 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. #' #' @details With boundary points we mean the points at the start and end of #' a linestring. @@ -174,12 +197,14 @@ df_to_lines = function(x_df, x_sf, id_col = "linestring_id", select = TRUE) { #' @importFrom sf st_geometry #' @importFrom sfheaders sfc_to_df #' @noRd -linestring_boundary_points = function(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 - df_to_points(coords[is_bound, ], x) + bounds = coords[is_bound, names(coords) %in% c("x", "y", "z", "m")] + if (return_df) return (bounds) + df_to_points(bounds, x, select = FALSE) } #' Get the start points of linestring geometries @@ -187,16 +212,24 @@ linestring_boundary_points = function(x) { #' @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. +#' 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 -linestring_start_points = function(x) { +linestring_start_points = function(x, return_df = FALSE) { coords = sfc_to_df(st_geometry(x)) is_start = !duplicated(coords[["linestring_id"]]) - df_to_points(coords[is_start, ], x) + 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 end points of linestring geometries @@ -204,16 +237,24 @@ linestring_start_points = function(x) { #' @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. +#' 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 -linestring_end_points = function(x) { +linestring_end_points = function(x ,return_df = FALSE) { coords = sfc_to_df(st_geometry(x)) is_end = !duplicated(coords[["linestring_id"]], fromLast = TRUE) - df_to_points(coords[is_end, ], x) + 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 From f2c8048a16c2cb3ee8299f4d32b4e466169d8bc1 Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Tue, 17 Sep 2024 17:14:10 +0200 Subject: [PATCH 116/246] refactor: Use a new workflow for precision handling in point equality tests part II. Refs #130 :construction: --- R/blend.R | 2 +- R/create.R | 46 ++------ R/join.R | 10 +- R/morphers.R | 177 ++----------------------------- R/subdivide.R | 5 +- R/utils.R | 41 +++++-- man/create_from_spatial_lines.Rd | 6 ++ man/spatial_morphers.Rd | 10 +- man/st_network_join.Rd | 9 +- 9 files changed, 80 insertions(+), 226 deletions(-) diff --git a/R/blend.R b/R/blend.R index 4344bf57..60880eae 100644 --- a/R/blend.R +++ b/R/blend.R @@ -241,7 +241,7 @@ blend_ = function(x, y, tolerance) { # 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) + is_duplicated = st_duplicated_points(Y) Y = Y[!is_duplicated] ## ========================================== # STEP V: INCLUDE FEATURES IN EDGE GEOMETRIES diff --git a/R/create.R b/R/create.R index 6b390fab..b7afba7c 100644 --- a/R/create.R +++ b/R/create.R @@ -414,6 +414,11 @@ as_sfnetwork.focused_tbl_graph = function(x, ...) { #' 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}}. +#' #' @return An object of class \code{\link{sfnetwork}}. #' #' @examples @@ -429,51 +434,12 @@ as_sfnetwork.focused_tbl_graph = function(x, ...) { #' #' par(oldpar) #' -#' @importFrom sf st_as_sf st_sf +#' @importFrom sf st_as_sf st_precision st_sf #' @export create_from_spatial_lines = function(x, directed = TRUE, compute_length = FALSE) { # The provided lines will form the edges of the network. edges = st_as_sf(x) - # 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 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 = st_sf(geometry = nodes) - # Use the same sf column name in the nodes as in the edges. - geom_colname = attr(edges, "sf_column") - if (geom_colname != "geometry") { - names(nodes)[1] = geom_colname - attr(nodes, "sf_column") = geom_colname - } - # Use the same class for the nodes as for the edges. - # This mainly affects the "lower level" classes. - # For example an sf tibble instead of a sf data frame. - class(nodes) = class(edges) - # 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_from_spatial_lines_v2 = function(x, directed = TRUE, - compute_length = FALSE) { - # The provided lines will form the edges of the network. - edges = st_as_sf(x) # Get the coordinates of the boundary points of the edges. # These will form the nodes of the network. node_coords = linestring_boundary_points(edges, return_df = TRUE) diff --git a/R/join.R b/R/join.R index db63f144..0dd96df4 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,6 +14,11 @@ #' #' @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 @@ -86,7 +90,7 @@ spatial_join_network = function(x, y, ...) { N_x = vertex_attr(x, x_geomcol) N_y = vertex_attr(y, y_geomcol) N = c(N_x, N_y) - uid = st_match(N) + 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") diff --git a/R/morphers.R b/R/morphers.R index dc856ec1..c5b1282b 100644 --- a/R/morphers.R +++ b/R/morphers.R @@ -838,7 +838,10 @@ to_spatial_smooth = function(x, #' be merged into a single node, and should subdivision points at the same #' 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. +#' 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}}. #' #' @export to_spatial_subdivision = function(x, merge_equal = TRUE) { @@ -890,7 +893,10 @@ to_spatial_transformed = function(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}}. +#' 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}}. #' #' @importFrom igraph contract delete_vertex_attr #' @importFrom sf st_as_sf st_geometry @@ -941,170 +947,3 @@ to_spatial_unique = function(x, summarise_attributes = "ignore", unique = tbg_to_sfn(x_new %preserve_network_attrs% x) ) } - - -to_spatial_subdivision_orig = function(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_incident_ids(x) - } else { - node_idxs[is_boundary] = edge_boundary_ids(x) - } - # 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) - # Return in a list. - list( - subdivision = x_new %preserve_network_attrs% x - ) -} diff --git a/R/subdivide.R b/R/subdivide.R index f0714e26..d5a02160 100644 --- a/R/subdivide.R +++ b/R/subdivide.R @@ -13,7 +13,10 @@ #' be merged into a single node, and should subdivision points at the same #' 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. +#' 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}}. #' #' @returns A subdivision of x as object of class \code{\link{sfnetwork}}. #' diff --git a/R/utils.R b/R/utils.R index 259ba43b..1181fb0d 100644 --- a/R/utils.R +++ b/R/utils.R @@ -24,6 +24,20 @@ st_duplicated = function(x) { dup } +#' @importFrom sf st_geometry +#' @importFrom sfheaders sfc_to_df +st_duplicated_points = function(x, precision = attr(x, "precision")) { + x_df = sfc_to_df(st_geometry(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]{sf}} or \code{\link[sf]{sfc}}. @@ -58,18 +72,11 @@ st_match_points = function(x, precision = attr(x, "precision")) { } st_match_points_df = function(x, precision = NULL) { - x_trim = lapply(x, round, digits = precision_to_digits(precision)) + x_trim = lapply(x, round, digits = precision_digits(precision)) x_concat = do.call(paste, x_trim) match(x_concat, unique(x_concat)) } -#' @importFrom cli cli_abort -precision_to_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") -} - #' Rounding of coordinates of point and linestring geometries #' #' @param x An object of class \code{\link[sf]{sf}} or \code{\link[sf]{sfc}}. @@ -425,6 +432,24 @@ merge_mranges = function(a, b) { 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. diff --git a/man/create_from_spatial_lines.Rd b/man/create_from_spatial_lines.Rd index 022bcf25..6741990f 100644 --- a/man/create_from_spatial_lines.Rd +++ b/man/create_from_spatial_lines.Rd @@ -32,6 +32,12 @@ 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) diff --git a/man/spatial_morphers.Rd b/man/spatial_morphers.Rd index 11603019..f87e871d 100644 --- a/man/spatial_morphers.Rd +++ b/man/spatial_morphers.Rd @@ -127,7 +127,10 @@ using the \code{==} operator.} be merged into a single node, and should subdivision points at the same 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.} +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.} } @@ -242,7 +245,10 @@ Returns a \code{morphed_sfnetwork} containing a single element of class \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}}. +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{ diff --git a/man/st_network_join.Rd b/man/st_network_join.Rd index 3f711648..1512195d 100644 --- a/man/st_network_join.Rd +++ b/man/st_network_join.Rd @@ -19,13 +19,18 @@ 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) From 448d8aee32d64901a473323d0363cf5a2b521a2b Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Tue, 17 Sep 2024 17:26:07 +0200 Subject: [PATCH 117/246] feat: Generalize st_round to all geometry types. Refs #213 :gift: --- NAMESPACE | 1 + R/utils.R | 30 +++++++++--------------------- man/st_round.Rd | 12 +++--------- 3 files changed, 13 insertions(+), 30 deletions(-) diff --git a/NAMESPACE b/NAMESPACE index 4e5e043b..d8c41716 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -230,6 +230,7 @@ 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) diff --git a/R/utils.R b/R/utils.R index 1181fb0d..16d71046 100644 --- a/R/utils.R +++ b/R/utils.R @@ -77,20 +77,15 @@ st_match_points_df = function(x, precision = NULL) { match(x_concat, unique(x_concat)) } -#' Rounding of coordinates of point and linestring geometries +#' Rounding of geometry coordinates #' #' @param x An object of class \code{\link[sf]{sf}} or \code{\link[sf]{sfc}}. #' #' @param digits Integer indicating the number of decimal places to be used. #' -#' @param ... Additional arguments passed on to \code{\link{round}}. -#' #' @return An object of class \code{\link[sf]{sf}} or \code{\link[sf]{sfc}} #' with rounded coordinates. #' -#' @note Currently this function only works for \code{POINT} and -#' \code{LINESTRING} geometries. -#' #' @seealso \code{\link{round}} #' #' @examples @@ -102,22 +97,15 @@ st_match_points_df = function(x, precision = NULL) { #' #' st_round(c(p1, p2, p2, p3, p1), digits = 1) #' -#' @importFrom rlang try_fetch -#' @importFrom sf st_crs st_geometry st_sfc +#' @importFrom sf st_as_binary st_as_sfc st_geometry st_geometry<- +#' st_precision<- #' @export -st_round = function(x, digits = 0, ...) { - xg = st_geometry(x) - try_fetch( - st_sfc(lapply(xg, \(i) round(i, digits = digits, ...)), crs = st_crs(x)), - error = function(e) { - if (! (are_points(xg) | are_linestrings(xg))) { - cli_abort(c( - "Unsupported geometry types.", - "i" = "st_rounds only supports {.cls POINT} and {.cls LINESTRING}." - )) - } - } - ) +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 } #' Convert a sfheaders data frame into sfc point geometries diff --git a/man/st_round.Rd b/man/st_round.Rd index 9191b9b9..4db8177c 100644 --- a/man/st_round.Rd +++ b/man/st_round.Rd @@ -2,27 +2,21 @@ % Please edit documentation in R/utils.R \name{st_round} \alias{st_round} -\title{Rounding of coordinates of point and linestring geometries} +\title{Rounding of geometry coordinates} \usage{ -st_round(x, digits = 0, ...) +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.} - -\item{...}{Additional arguments passed on to \code{\link{round}}.} } \value{ An object of class \code{\link[sf]{sf}} or \code{\link[sf]{sfc}} with rounded coordinates. } \description{ -Rounding of coordinates of point and linestring geometries -} -\note{ -Currently this function only works for \code{POINT} and -\code{LINESTRING} geometries. +Rounding of geometry coordinates } \examples{ library(sf, quietly = TRUE) From 73cfb7b996f69e57e2a1e4a23b0d60f4198455b2 Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Tue, 17 Sep 2024 17:57:07 +0200 Subject: [PATCH 118/246] refactor: Tidy :construction: --- R/create.R | 14 ++------------ R/geom.R | 11 +++++++++++ R/subdivide.R | 7 +++---- R/utils.R | 17 +++++++++++++++++ 4 files changed, 33 insertions(+), 16 deletions(-) diff --git a/R/create.R b/R/create.R index b7afba7c..4b21b3a9 100644 --- a/R/create.R +++ b/R/create.R @@ -444,7 +444,7 @@ create_from_spatial_lines = function(x, directed = TRUE, # These will form the nodes of the network. node_coords = linestring_boundary_points(edges, return_df = TRUE) # Give each unique location a unique ID. - indices = st_match_points_df(node_coords, st_precision(x)) + indices = st_match_points_df(node_coords, attr(x, "precision")) # 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. @@ -457,17 +457,7 @@ create_from_spatial_lines = function(x, directed = TRUE, # 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 in the nodes as in the edges. - geom_colname = attr(edges, "sf_column") - if (geom_colname != "geometry") { - names(nodes)[1] = geom_colname - attr(nodes, "sf_column") = geom_colname - } - # Use the same class for the nodes as for the edges. - # This mainly affects the "lower level" classes. - # For example an sf tibble instead of a sf data frame. - class(nodes) = class(edges) + 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, diff --git a/R/geom.R b/R/geom.R index 18ceff7c..9ab9a4e1 100644 --- a/R/geom.R +++ b/R/geom.R @@ -142,6 +142,12 @@ pull_edge_geom = function(x, focused = FALSE) { #' 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 @@ -180,11 +186,16 @@ mutate_node_geom = function(x, y, focused = FALSE) { #' @noRd 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 } diff --git a/R/subdivide.R b/R/subdivide.R index d5a02160..e286c22d 100644 --- a/R/subdivide.R +++ b/R/subdivide.R @@ -22,7 +22,7 @@ #' #' @importFrom dplyr arrange bind_rows #' @importFrom igraph is_directed -#' @importFrom sf st_as_sf st_geometry<- +#' @importFrom sf st_geometry<- #' @importFrom sfheaders sf_to_df #' @noRd subdivide = function(x, merge_equal = TRUE) { @@ -62,7 +62,7 @@ subdivide = function(x, merge_equal = TRUE) { # Compute for each edge point a unique location index. # Edge points that are spatially equal get the same location index. edge_coords = edge_pts[names(edge_pts) %in% c("x", "y", "z", "m")] - edge_lids = st_match_points_df(edge_coords, st_precision(edges)) + edge_lids = st_match_points_df(edge_coords, attr(edges, "precision")) edge_pts$lid = edge_lids # Define which edge points are not unique. has_duplicate = duplicated(edge_lids) | duplicated(edge_lids, fromLast = TRUE) @@ -177,8 +177,7 @@ subdivide = function(x, merge_equal = TRUE) { 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 = st_as_sf(add_node_geoms) - st_geometry(add_nodes) = attr(nodes, "sf_column") # Use same column name. + 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 diff --git a/R/utils.R b/R/utils.R index 16d71046..45a1dce5 100644 --- a/R/utils.R +++ b/R/utils.R @@ -108,6 +108,23 @@ st_round = function(x, digits = 0) { x } +#' Convert a sfc object into a sf object. +#' +#' @param x An object of class \code{\link[sf]{sfc}}. +#' +#' @param colname The name that should be given to the geometry column. +#' +#' @return An object of class \code{\link[sf]{sf}}. +#' +#' @importFrom sf st_as_sf +#' @noRd +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 +} + #' Convert a sfheaders data frame into sfc point geometries #' #' @param x_df An object of class \code{\link{data.frame}} as constructed by From c3c9f88f73bfcbe6a6a5869f854dd226adaa6378 Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Wed, 18 Sep 2024 12:13:34 +0200 Subject: [PATCH 119/246] refactor: Put weight evaluation in separate function :construction: --- R/messages.R | 11 +++++++---- R/paths.R | 37 +++++-------------------------------- R/weight.R | 17 +++++++++++++++++ 3 files changed, 29 insertions(+), 36 deletions(-) create mode 100644 R/weight.R diff --git a/R/messages.R b/R/messages.R index f26e999e..0559296d 100644 --- a/R/messages.R +++ b/R/messages.R @@ -148,10 +148,13 @@ deprecate_edges_as_lines = function() { } #' @importFrom lifecycle deprecate_warn -deprecate_weights_is_string = function(caller) { +deprecate_weights_is_string = function() { deprecate_warn( when = "v1.0", - what = paste0(caller, "(weights = 'uses tidy evaluation')"), + what = paste0( + "evaluate_weight_spec", + "(weights = 'uses tidy evaluation')" + ), details = c( i = paste( "This means you can forward column names without quotations, e.g.", @@ -164,11 +167,11 @@ deprecate_weights_is_string = function(caller) { } #' @importFrom lifecycle deprecate_warn -deprecate_weights_is_null = function(caller) { +deprecate_weights_is_null = function() { deprecate_warn( when = "v1.0", what = paste0( - caller, + "evaluate_weight_spec", "(weights = 'if set to NULL means no edge weights are used')" ), details = c( diff --git a/R/paths.R b/R/paths.R index 696facb3..41782b80 100644 --- a/R/paths.R +++ b/R/paths.R @@ -179,9 +179,8 @@ st_network_paths = function(x, from, to = node_ids(x), } #' @importFrom igraph vertex_attr vertex_attr_names -#' @importFrom rlang enquo eval_tidy expr has_name +#' @importFrom rlang has_name #' @importFrom sf st_as_sf -#' @importFrom tidygraph .E .register_graph_context #' @export st_network_paths.sfnetwork = function(x, from, to = node_ids(x), weights = edge_length(), @@ -196,20 +195,8 @@ st_network_paths.sfnetwork = function(x, from, to = node_ids(x), if (length(from) > 1) raise_multiple_elements("from"); from = from[1] if (any(is.na(from))) raise_na_values("from") if (any(is.na(to))) raise_na_value("to") - # Parse weights argument using tidy evaluation on the network edges. - .register_graph_context(x, free = TRUE) - weights = enquo(weights) - weights = eval_tidy(weights, .E()) - if (is_single_string(weights)) { - # Allow character values for backward compatibility. - deprecate_weights_is_string("st_network_paths") - 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("st_network_paths") - weights = NA - } + # Evaluate the given weights specification. + weights = evaluate_weight_spec(x, weights) # Compute the shortest paths. paths = igraph_paths(x, from, to, weights, type, direction, ...) # Convert node indices to node names if requested. @@ -426,8 +413,6 @@ st_network_cost = function(x, from = node_ids(x), to = node_ids(x), #' @importFrom igraph distances #' @importFrom methods hasArg -#' @importFrom rlang enquo eval_tidy expr -#' @importFrom tidygraph .E .register_graph_context #' @importFrom units as_units deparse_unit #' @export st_network_cost.sfnetwork = function(x, from = node_ids(x), to = node_ids(x), @@ -440,20 +425,8 @@ st_network_cost.sfnetwork = function(x, from = node_ids(x), to = node_ids(x), if (is_sf(from) | is_sfc(from)) from = nearest_node_ids(x, from) if (is_sf(to) | is_sfc(to)) to = nearest_node_ids(x, to) if (any(is.na(c(from, to)))) raise_na_values("from and/or to") - # Parse weights argument using tidy evaluation on the network edges. - .register_graph_context(x, free = TRUE) - weights = enquo(weights) - weights = eval_tidy(weights, .E()) - if (is_single_string(weights)) { - # Allow character values for backward compatibility. - deprecate_weights_is_string("st_network_cost") - 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("st_network_cost") - weights = NA - } + # Evaluate the given weights specification. + weights = evaluate_weight_spec(x, weights) # Parse other arguments. # --> The direction argument is used instead of igraphs mode argument. # --> This means the mode argument should not be set. diff --git a/R/weight.R b/R/weight.R new file mode 100644 index 00000000..e998a074 --- /dev/null +++ b/R/weight.R @@ -0,0 +1,17 @@ +#' @importFrom rlang enquo eval_tidy expr +#' @importFrom tidygraph .E .register_graph_context +evaluate_weight_spec = function(data, weights) { + .register_graph_context(data, free = TRUE) + weights = eval_tidy(enquo(weights), .E()) + 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 + } + weights +} \ No newline at end of file From 1d9878f702b2f9f8579a1c9e0deb44a02adba5f0 Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Wed, 18 Sep 2024 12:36:30 +0200 Subject: [PATCH 120/246] feat: Implement tidy evaluation of from and to arguments :gift: --- NAMESPACE | 1 + R/morphers.R | 5 ----- R/node.R | 28 ++++++++++++++++++++++++++++ R/paths.R | 23 +++++++++++------------ 4 files changed, 40 insertions(+), 17 deletions(-) diff --git a/NAMESPACE b/NAMESPACE index d8c41716..187f3761 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -295,6 +295,7 @@ 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) diff --git a/R/morphers.R b/R/morphers.R index c5b1282b..555492fc 100644 --- a/R/morphers.R +++ b/R/morphers.R @@ -275,11 +275,6 @@ to_spatial_explicit = function(x, ...) { #' @importFrom units as_units deparse_unit #' @export to_spatial_neighborhood = function(x, node, threshold, ...) { - # 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 = nearest_node_ids(x, node) - if (length(node) > 1) raise_multiple_elements("node") # Compute the cost matrix from the source node. # By calling st_network_cost with the given arguments. if (hasArg("from")) { diff --git a/R/node.R b/R/node.R index f763a8d6..13de2f91 100644 --- a/R/node.R +++ b/R/node.R @@ -1,3 +1,31 @@ +#' @importFrom cli cli_abort +#' @importFrom igraph vertex_attr +#' @importFrom rlang enquo eval_tidy +#' @importFrom tidygraph .N .register_graph_context +evaluate_node_query = function(data, nodes) { + .register_graph_context(data, free = TRUE) + nodes = eval_tidy(enquo(nodes), .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) + } + nodes +} + #' Query node coordinates #' #' These functions allow to query specific coordinate values from the diff --git a/R/paths.R b/R/paths.R index 41782b80..4d73234e 100644 --- a/R/paths.R +++ b/R/paths.R @@ -187,14 +187,13 @@ st_network_paths.sfnetwork = function(x, from, to = node_ids(x), type = "shortest", direction = "out", use_names = TRUE, return_cost = TRUE, return_geometry = TRUE, ...) { - # Parse from and to arguments. - # --> Convert geometries to node indices. - # --> Raise warnings when igraph requirements are not met. - if (is_sf(from) | is_sfc(from)) from = nearest_node_ids(x, from) - if (is_sf(to) | is_sfc(to)) to = nearest_node_ids(x, to) + # Evaluate the given from node query. + from = evaluate_node_query(from) if (length(from) > 1) raise_multiple_elements("from"); from = from[1] if (any(is.na(from))) raise_na_values("from") - if (any(is.na(to))) raise_na_value("to") + # Evaluate the given to node query. + to = evaluate_node_query(to) + if (any(is.na(to))) raise_na_values("to") # Evaluate the given weights specification. weights = evaluate_weight_spec(x, weights) # Compute the shortest paths. @@ -419,12 +418,12 @@ st_network_cost.sfnetwork = function(x, from = node_ids(x), to = node_ids(x), weights = edge_length(), direction = "out", Inf_as_NaN = FALSE, ...) { - # Parse from and to arguments. - # --> Convert geometries to node indices. - # --> Raise warnings when igraph requirements are not met. - if (is_sf(from) | is_sfc(from)) from = nearest_node_ids(x, from) - if (is_sf(to) | is_sfc(to)) to = nearest_node_ids(x, to) - if (any(is.na(c(from, to)))) raise_na_values("from and/or to") + # Evaluate the given from node query. + from = evaluate_node_query(from) + if (any(is.na(from))) raise_na_values("from") + # Evaluate the given to node query. + to = evaluate_node_query(to) + if (any(is.na(to))) raise_na_values("to") # Evaluate the given weights specification. weights = evaluate_weight_spec(x, weights) # Parse other arguments. From 8ccf380b6a80768ac5bcfe6500682866adcda3cf Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Wed, 18 Sep 2024 12:40:45 +0200 Subject: [PATCH 121/246] feat: Implement tidy evaluation of protect argument :gift: --- R/morphers.R | 30 ++---------------------------- 1 file changed, 2 insertions(+), 28 deletions(-) diff --git a/R/morphers.R b/R/morphers.R index 555492fc..38a9ca29 100644 --- a/R/morphers.R +++ b/R/morphers.R @@ -478,34 +478,8 @@ to_spatial_smooth = function(x, ## =========================== # 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)) { - cli_abort(c( - "Failed to identify protected nodes by their name.", - "x" = "There is not node attribute {.field name}" - )) - } - # Match node names to node indices. - matched_names = match(protect, node_names) - if (any(is.na(matched_names))) { - unknown_names = paste(protect[is.na(matched_names)], collapse = ", ") - cli_abort(c( - "Failed to identify protected nodes by their name.", - "x" = "The following node names were not found: {unknown_names}" - )) - } - protect = matched_names - } else if (is_sf(protect) | is_sfc(protect)) { - protect = nearest_node_ids(x, protect) - } + # Evaluate the given protected nodes query. + protect = evaluate_node_query(x, protect) # Mark all protected nodes as not being a pseudo node. pseudo[protect] = FALSE if (! any(pseudo)) return (x) From 997313d5d576dd9fbca0aa8f3ea0b1872396a57c Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Wed, 18 Sep 2024 12:41:09 +0200 Subject: [PATCH 122/246] feat: Enable edge querying using tidy evaluation :gift: --- R/edge.R | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/R/edge.R b/R/edge.R index c9ea2709..25bf02fd 100644 --- a/R/edge.R +++ b/R/edge.R @@ -1,3 +1,29 @@ +#' @importFrom cli cli_abort +#' @importFrom igraph edge_attr +#' @importFrom rlang enquo eval_tidy +#' @importFrom tidygraph .E .register_graph_context +evaluate_edge_query = function(data, edges) { + .register_graph_context(data, free = TRUE) + edges = eval_tidy(enquo(edges), .E()) + 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) + } + edges +} + #' Query spatial edge measures #' #' These functions are a collection of specific spatial edge measures, that From a30a0de6718a41a9cf26ab794d92c2d4d669e2a4 Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Wed, 18 Sep 2024 14:28:27 +0200 Subject: [PATCH 123/246] feat: Document, reorganize and export tidy evaluation of from, to and weights :gift: --- NAMESPACE | 3 + R/ids.R | 146 +++++++++++++++++++++++++++++++++++- R/morphers.R | 17 ++--- R/node.R | 28 ------- R/paths.R | 117 +++++++++-------------------- R/travel.R | 32 ++------ R/weight.R | 17 ----- R/weights.R | 60 +++++++++++++++ man/evaluate_edge_query.Rd | 53 +++++++++++++ man/evaluate_node_query.Rd | 53 +++++++++++++ man/evaluate_weight_spec.Rd | 48 ++++++++++++ man/ids.Rd | 6 +- man/spatial_morphers.Rd | 17 ++--- man/st_network_cost.Rd | 54 ++++--------- man/st_network_paths.Rd | 62 +++++---------- man/st_network_travel.Rd | 20 ++--- 16 files changed, 454 insertions(+), 279 deletions(-) delete mode 100644 R/weight.R create mode 100644 R/weights.R create mode 100644 man/evaluate_edge_query.Rd create mode 100644 man/evaluate_node_query.Rd create mode 100644 man/evaluate_weight_spec.Rd diff --git a/NAMESPACE b/NAMESPACE index 187f3761..71625f30 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -91,6 +91,9 @@ export(edge_is_within_distance) export(edge_length) export(edge_overlaps) export(edge_touches) +export(evaluate_edge_query) +export(evaluate_node_query) +export(evaluate_weight_spec) export(group_spatial) export(is.sfnetwork) export(is_sfnetwork) diff --git a/R/ids.R b/R/ids.R index a3486b4c..d20cb84e 100644 --- a/R/ids.R +++ b/R/ids.R @@ -1,4 +1,4 @@ -#' Extract the node or edge indices from a spatial network +#' Extract all node or edge indices from a spatial network #' #' @param x An object of class \code{\link{sfnetwork}}. #' @@ -9,7 +9,7 @@ #' @details The indices in these objects are always integers that correspond to #' rownumbers in respectively the nodes or edges table. #' -#' @return An vector of integers. +#' @return A vector of integers. #' #' @examples #' net = as_sfnetwork(roxel[1:10, ]) @@ -38,6 +38,148 @@ edge_ids = function(x, focused = TRUE) { } } +#' Query specific node indices from a spatial network +#' +#' @param data An object of class \code{\link{sfnetwork}}. +#' +#' @param query The query that defines for which nodes to extract indices. See +#' Details. +#' +#' @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 enquo eval_tidy +#' @importFrom tidygraph .N .register_graph_context +#' @export +evaluate_node_query = function(data, query) { + .register_graph_context(data, free = TRUE) + nodes = eval_tidy(enquo(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 +#' +#' @param data An object of class \code{\link{sfnetwork}}. +#' +#' @param query The query that defines for which edges to extract indices. See +#' Details. +#' +#' @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 enquo eval_tidy +#' @importFrom tidygraph .E .register_graph_context +#' @export +evaluate_edge_query = function(data, query) { + .register_graph_context(data, free = TRUE) + edges = eval_tidy(enquo(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}}. diff --git a/R/morphers.R b/R/morphers.R index 38a9ca29..946b5749 100644 --- a/R/morphers.R +++ b/R/morphers.R @@ -257,12 +257,9 @@ to_spatial_explicit = function(x, ...) { #' network. Returns a \code{morphed_sfnetwork} containing a single element of #' class \code{\link{sfnetwork}}. #' -#' @param node The node for which the neighborhood will be calculated. Can be -#' an integer specifying its index or a character specifying its name. Can also -#' be an object of class \code{\link[sf]{sf}} or \code{\link[sf]{sfc}} -#' containing a single spatial feature. In that case, this feature will be -#' snapped to its nearest node before calculating the neighborhood. When -#' multiple indices, names or features are given, only the first one is used. +#' @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 threshold The threshold distance to be used. Only nodes within the #' threshold distance from the reference node will be included in the @@ -412,12 +409,8 @@ to_spatial_simple = function(x, remove_multiple = TRUE, remove_loops = TRUE, #' 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. +#' are a pseudo node or not. Evaluated by \code{\link{evaluate_node_query}}. +#' 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}, diff --git a/R/node.R b/R/node.R index 13de2f91..f763a8d6 100644 --- a/R/node.R +++ b/R/node.R @@ -1,31 +1,3 @@ -#' @importFrom cli cli_abort -#' @importFrom igraph vertex_attr -#' @importFrom rlang enquo eval_tidy -#' @importFrom tidygraph .N .register_graph_context -evaluate_node_query = function(data, nodes) { - .register_graph_context(data, free = TRUE) - nodes = eval_tidy(enquo(nodes), .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) - } - nodes -} - #' Query node coordinates #' #' These functions allow to query specific coordinate values from the diff --git a/R/paths.R b/R/paths.R index 4d73234e..7123c2d8 100644 --- a/R/paths.R +++ b/R/paths.R @@ -7,30 +7,18 @@ #' #' @param x An object of class \code{\link{sfnetwork}}. #' -#' @param from The node where the paths should start. Can be an integer -#' specifying its index or a character specifying its name. Can also be an -#' object of class \code{\link[sf]{sf}} or \code{\link[sf]{sfc}} containing a -#' single spatial feature. In that case, this feature will be snapped to its -#' nearest node before finding the paths. When multiple indices, names or -#' features are given, only the first one is used. -#' -#' @param to The nodes where the paths should end. Can be an integer vector -#' specifying their indices or a character vector specifying their name. Can -#' also be an object of class \code{\link[sf]{sf}} or \code{\link[sf]{sfc}} -#' containing spatial features. In that case, these feature will be snapped to -#' their nearest node before finding the paths. 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}}. When multiple nodes are given, only the +#' first one is used. +#' +#' @param to The node where the paths should start. 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 of the same length as the number of edges, a -#' \link[=spatial_edge_measures]{spatial edge measure function}, or a column in -#' the edges table of the network. Tidy evaluation is used such that column -#' names can be specified as if they were variables in the environment (e.g. -#' simply \code{length} instead of \code{igraph::edge_attr(x, "length")}). -#' If set to \code{NULL} or \code{NA} no edge weights are used, and the -#' shortest path is the path with the fewest number of edges, ignoring space. -#' The default is \code{\link{edge_length}}, which computes the geographic -#' lengths of the edges. +#' Evaluated by \code{\link{evaluate_edge_spec}}. The default is +#' \code{\link{edge_length}}, which computes the geographic lengths of the +#' edges. #' #' @param type Character defining which type of path calculation should be #' performed. If set to \code{'shortest'} paths are found using @@ -65,30 +53,16 @@ #' Arguments \code{predecessors} and \code{inbound.edges} are ignored. #' Instead of the \code{mode} argument, use the \code{direction} argument. #' -#' @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. -#' -#' When computing simple paths by setting \code{type = 'all_simple'}, note that -#' potentially there are exponentially many paths between two nodes, and you -#' may run out of memory especially in undirected, dense, and/or lattice-like -#' networks. -#' -#' For more details on the wrapped igraph functions see the +#' @details For more details on the wrapped igraph functions see the #' \code{\link[igraph]{distances}} and #' \code{\link[igraph]{all_simple_paths}} documentation pages. #' -#' @seealso \code{\link{st_network_cost}} +#' @note When computing simple paths by setting \code{type = 'all_simple'}, +#' note that potentially there are exponentially many paths between two nodes, +#' and you may run out of memory especially in undirected, dense, and/or +#' lattice-like networks. +#' +#' @seealso \code{\link{st_network_cost}}, \code{\link{st_network_travel}} #' #' @return An object of class \code{\link[tibble]{tbl_df}} or #' \code{\link[sf]{sf}} with one row per path. If \code{type = 'shortest'}, the @@ -153,6 +127,9 @@ #' plot(c(p1, p2), col = "black", pch = 8, add = TRUE) #' plot(st_geometry(paths), col = "red", lwd = 1.5, 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()) @@ -296,30 +273,18 @@ igraph_paths = function(x, from, to, weights, type = "shortest", #' #' @param x An object of class \code{\link{sfnetwork}}. #' -#' @param from The nodes where the paths should start. Can be an integer vector -#' specifying their indices or a character vector specifying their name. Can -#' also be an object of class \code{\link[sf]{sf}} or \code{\link[sf]{sfc}} -#' containing spatial features. In that case, these feature will be snapped to -#' their nearest node before finding the paths. By default, all nodes in the -#' network are included. +#' @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. Can be an integer vector -#' specifying their indices or a character vector specifying their name. Can -#' also be an object of class \code{\link[sf]{sf}} or \code{\link[sf]{sfc}} -#' containing spatial features. In that case, these feature will be snapped to -#' their nearest node before finding the paths. 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. -#' Can be a numeric vector of the same length as the number of edges, a -#' \link[=spatial_edge_measures]{spatial edge measure function}, or a column in -#' the edges table of the network. Tidy evaluation is used such that column -#' names can be specified as if they were variables in the environment (e.g. -#' simply \code{length} instead of \code{igraph::edge_attr(x, "length")}). -#' If set to \code{NULL} or \code{NA} no edge weights are used, and the -#' shortest path is the path with the fewest number of edges, ignoring space. -#' The default is \code{\link{edge_length}}, which computes the geographic -#' lengths of the edges. +#' Evaluated by \code{\link{evaluate_edge_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 @@ -335,27 +300,10 @@ igraph_paths = function(x, from, to, weights, type = "shortest", #' @param ... Additional arguments passed on to \code{\link[igraph]{distances}}. #' Instead of the \code{mode} argument, use the \code{direction} argument. #' -#' @details \code{st_network_cost} allows to use any set of edge weights, while -#' \code{st_network_distance} is a intuitive synonym for cost matrix computation -#' in which the edge weights are set to their geographic length. -#' -#' 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 igraph function see the +#' @details For more details on the wrapped igraph function see the #' \code{\link[igraph]{distances}} documentation page. #' -#' @seealso \code{\link{st_network_paths}} +#' @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. @@ -383,6 +331,9 @@ igraph_paths = function(x, from, to, weights, type = "shortest", #' #' 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()) diff --git a/R/travel.R b/R/travel.R index 25accc0b..c5e9cd82 100644 --- a/R/travel.R +++ b/R/travel.R @@ -2,12 +2,8 @@ #' #' The travelling salesman problem is currently implemented #' -#' @param pois Locations that the travelling salesman will visit. -#' Can be an integer vector specifying nodes indices or a character vector -#' specifying node names. Can also be an object of class \code{\link[sf]{sf}} -#' or \code{\link[sf]{sfc}} containing spatial features. -#' In that case, these feature will be snapped to their nearest node before -#' solving the algorithm. +#' @param pois Locations that the travelling salesman will visit. Evaluated by +#' \code{\link{evaluate_node_query}}. #' #' @param return_paths Should the shortest paths between `pois` be computed? #' Defaults to `TRUE`. If `FALSE`, a vector with indices in the visiting order @@ -32,25 +28,11 @@ st_network_travel = function(x, pois, weights = edge_length(), return_cost = TRUE, return_geometry = TRUE, ...) { - # Parse pois argument. - # --> Convert geometries to node indices. - ### check # --> Raise warnings when requirements are not met. - if (is_sf(pois) | is_sfc(pois)) pois = nearest_node_ids(x, pois) - # if (any(is.na(pois))) raise_na_values("pois") - # Parse weights argument using tidy evaluation on the network edges. - .register_graph_context(x, free = TRUE) - weights = enquo(weights) - weights = eval_tidy(weights, .E()) - if (is_single_string(weights)) { - # Allow character values for backward compatibility. - deprecate_weights_is_string("st_network_travel") - 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("st_network_travel") - weights = NA - } + # Evaluate the node query for the pois. + pois = evaluate_node_query(pois) + if (any(is.na(pois))) raise_na_values("pois") + # Evaluate the given weights specification. + weights = evaluate_weight_spec(x, weights) # Compute cost matrix costmat = st_network_cost(x, from = pois, to = pois, weights = weights) # Use nearest node indices as row and column names diff --git a/R/weight.R b/R/weight.R deleted file mode 100644 index e998a074..00000000 --- a/R/weight.R +++ /dev/null @@ -1,17 +0,0 @@ -#' @importFrom rlang enquo eval_tidy expr -#' @importFrom tidygraph .E .register_graph_context -evaluate_weight_spec = function(data, weights) { - .register_graph_context(data, free = TRUE) - weights = eval_tidy(enquo(weights), .E()) - 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 - } - weights -} \ No newline at end of file diff --git a/R/weights.R b/R/weights.R new file mode 100644 index 00000000..7a2f4aa0 --- /dev/null +++ b/R/weights.R @@ -0,0 +1,60 @@ +#' Specify edge weights in a spatial network +#' +#' @param data An object of class \code{\link{sfnetwork}}. +#' +#' @param spec The specification that defines how to compute or extract edge +#' weights. See Details. +#' +#' @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. +#' } +#' +#' 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 enquo 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(enquo(spec), .E()) + 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 + } + if (length(weights) != 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 +} \ No newline at end of file diff --git a/man/evaluate_edge_query.Rd b/man/evaluate_edge_query.Rd new file mode 100644 index 00000000..8ea54764 --- /dev/null +++ b/man/evaluate_edge_query.Rd @@ -0,0 +1,53 @@ +% 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. See +Details.} +} +\value{ +A vector of queried edge indices. +} +\description{ +Query specific edge indices from a spatial network +} +\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..6993b6af --- /dev/null +++ b/man/evaluate_node_query.Rd @@ -0,0 +1,53 @@ +% 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. See +Details.} +} +\value{ +A vector of queried node indices. +} +\description{ +Query specific node indices from a spatial network +} +\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..e976e5af --- /dev/null +++ b/man/evaluate_weight_spec.Rd @@ -0,0 +1,48 @@ +% 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. See Details.} +} +\value{ +A numeric vector of edge weights. +} +\description{ +Specify edge weights in a spatial network +} +\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. +} + +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/ids.Rd b/man/ids.Rd index 35af42cc..82b52386 100644 --- a/man/ids.Rd +++ b/man/ids.Rd @@ -4,7 +4,7 @@ \alias{ids} \alias{node_ids} \alias{edge_ids} -\title{Extract the node or edge indices from a spatial network} +\title{Extract all node or edge indices from a spatial network} \usage{ node_ids(x, focused = TRUE) @@ -18,10 +18,10 @@ extracted? Defaults to \code{TRUE}. See \code{\link[tidygraph]{focus}} for more information on focused networks.} } \value{ -An vector of integers. +A vector of integers. } \description{ -Extract the node or edge indices from a spatial network +Extract all node or edge indices from a spatial network } \details{ The indices in these objects are always integers that correspond to diff --git a/man/spatial_morphers.Rd b/man/spatial_morphers.Rd index f87e871d..8ecad265 100644 --- a/man/spatial_morphers.Rd +++ b/man/spatial_morphers.Rd @@ -90,12 +90,9 @@ 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 node for which the neighborhood will be calculated. Can be -an integer specifying its index or a character specifying its name. Can also -be an object of class \code{\link[sf]{sf}} or \code{\link[sf]{sfc}} -containing a single spatial feature. In that case, this feature will be -snapped to its nearest node before calculating the neighborhood. When -multiple indices, names or features are given, only the first one is used.} +\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 distance to be used. Only nodes within the threshold distance from the reference node will be included in the @@ -109,12 +106,8 @@ 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.} +are a pseudo node or not. Evaluated by \code{\link{evaluate_node_query}}. +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}, diff --git a/man/st_network_cost.Rd b/man/st_network_cost.Rd index 8d8393e0..2370a475 100644 --- a/man/st_network_cost.Rd +++ b/man/st_network_cost.Rd @@ -27,30 +27,18 @@ st_network_distance( \arguments{ \item{x}{An object of class \code{\link{sfnetwork}}.} -\item{from}{The nodes where the paths should start. Can be an integer vector -specifying their indices or a character vector specifying their name. Can -also be an object of class \code{\link[sf]{sf}} or \code{\link[sf]{sfc}} -containing spatial features. In that case, these feature will be snapped to -their nearest node before finding the paths. By default, all nodes in the -network are included.} - -\item{to}{The nodes where the paths should end. Can be an integer vector -specifying their indices or a character vector specifying their name. Can -also be an object of class \code{\link[sf]{sf}} or \code{\link[sf]{sfc}} -containing spatial features. In that case, these feature will be snapped to -their nearest node before finding the paths. 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 of the same length as the number of edges, a -\link[=spatial_edge_measures]{spatial edge measure function}, or a column in -the edges table of the network. Tidy evaluation is used such that column -names can be specified as if they were variables in the environment (e.g. -simply \code{length} instead of \code{igraph::edge_attr(x, "length")}). -If set to \code{NULL} or \code{NA} no edge weights are used, and the -shortest path is the path with the fewest number of edges, ignoring space. -The default is \code{\link{edge_length}}, which computes the geographic -lengths of the edges.} +Evaluated by \code{\link{evaluate_edge_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 computed @@ -75,23 +63,6 @@ Compute total travel costs of shortest paths between nodes in a spatial network. } \details{ -\code{st_network_cost} allows to use any set of edge weights, while -\code{st_network_distance} is a intuitive synonym for cost matrix computation -in which the edge weights are set to their geographic length. - -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 igraph function see the \code{\link[igraph]{distances}} documentation page. } @@ -118,6 +89,9 @@ 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()) @@ -140,5 +114,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_paths.Rd b/man/st_network_paths.Rd index 4baf9085..673335a1 100644 --- a/man/st_network_paths.Rd +++ b/man/st_network_paths.Rd @@ -20,30 +20,18 @@ st_network_paths( \arguments{ \item{x}{An object of class \code{\link{sfnetwork}}.} -\item{from}{The node where the paths should start. Can be an integer -specifying its index or a character specifying its name. Can also be an -object of class \code{\link[sf]{sf}} or \code{\link[sf]{sfc}} containing a -single spatial feature. In that case, this feature will be snapped to its -nearest node before finding the paths. When multiple indices, names or -features are given, only the first one is used.} - -\item{to}{The nodes where the paths should end. Can be an integer vector -specifying their indices or a character vector specifying their name. Can -also be an object of class \code{\link[sf]{sf}} or \code{\link[sf]{sfc}} -containing spatial features. In that case, these feature will be snapped to -their nearest node before finding the paths. 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}}. When multiple nodes are given, only the +first one is used.} + +\item{to}{The node where the paths should start. 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 of the same length as the number of edges, a -\link[=spatial_edge_measures]{spatial edge measure function}, or a column in -the edges table of the network. Tidy evaluation is used such that column -names can be specified as if they were variables in the environment (e.g. -simply \code{length} instead of \code{igraph::edge_attr(x, "length")}). -If set to \code{NULL} or \code{NA} no edge weights are used, and the -shortest path is the path with the fewest number of edges, ignoring space. -The default is \code{\link{edge_length}}, which computes the geographic -lengths of the edges.} +Evaluated by \code{\link{evaluate_edge_spec}}. The default is +\code{\link{edge_length}}, which computes the geographic lengths of the +edges.} \item{type}{Character defining which type of path calculation should be performed. If set to \code{'shortest'} paths are found using @@ -113,29 +101,16 @@ paths, or all simple paths between one node and one or more other nodes in the 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. - -When computing simple paths by setting \code{type = 'all_simple'}, note that -potentially there are exponentially many paths between two nodes, and you -may run out of memory especially in undirected, dense, and/or lattice-like -networks. - For more details on the wrapped igraph functions see the \code{\link[igraph]{distances}} and \code{\link[igraph]{all_simple_paths}} documentation pages. } +\note{ +When computing simple paths by setting \code{type = 'all_simple'}, +note that potentially there are exponentially many paths between two nodes, +and you may run out of memory especially in undirected, dense, and/or +lattice-like networks. +} \examples{ library(sf, quietly = TRUE) library(tidygraph, quietly = TRUE) @@ -172,6 +147,9 @@ plot(net, col = "grey") plot(c(p1, p2), col = "black", pch = 8, add = TRUE) plot(st_geometry(paths), col = "red", lwd = 1.5, 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()) @@ -191,5 +169,5 @@ 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 index e9e9fc64..294b6034 100644 --- a/man/st_network_travel.Rd +++ b/man/st_network_travel.Rd @@ -19,23 +19,13 @@ st_network_travel( \arguments{ \item{x}{An object of class \code{\link{sfnetwork}}.} -\item{pois}{Locations that the travelling salesman will visit. -Can be an integer vector specifying nodes indices or a character vector -specifying node names. Can also be an object of class \code{\link[sf]{sf}} -or \code{\link[sf]{sfc}} containing spatial features. -In that case, these feature will be snapped to their nearest node before -solving the algorithm.} +\item{pois}{Locations that the travelling salesman will visit. Evaluated by +\code{\link{evaluate_node_query}}.} \item{weights}{The edge weights to be used in the shortest path calculation. -Can be a numeric vector of the same length as the number of edges, a -\link[=spatial_edge_measures]{spatial edge measure function}, or a column in -the edges table of the network. Tidy evaluation is used such that column -names can be specified as if they were variables in the environment (e.g. -simply \code{length} instead of \code{igraph::edge_attr(x, "length")}). -If set to \code{NULL} or \code{NA} no edge weights are used, and the -shortest path is the path with the fewest number of edges, ignoring space. -The default is \code{\link{edge_length}}, which computes the geographic -lengths of the edges.} +Evaluated by \code{\link{evaluate_edge_spec}}. The default is +\code{\link{edge_length}}, which computes the geographic lengths of the +edges.} \item{return_paths}{Should the shortest paths between `pois` be computed? Defaults to `TRUE`. If `FALSE`, a vector with indices in the visiting order From 319a565d05670459329847aeaed341a1adbbb455 Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Wed, 18 Sep 2024 15:12:09 +0200 Subject: [PATCH 124/246] refactor: Reorganize routing functions :construction: --- R/cost.R | 161 ++++++++++++++++++++++++++++++++++++++ R/paths.R | 172 +++++------------------------------------ R/travel.R | 23 +++--- man/st_network_cost.Rd | 2 +- 4 files changed, 191 insertions(+), 167 deletions(-) create mode 100644 R/cost.R diff --git a/R/cost.R b/R/cost.R new file mode 100644 index 00000000..6d8ddc61 --- /dev/null +++ b/R/cost.R @@ -0,0 +1,161 @@ +#' 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_edge_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 ... Additional arguments passed on to \code{\link[igraph]{distances}}. +#' Instead of the \code{mode} argument, use the \code{direction} argument. +#' +#' @details For more details on the wrapped igraph function see the +#' \code{\link[igraph]{distances}} documentation page. +#' +#' @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 = NULL) +#' +#' # 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, ...) { + UseMethod("st_network_cost") +} + +#' @export +st_network_cost.sfnetwork = function(x, from = node_ids(x), to = node_ids(x), + weights = edge_length(), + direction = "out", + Inf_as_NaN = FALSE, ...) { + # Evaluate the given from node query. + from = evaluate_node_query(from) + if (any(is.na(from))) raise_na_values("from") + # Evaluate the given to node query. + to = evaluate_node_query(to) + if (any(is.na(to))) raise_na_values("to") + # Evaluate the given weights specification. + weights = evaluate_weight_spec(x, weights) + # Parse other arguments. + # --> 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") + # Compute the cost matrix. + compute_costs( + x, from, to, weights, + direction = direction, + Inf_as_NaN = Inf_as_NaN, + ... + ) +} + +#' @name st_network_cost +#' @export +st_network_distance = function(x, from = node_ids(x), to = node_ids(x), + direction = "out", Inf_as_NaN = FALSE, ...) { + st_network_cost( + x, from, to, + weights = edge_length(), + direction = direction, + Inf_as_NaN = Inf_as_NaN, + ... + ) +} + +#' @importFrom igraph distances +#' @importFrom methods hasArg +#' @importFrom units as_units deparse_unit +compute_costs = function(x, from, to, weights, direction = "out", + Inf_as_NaN = FALSE, ...) { + # Call the 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) + matrix = distances(x, from, tou, weights = weights, mode = direction, ...) + matrix = matrix[, match(to, tou), drop = FALSE] + } else { + matrix = distances(x, from, to, weights = weights, mode = direction, ...) + } + # Post-process and return. + # --> Convert Inf to NaN if requested. + # --> Attach units if the provided weights had units. + if (Inf_as_NaN) matrix[is.infinite(matrix)] = NaN + if (inherits(weights, "units")) { + as_units(matrix, deparse_unit(weights)) + } else { + matrix + } +} diff --git a/R/paths.R b/R/paths.R index 7123c2d8..fa222b41 100644 --- a/R/paths.R +++ b/R/paths.R @@ -155,9 +155,6 @@ st_network_paths = function(x, from, to = node_ids(x), UseMethod("st_network_paths") } -#' @importFrom igraph vertex_attr vertex_attr_names -#' @importFrom rlang has_name -#' @importFrom sf st_as_sf #' @export st_network_paths.sfnetwork = function(x, from, to = node_ids(x), weights = edge_length(), @@ -174,6 +171,24 @@ st_network_paths.sfnetwork = function(x, from, to = node_ids(x), # Evaluate the given weights specification. weights = evaluate_weight_spec(x, weights) # Compute the shortest paths. + find_paths( + x, from, to, weights, + type = type, + direction = direction, + use_names = use_names, + return_cost = return_cost, + return_geometry = return_geometry, + ... + ) +} + +#' @importFrom igraph vertex_attr vertex_attr_names +#' @importFrom rlang has_name +#' @importFrom sf st_as_sf +find_paths = function(x, from, to, weights, type = "shortest", + direction = "out", use_names = TRUE, return_cost = TRUE, + return_geometry = TRUE, ...) { + # Find paths with the given router. paths = igraph_paths(x, from, to, weights, type, direction, ...) # Convert node indices to node names if requested. if (use_names && "name" %in% vertex_attr_names(x)) { @@ -265,154 +280,3 @@ igraph_paths = function(x, from, to, weights, type = "shortest", path_found = path_found ) } - -#' 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_edge_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 ... Additional arguments passed on to \code{\link[igraph]{distances}}. -#' Instead of the \code{mode} argument, use the \code{direction} argument. -#' -#' @details For more details on the wrapped igraph function see the -#' \code{\link[igraph]{distances}} documentation page. -#' -#' @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 = NULL) -#' -#' # 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, ...) { - UseMethod("st_network_cost") -} - -#' @importFrom igraph distances -#' @importFrom methods hasArg -#' @importFrom units as_units deparse_unit -#' @export -st_network_cost.sfnetwork = function(x, from = node_ids(x), to = node_ids(x), - weights = edge_length(), - direction = "out", - Inf_as_NaN = FALSE, ...) { - # Evaluate the given from node query. - from = evaluate_node_query(from) - if (any(is.na(from))) raise_na_values("from") - # Evaluate the given to node query. - to = evaluate_node_query(to) - if (any(is.na(to))) raise_na_values("to") - # Evaluate the given weights specification. - weights = evaluate_weight_spec(x, weights) - # Parse other arguments. - # --> 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 the 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) - matrix = distances(x, from, tou, weights = weights, mode = direction, ...) - matrix = matrix[, match(to, tou), drop = FALSE] - } else { - matrix = distances(x, from, to, weights = weights, mode = direction, ...) - } - # Post-process and return. - # --> Convert Inf to NaN if requested. - # --> Attach units if the provided weights had units. - if (Inf_as_NaN) matrix[is.infinite(matrix)] = NaN - if (inherits(weights, "units")) { - as_units(matrix, deparse_unit(weights)) - } else { - matrix - } -} - -#' @name st_network_cost -#' @export -st_network_distance = function(x, from = node_ids(x), to = node_ids(x), - direction = "out", Inf_as_NaN = FALSE, ...) { - st_network_cost( - x, from, to, - weights = edge_length(), - direction = direction, - Inf_as_NaN = Inf_as_NaN, - ... - ) -} diff --git a/R/travel.R b/R/travel.R index c5e9cd82..e3f5b875 100644 --- a/R/travel.R +++ b/R/travel.R @@ -34,7 +34,7 @@ st_network_travel = function(x, pois, weights = edge_length(), # Evaluate the given weights specification. weights = evaluate_weight_spec(x, weights) # Compute cost matrix - costmat = st_network_cost(x, from = pois, to = pois, weights = weights) + costmat = compute_costs(x, from = pois, to = pois, weights = weights) # Use nearest node indices as row and column names row.names(costmat) = pois colnames(costmat) = pois @@ -56,18 +56,17 @@ st_network_travel = function(x, pois, weights = edge_length(), # 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. - bind_rows( - Map( - \(...) st_network_paths(x = net, ..., - weights = weights, - use_names = use_names, - return_cost = return_cost, - return_geometry = return_geometry), - from = from_idxs, - to = to_idxs + find_leg = function(...) { + find_paths( + x = net, + ..., + weights = weights, + use_names = use_names, + return_cost = return_cost, + return_geometry = return_geometry ) - ) + } + bind_rows(Map(find_leg, from = from_idxs, to = to_idxs)) } } diff --git a/man/st_network_cost.Rd b/man/st_network_cost.Rd index 2370a475..49f42e2e 100644 --- a/man/st_network_cost.Rd +++ b/man/st_network_cost.Rd @@ -1,5 +1,5 @@ % 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} From 7a5123136286a669ffcf5e6cf2dec3e000182a42 Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Thu, 19 Sep 2024 17:35:37 +0200 Subject: [PATCH 125/246] refactor: Restructure spatial morphers :construction: --- NAMESPACE | 6 +- R/attrs.R | 8 + R/contract.R | 157 ++++++++ R/data.R | 38 ++ R/edge.R | 184 +++++---- R/morphers.R | 741 ++++++------------------------------- R/simplify.R | 95 +++++ R/smooth.R | 405 ++++++++++++++++++++ R/subdivide.R | 29 +- man/autoplot.Rd | 2 +- man/contract_nodes.Rd | 63 ++++ man/make_edges_directed.Rd | 31 ++ man/make_edges_explicit.Rd | 16 +- man/simplify_network.Rd | 51 +++ man/smooth_pseudo_nodes.Rd | 56 +++ man/spatial_morphers.Rd | 107 +++--- man/subdivide_edges.Rd | 31 ++ 17 files changed, 1250 insertions(+), 770 deletions(-) create mode 100644 R/contract.R create mode 100644 R/simplify.R create mode 100644 R/smooth.R create mode 100644 man/contract_nodes.Rd create mode 100644 man/make_edges_directed.Rd create mode 100644 man/simplify_network.Rd create mode 100644 man/smooth_pseudo_nodes.Rd create mode 100644 man/subdivide_edges.Rd diff --git a/NAMESPACE b/NAMESPACE index 71625f30..496755f2 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -67,6 +67,7 @@ export("%>%") export(activate) export(active) export(as_sfnetwork) +export(contract_nodes) export(convert) export(create_from_spatial_lines) export(create_from_spatial_points) @@ -97,6 +98,7 @@ export(evaluate_weight_spec) export(group_spatial) export(is.sfnetwork) export(is_sfnetwork) +export(make_edges_directed) export(make_edges_explicit) export(make_edges_follow_indices) export(make_edges_valid) @@ -126,6 +128,8 @@ export(play_spatial) export(sf_attr) export(sfnetwork) export(sfnetwork_to_nb) +export(simplify_network) +export(smooth_pseudo_nodes) export(st_duplicated) export(st_match) export(st_network_bbox) @@ -136,6 +140,7 @@ export(st_network_join) export(st_network_paths) export(st_network_travel) export(st_round) +export(subdivide_edges) export(to_spatial_contracted) export(to_spatial_directed) export(to_spatial_explicit) @@ -163,7 +168,6 @@ importFrom(dplyr,bind_rows) importFrom(dplyr,full_join) importFrom(dplyr,group_by) importFrom(dplyr,group_indices) -importFrom(dplyr,group_size) importFrom(dplyr,join_by) importFrom(dplyr,mutate) importFrom(graphics,plot) diff --git a/R/attrs.R b/R/attrs.R index 1a76887d..974ec41d 100644 --- a/R/attrs.R +++ b/R/attrs.R @@ -105,6 +105,14 @@ edge_agr = function(x) { x } +update_node_agr = function(x) { + node_agr(x) = node_agr(x) +} + +update_edge_agr = function(x) { + edge_agr(x) = edge_agr(x) +} + #' Create an empty agr factor #' #' @param names A character vector containing the names that should be present diff --git a/R/contract.R b/R/contract.R new file mode 100644 index 00000000..0ed916be --- /dev/null +++ b/R/contract.R @@ -0,0 +1,157 @@ +#' 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 summarise_attributes 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, + summarise_attributes = "concat", + store_original_ids = FALSE, + store_original_data = FALSE) { + # Add a index column if not present. + if (! ".tidygraph_node_index" %in% vertex_attr_names(x)) { + vertex_attr(x, ".tidygraph_node_index") = seq_len(1:n_nodes(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(summarise_attributes, "list")) { + summarise_attributes = list(summarise_attributes) + } + summarise_attributes[".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, summarise_attributes)) + ## ======================================= + # 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 = delete_vertex_attr(x_new, ".tidygraph_node_index") + } + x_new +} \ No newline at end of file diff --git a/R/data.R b/R/data.R index 27e0e0e8..0cc44b01 100644 --- a/R/data.R +++ b/R/data.R @@ -142,3 +142,41 @@ edge_colnames = function(x, idxs = FALSE, geom = TRUE) { 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/edge.R b/R/edge.R index 25bf02fd..d56198a6 100644 --- a/R/edge.R +++ b/R/edge.R @@ -367,6 +367,124 @@ evaluate_edge_predicate = function(predicate, x, y, ...) { 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 undirected network as 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 An 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 +} + +#' 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 +} + +#' 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 @@ -379,9 +497,6 @@ evaluate_edge_predicate = function(predicate, x, y, ...) { #' @param preserve_geometries Should the edge geometries remain unmodified? #' Defaults to \code{FALSE}. See Details. #' -#' @return An object of class \code{\link{sfnetwork}} with corrected edge -#' geometries. -#' #' @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 @@ -397,6 +512,9 @@ evaluate_edge_predicate = function(predicate, x, y, ...) { #' 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) { @@ -476,63 +594,3 @@ replace_invalid_edge_boundaries = function(x) { # Update the geometries of the edges table. mutate_edge_geom(x, df_to_lines(as.data.frame(E_new), edges, id_col = "id")) } - -#' Construct edge geometries for spatially implicit networks -#' -#' This function turns spatially implicit networks into spatially explicit -#' networks by adding a geometry column to the edges data containing straight -#' lines between the source and target nodes. -#' -#' @param x An object of class \code{\link{sfnetwork}}. -#' -#' @return An object of class \code{\link{sfnetwork}} with spatially explicit -#' edges. If \code{x} was already spatially explicit it is returned unmodified. -#' -#' @importFrom 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 draw straight lines between the boundary nodes of edges. - bounds = edge_incident_geoms(x, list = TRUE) - mutate_edge_geom(x, draw_lines(bounds[[1]], bounds[[2]])) -} - -#' 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}}. -#' -#' @return An object of class \code{\link{sfnetwork}} with updated edge -#' geometries. -#' -#' @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. -#' -#' @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) -} diff --git a/R/morphers.R b/R/morphers.R index 946b5749..01a21cdc 100644 --- a/R/morphers.R +++ b/R/morphers.R @@ -2,63 +2,57 @@ #' #' 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 summarise_attributes 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 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 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 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 -#' \code{\link[igraph]{igraph-attribute-combination}} for details. +#' @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,9 +60,9 @@ 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 by -#' default as geometry of the contracted node. If edges are spatially explicit, -#' edge geometries are updated accordingly such that the valid spatial network +#' 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}}. #' @@ -85,108 +79,30 @@ NULL #' 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 -#' @importFrom igraph contract delete_edges delete_vertex_attr is_directed -#' which_loop which_multiple -#' @importFrom sf st_as_sf st_centroid st_combine st_drop_geometry st_geometry -#' @importFrom tibble as_tibble -#' @importFrom tidygraph as_tbl_graph +#' @importFrom dplyr group_by group_indices +#' @importFrom sf st_drop_geometry #' @export to_spatial_contracted = function(x, ..., simplify = TRUE, compute_centroids = TRUE, summarise_attributes = "ignore", store_original_data = FALSE) { - # Retrieve nodes from the network. - # Extract specific information from them. - nodes = nodes_as_sf(x) - node_data = st_drop_geometry(nodes) - node_geom = st_geometry(nodes) - node_geomcol = 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. - ## ======================= - node_data = group_by(node_data, ...) - group_ids = group_indices(node_data) - # If no group contains more than one node simply return x. - if (all(group_size(node_data) == 1)) return(list(contracted = x)) - ## =========================== - # STEP II: CONTRACT THE NODES - # Contract the nodes in the network using igraph::contract. - # Use the extracted group indices as mapping. - ## =========================== - # Update the attribute summary instructions. - # During morphing tidygraph adds 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" - # 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, group_ids, summarise_attributes)) - ## ====================================================== - # STEP III: 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", 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, group_ids) - new_node_geom = do.call("c", lapply(grouped_geoms, centroid)) - } else { - new_node_geom = node_geom[!duplicated(group_ids)] - } - new_nodes[node_geomcol] = list(new_node_geom) - # 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 } - grouped_data = split(nodes, group_ids) - new_nodes$.orig_data = lapply(grouped_data, drop_index) - } - # Update the nodes table of the contracted network. - new_nodes = st_as_sf(new_nodes, sf_column_name = node_geomcol) - node_data(x_new) = new_nodes - # Convert to a sfnetwork. - x_new = tbg_to_sfn(x_new) - ## =============================================================== - # STEP IV: RECONNECT THE EDGE GEOMETRIES OF THE CONTRACTED NETWORK - # 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. - ## =============================================================== - # 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. - # The boundaries of the edges will be replaced by the new node geometries. - if (has_explicit_edges(x)) { - if (! is_directed(x)) { - x_new = make_edges_follow_indices(x_new) - } - x_new = make_edges_valid(x_new) - } + # 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, + summarise_attributes = summarise_attributes, + 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 ) } @@ -198,27 +114,10 @@ to_spatial_contracted = function(x, ..., simplify = TRUE, #' 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) { - 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. - x_new = sfnetwork_(nodes, edges, directed = TRUE) - # Return in a list. list( - directed = x_new %preserve_network_attrs% x + directed = make_edges_directed(x) ) } @@ -230,24 +129,10 @@ 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 rlang dots_n -#' @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. - 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 { - x_new = make_edges_explicit(x) - } - # Return in a list. list( - explicit = x_new + explicit = make_edges_explicit(x, ...) ) } @@ -291,10 +176,10 @@ to_spatial_neighborhood = function(x, node, threshold, ...) { } in_neighborhood = costs[1, ] <= threshold # Subset the network to keep only the nodes in the neighborhood. - x_new = induced_subgraph(x, in_neighborhood) + x_new = induced_subgraph(x, in_neighborhood) %preserve_all_attrs% x # Return in a list. list( - neighborhood = x_new %preserve_all_attrs% x + neighborhood = x_new ) } @@ -333,64 +218,33 @@ to_spatial_shortest_paths = function(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 #' @export to_spatial_simple = function(x, remove_multiple = TRUE, remove_loops = TRUE, summarise_attributes = "first", store_original_data = 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) - } - edge_geomcol = edge_geom_colname(x) - if (! is.null(edge_geomcol)) summarise_attributes[edge_geomcol] = "first" - 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 (! 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) - edge_data(x_new) = new_edges - } - # If requested, original edge data should be stored in a .orig_data column. - if (store_original_data) { - edges = edge_data(x, focused = FALSE) - edges$.tidygraph_edge_index = NULL - new_edges = edge_data(x, focused = FALSE_new) - copy_data = function(i) edges[i, , drop = FALSE] - new_edges$.orig_data = lapply(new_edges$.tidygraph_edge_index, copy_data) - edge_data(x_new) = new_edges - } + # Simplify. + x_new = simplify_network( + x = x, + remove_loops = remove_loops, + remove_multiple = remove_multiple, + summarise_attributes = summarise_attributes, + store_original_ids = TRUE, + store_original_data = store_original_data + ) # Return in a list. list( simple = x_new @@ -412,375 +266,26 @@ to_spatial_simple = function(x, remove_multiple = TRUE, remove_loops = TRUE, #' are a pseudo node or not. Evaluated by \code{\link{evaluate_node_query}}. #' Defaults to \code{NULL}, meaning that none of the nodes is protected. #' -#' @param require_equal Should nodes only be removed when the attribute values +#' @param require_equal Should nodes only be smoothed 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 +#' smoothed. 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. #' -#' @importFrom cli cli_abort -#' @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_equals st_is -#' st_line_merge #' @export -to_spatial_smooth = function(x, - protect = NULL, +to_spatial_smooth = function(x, protect = NULL, require_equal = FALSE, summarise_attributes = "ignore", - require_equal = 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)) - # 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. - ## ========================== - 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)) { - # Evaluate the given protected nodes query. - protect = evaluate_node_query(x, protect) - # Mark all protected nodes as not being a pseudo node. - pseudo[protect] = FALSE - if (! any(pseudo)) return (x) - } - # 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_colnames(x, geom = FALSE) - } else { - # Check if all given attributes exist in the edges table of x. - attr_exists = require_equal %in% edge_colnames(x, geom = FALSE) - if (! all(attr_exists)) { - unknown_attrs = paste(require_equal[!attr_exists], collapse = ", ") - cli_abort(c( - "Failed to check for edge attribute equality.", - "x" = "The following edge attributes were not found: {unknown_attrs}" - )) - } - } - # 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_attr(x, require_equal, incident_idxs) - # 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) - } - ## ==================================== - # 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", 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) { - 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 (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)) + # Smooth. + x_new = smooth_pseudo_nodes( + x = x, + protect = protect, + summarise_attributes = summarise_attributes, + require_equal = require_equal, + store_original_ids = TRUE, + store_original_data = store_original_data ) - # 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: 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 = edge_data(x, focused = FALSE_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_data(x_new) = new_edges - } # Return in a list. list( smooth = x_new @@ -796,20 +301,22 @@ to_spatial_smooth = function(x, #' element of class \code{\link{sfnetwork}}. This morpher requires edges to be #' spatially explicit. #' -#' @param merge_equal Should multiple subdivision points at the same location -#' be merged into a single node, and should subdivision points at the same -#' 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}}. +#' @param merge Should multiple subdivision points at the same location be +#' merged into a single node, and should subdivision points at the same +#' locationas 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}}. #' #' @export -to_spatial_subdivision = function(x, merge_equal = TRUE) { - x_new = subdivide(x, merge_equal = merge_equal) +to_spatial_subdivision = function(x, merge = TRUE) { + # Subdivide. + x_new = subdivide_edges(x, merge = merge) + # Return in a list. list( - subdivision = x_new %preserve_network_attrs% x + subdivision = x_new ) } @@ -825,6 +332,7 @@ to_spatial_subdivision = function(x, merge_equal = TRUE) { #' @importFrom cli cli_alert #' @export to_spatial_subset = function(x, ..., subset_by = NULL) { + # Subset. if (is.null(subset_by)) { subset_by = attr(x, "active") cli_alert("Subsetting by {subset_by}") @@ -835,6 +343,7 @@ to_spatial_subset = function(x, ..., subset_by = NULL) { edges = spatial_filter_edges(x, ...), raise_unknown_input("subset_by", subset_by, c("nodes", "edges")) ) + # Return in a list. list( subset = x_new ) @@ -860,52 +369,24 @@ to_spatial_transformed = function(x, ...) { #' behavior by explicitly setting the precision of the network using #' \code{\link[sf]{st_set_precision}}. #' -#' @importFrom igraph contract delete_vertex_attr -#' @importFrom sf st_as_sf st_geometry -#' @importFrom tibble as_tibble -#' @importFrom tidygraph as_tbl_graph #' @export to_spatial_unique = function(x, summarise_attributes = "ignore", store_original_data = FALSE) { - # Retrieve nodes from the network. - # Extract specific information from them. - nodes = nodes_as_sf(x) - node_geoms = st_geometry(nodes) - node_geomcol = attr(nodes, "sf_column") - # Define which nodes have equal geometries. - matches = st_match_points(node_geoms) - # Update the attribute summary instructions. - # During morphing tidygraph adds 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" - # 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, matches, summarise_attributes)) - # Extract the nodes from the contracted network. - new_nodes = as_tibble(x_new, "nodes", focused = FALSE) - # Add geometries to the new nodes. - # These are simply the original node geometries with duplicates removed. - new_node_geoms = node_geoms[!duplicated(matches)] - new_nodes[node_geomcol] = 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 } - grouped_data = split(nodes, matches) - new_nodes$.orig_data = lapply(grouped_data, drop_index) - } - # Update the nodes table of the contracted network. - new_nodes = st_as_sf(new_nodes, sf_column_name = node_geomcol) - node_data(x_new) = new_nodes - # Return new network as sfnetwork object in a list. + # 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, + summarise_attributes = summarise_attributes, + store_original_ids = TRUE, + store_original_data = store_original_data + ) + # Return in a list. list( - unique = tbg_to_sfn(x_new %preserve_network_attrs% x) + unique = x_new ) } diff --git a/R/simplify.R b/R/simplify.R new file mode 100644 index 00000000..2b078adf --- /dev/null +++ b/R/simplify.R @@ -0,0 +1,95 @@ +#' 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 summarise_attributes 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, + summarise_attributes = "first", + store_original_ids = FALSE, + store_original_data = FALSE) { + # Add a index column if not present. + if (! ".tidygraph_edge_index" %in% edge_attr_names(x)) { + edge_attr(x, ".tidygraph_edge_index") = seq_len(1:n_edges(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(summarise_attributes, "list")) { + summarise_attributes = list(summarise_attributes) + } + edge_geomcol = edge_geom_colname(x) + if (! is.null(edge_geomcol)) summarise_attributes[edge_geomcol] = "first" + 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_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 = delete_edge_attr(x_new, ".tidygraph_edge_index") + } + x_new +} \ No newline at end of file diff --git a/R/smooth.R b/R/smooth.R new file mode 100644 index 00000000..22838b05 --- /dev/null +++ b/R/smooth.R @@ -0,0 +1,405 @@ +#' 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 Nodes to be protected from being removed, no matter if they +#' are a pseudo node or not. Evaluated by \code{\link{evaluate_node_query}}. +#' Defaults to \code{NULL}, meaning that none of the nodes is protected. +#' +#' @param summarise_attributes How should the attributes of concatenated edges +#' be summarized? There are several options, see +#' \code{\link[igraph]{igraph-attribute-combination}} for details. +#' +#' @param require_equal Should nodes only be smoothed 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 +#' smoothed. 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 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 igraph adjacent_vertices decompose degree delete_edge_attr +#' delete_vertices edge_attr edge_attr<- edge_attr_names 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 +#' @export +smooth_pseudo_nodes = function(x, protect = NULL, + summarise_attributes = "concat", + require_equal = FALSE, + 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 a index column if not present. + if (! ".tidygraph_edge_index" %in% edge_attr_names(x)) { + edge_attr(x, ".tidygraph_edge_index") = seq_len(1:n_edges(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. + ## ========================== + 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)) { + # Evaluate the given protected nodes query. + protect = evaluate_node_query(x, protect) + # Mark all protected nodes as not being a pseudo node. + pseudo[protect] = FALSE + if (! any(pseudo)) return (x) + } + # 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_colnames(x, geom = FALSE) + } else { + # Check if all given attributes exist in the edges table of x. + attr_exists = require_equal %in% edge_colnames(x, geom = FALSE) + if (! all(attr_exists)) { + unknown_attrs = paste(require_equal[!attr_exists], collapse = ", ") + cli_abort(c( + "Failed to check for edge attribute equality.", + "x" = "The following edge attributes were not found: {unknown_attrs}" + )) + } + } + # 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_attr(x, require_equal, incident_idxs) + # 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) + } + ## ==================================== + # 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", 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) { + 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 (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 = delete_edge_attr(x_new, ".tidygraph_edge_index") + } + x_new +} \ No newline at end of file diff --git a/R/subdivide.R b/R/subdivide.R index e286c22d..24f8183d 100644 --- a/R/subdivide.R +++ b/R/subdivide.R @@ -9,23 +9,23 @@ #' @param x An object of class \code{\link{sfnetwork}} with spatially explicit #' edges. #' -#' @param merge_equal Should multiple subdivision points at the same location -#' be merged into a single node, and should subdivision points at the same -#' 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}}. +#' @param merge Should multiple subdivision points at the same location be +#' merged into a single node, and should subdivision points at the same +#' locationas 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}}. #' -#' @returns A subdivision of x as object of class \code{\link{sfnetwork}}. +#' @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 -#' @noRd -subdivide = function(x, merge_equal = TRUE) { +#' @export +subdivide_edges = function(x, merge = TRUE) { nodes = nodes_as_sf(x) edges = edges_as_sf(x) ## =========================== @@ -123,12 +123,12 @@ subdivide = function(x, merge_equal = TRUE) { 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_equal is set to TRUE: + # 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_equal) { + if (merge) { # Arrange the new nodes table such that: # --> Existing nodes come before split points. new_node_pts = arrange(new_node_pts, nid) @@ -190,5 +190,6 @@ subdivide = function(x, merge_equal = TRUE) { # STEP VII: RECREATE THE NETWORK # Use the new nodes data and the new edges data to create the new network. ## ============================ - sfnetwork_(new_nodes, new_edges, directed = is_directed(x)) + x_new = sfnetwork_(new_nodes, new_edges, directed = is_directed(x)) + x_new %preserve_network_attrs% x } diff --git a/man/autoplot.Rd b/man/autoplot.Rd index 7d00e0f3..c490d106 100644 --- a/man/autoplot.Rd +++ b/man/autoplot.Rd @@ -5,7 +5,7 @@ \alias{autoplot.sfnetwork} \title{Plot sfnetwork geometries with ggplot2} \usage{ -autoplot.sfnetwork(object, ...) +\method{autoplot}{sfnetwork}(object, ...) } \arguments{ \item{object}{An object of class \code{\link{sfnetwork}}.} diff --git a/man/contract_nodes.Rd b/man/contract_nodes.Rd new file mode 100644 index 00000000..450251dc --- /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, + summarise_attributes = "concat", + 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{summarise_attributes}{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/make_edges_directed.Rd b/man/make_edges_directed.Rd new file mode 100644 index 00000000..424f038d --- /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 undirected network as object of class \code{\link{sfnetwork}}.} +} +\value{ +An 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 index 1d8227f0..7c416749 100644 --- a/man/make_edges_explicit.Rd +++ b/man/make_edges_explicit.Rd @@ -4,17 +4,25 @@ \alias{make_edges_explicit} \title{Construct edge geometries for spatially implicit networks} \usage{ -make_edges_explicit(x) +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. If \code{x} was already spatially explicit it is returned unmodified. +edges. } \description{ This function turns spatially implicit networks into spatially explicit -networks by adding a geometry column to the edges data containing straight -lines between the source and target nodes. +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/simplify_network.Rd b/man/simplify_network.Rd new file mode 100644 index 00000000..253761ef --- /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, + summarise_attributes = "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{summarise_attributes}{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..8f505bd4 --- /dev/null +++ b/man/smooth_pseudo_nodes.Rd @@ -0,0 +1,56 @@ +% 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, + summarise_attributes = "concat", + require_equal = FALSE, + store_original_ids = FALSE, + store_original_data = FALSE +) +} +\arguments{ +\item{x}{An object of class \code{\link{sfnetwork}}.} + +\item{protect}{Nodes to be protected from being removed, no matter if they +are a pseudo node or not. Evaluated by \code{\link{evaluate_node_query}}. +Defaults to \code{NULL}, meaning that none of the nodes is protected.} + +\item{summarise_attributes}{How should the attributes of concatenated edges +be summarized? There are several options, see +\code{\link[igraph]{igraph-attribute-combination}} for details.} + +\item{require_equal}{Should nodes only be smoothed 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 +smoothed. 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{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_morphers.Rd b/man/spatial_morphers.Rd index 8ecad265..bf31cacd 100644 --- a/man/spatial_morphers.Rd +++ b/man/spatial_morphers.Rd @@ -43,12 +43,12 @@ to_spatial_simple( to_spatial_smooth( x, protect = NULL, - summarise_attributes = "ignore", require_equal = FALSE, + summarise_attributes = "ignore", store_original_data = FALSE ) -to_spatial_subdivision(x, merge_equal = TRUE) +to_spatial_subdivision(x, merge = TRUE) to_spatial_subset(x, ..., subset_by = NULL) @@ -79,16 +79,16 @@ 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{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{summarise_attributes}{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{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{node}{The node for which the neighborhood will be calculated. Evaluated by \code{\link{evaluate_node_query}}. When multiple nodes are @@ -109,21 +109,21 @@ to \code{TRUE}.} are a pseudo node or not. Evaluated by \code{\link{evaluate_node_query}}. Defaults to \code{NULL}, meaning that none of the nodes is protected.} -\item{require_equal}{Should nodes only be removed when the attribute values +\item{require_equal}{Should nodes only be smoothed 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 +smoothed. 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{merge_equal}{Should multiple subdivision points at the same location -be merged into a single node, and should subdivision points at the same -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{merge}{Should multiple subdivision points at the same location be +merged into a single node, and should subdivision points at the same +locationas 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.} } @@ -136,28 +136,27 @@ 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 by -default as geometry of the contracted node. If edges are spatially explicit, -edge geometries are updated accordingly such that the valid spatial network +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}}. @@ -194,14 +193,14 @@ 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}}. +\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 @@ -248,25 +247,19 @@ behavior by explicitly setting the precision of the network using 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/subdivide_edges.Rd b/man/subdivide_edges.Rd new file mode 100644 index 00000000..16329f2b --- /dev/null +++ b/man/subdivide_edges.Rd @@ -0,0 +1,31 @@ +% 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, merge = TRUE) +} +\arguments{ +\item{x}{An object of class \code{\link{sfnetwork}} with spatially explicit +edges.} + +\item{merge}{Should multiple subdivision points at the same location be +merged into a single node, and should subdivision points at the same +locationas 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}}.} +} +\value{ +The subdivision of x as object of class \code{\link{sfnetwork}}. +} +\description{ +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 are those points that shape a linestring geometry +feature but are not endpoints of it, while boundary points are the endpoints +of the linestrings, i.e. the existing nodes in het network. +} From a1886b75702ba799695094501ddaa5370175319b Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Thu, 19 Sep 2024 17:45:18 +0200 Subject: [PATCH 126/246] feat: New morpher to_spatial_implicit :gift: --- NAMESPACE | 2 ++ R/edge.R | 18 ++++++++++++++++++ R/morphers.R | 10 ++++++++++ man/make_edges_implicit.Rd | 23 +++++++++++++++++++++++ man/spatial_morphers.Rd | 7 +++++++ 5 files changed, 60 insertions(+) create mode 100644 man/make_edges_implicit.Rd diff --git a/NAMESPACE b/NAMESPACE index 496755f2..d851bf69 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -101,6 +101,7 @@ export(is_sfnetwork) export(make_edges_directed) export(make_edges_explicit) export(make_edges_follow_indices) +export(make_edges_implicit) export(make_edges_valid) export(morph) export(n_edges) @@ -144,6 +145,7 @@ export(subdivide_edges) export(to_spatial_contracted) export(to_spatial_directed) export(to_spatial_explicit) +export(to_spatial_implicit) export(to_spatial_neighborhood) export(to_spatial_shortest_paths) export(to_spatial_simple) diff --git a/R/edge.R b/R/edge.R index d56198a6..7b1e88f0 100644 --- a/R/edge.R +++ b/R/edge.R @@ -448,6 +448,24 @@ make_edges_explicit = function(x, ...) { 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 diff --git a/R/morphers.R b/R/morphers.R index 01a21cdc..2ed13b3a 100644 --- a/R/morphers.R +++ b/R/morphers.R @@ -136,6 +136,16 @@ to_spatial_explicit = function(x, ...) { ) } +#' @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_implict(x, ...) + ) +} + #' @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 source node to all other nodes in the diff --git a/man/make_edges_implicit.Rd b/man/make_edges_implicit.Rd new file mode 100644 index 00000000..e045b273 --- /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/spatial_morphers.Rd b/man/spatial_morphers.Rd index bf31cacd..bc6e5d63 100644 --- a/man/spatial_morphers.Rd +++ b/man/spatial_morphers.Rd @@ -5,6 +5,7 @@ \alias{to_spatial_contracted} \alias{to_spatial_directed} \alias{to_spatial_explicit} +\alias{to_spatial_implicit} \alias{to_spatial_neighborhood} \alias{to_spatial_shortest_paths} \alias{to_spatial_simple} @@ -28,6 +29,8 @@ to_spatial_directed(x) to_spatial_explicit(x, ...) +to_spatial_implicit(x) + to_spatial_neighborhood(x, node, threshold, ...) to_spatial_shortest_paths(x, ...) @@ -178,6 +181,10 @@ 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_neighborhood()}: 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 source node to all other nodes in the From cb8dd3650f52370b0c5fc302e817d2a13e3149a8 Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Thu, 19 Sep 2024 18:31:58 +0200 Subject: [PATCH 127/246] feat: New morpher to_spatial_reversed :gift: --- NAMESPACE | 1 + R/morphers.R | 38 ++++++++++++++++++++++++++++++++++---- man/spatial_morphers.Rd | 16 ++++++++++++---- 3 files changed, 47 insertions(+), 8 deletions(-) diff --git a/NAMESPACE b/NAMESPACE index d851bf69..49020ffd 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -147,6 +147,7 @@ export(to_spatial_directed) export(to_spatial_explicit) export(to_spatial_implicit) export(to_spatial_neighborhood) +export(to_spatial_reversed) export(to_spatial_shortest_paths) export(to_spatial_simple) export(to_spatial_smooth) diff --git a/R/morphers.R b/R/morphers.R index 2ed13b3a..c9d3f2c5 100644 --- a/R/morphers.R +++ b/R/morphers.R @@ -6,6 +6,11 @@ #' #' @param x An object of class \code{\link{sfnetwork}}. #' +#' @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 summarise_attributes 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 @@ -193,6 +198,35 @@ to_spatial_neighborhood = function(x, node, threshold, ...) { ) } +#' @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 sf st_reverse +#' @export +to_spatial_reversed = function(x, protect = NULL) { + # Define which edges should be reversed. + if (is.null(protect)) { + reverse = edge_ids(x, focused = FALSE) + } else { + protect = evaluate_edge_query(x, 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) + } + # Return in a list. + list( + 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 @@ -272,10 +306,6 @@ 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. Evaluated by \code{\link{evaluate_node_query}}. -#' Defaults to \code{NULL}, meaning that none of the nodes is protected. -#' #' @param require_equal Should nodes only be smoothed 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 diff --git a/man/spatial_morphers.Rd b/man/spatial_morphers.Rd index bc6e5d63..e0f88908 100644 --- a/man/spatial_morphers.Rd +++ b/man/spatial_morphers.Rd @@ -7,6 +7,7 @@ \alias{to_spatial_explicit} \alias{to_spatial_implicit} \alias{to_spatial_neighborhood} +\alias{to_spatial_reversed} \alias{to_spatial_shortest_paths} \alias{to_spatial_simple} \alias{to_spatial_smooth} @@ -33,6 +34,8 @@ to_spatial_implicit(x) to_spatial_neighborhood(x, node, threshold, ...) +to_spatial_reversed(x, protect = NULL) + to_spatial_shortest_paths(x, ...) to_spatial_simple( @@ -103,15 +106,16 @@ neighborhood. Should be a numeric value in the same units as the weight values used for the cost matrix computation. Alternatively, units can be specified explicitly by providing a \code{\link[units]{units}} object.} +\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. Evaluated by \code{\link{evaluate_node_query}}. -Defaults to \code{NULL}, meaning that none of the nodes is protected.} - \item{require_equal}{Should nodes only be smoothed 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 @@ -191,6 +195,10 @@ compute the travel cost from the source node to all other nodes in the network. Returns a \code{morphed_sfnetwork} containing a single element of class \code{\link{sfnetwork}}. +\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 From 94edd5ca93bc759cef66a773a5d1d0d66fd05c6e Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Mon, 23 Sep 2024 15:15:10 +0200 Subject: [PATCH 128/246] feat: Add new morpher to_spatial_mixed :gift: --- NAMESPACE | 2 ++ R/edge.R | 56 ++++++++++++++++++++++++++++++++++++-- R/ids.R | 42 ++++++++++++++-------------- R/morphers.R | 16 +++++++++++ man/autoplot.Rd | 2 +- man/make_edges_directed.Rd | 4 +-- man/make_edges_mixed.Rd | 23 ++++++++++++++++ man/spatial_morphers.Rd | 12 ++++++++ 8 files changed, 131 insertions(+), 26 deletions(-) create mode 100644 man/make_edges_mixed.Rd diff --git a/NAMESPACE b/NAMESPACE index 49020ffd..cc2538dc 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -102,6 +102,7 @@ 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) @@ -146,6 +147,7 @@ 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) diff --git a/R/edge.R b/R/edge.R index 7b1e88f0..022b946d 100644 --- a/R/edge.R +++ b/R/edge.R @@ -372,7 +372,7 @@ evaluate_edge_predicate = function(predicate, x, y, ...) { #' This function converts an undirected network to a directed network following #' the direction given by the linestring geometries of the edges. #' -#' @param x An undirected network as object of class \code{\link{sfnetwork}}. +#' @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 @@ -385,7 +385,7 @@ evaluate_edge_predicate = function(predicate, x, y, ...) { #' #' @note If the network is already directed it is returned unmodified. #' -#' @return An directed network as object of class \code{\link{sfnetwork}}. +#' @return A directed network as object of class \code{\link{sfnetwork}}. #' #' @importFrom igraph is_directed #' @export @@ -407,6 +407,58 @@ make_edges_directed = function(x) { 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 Which edges should be directed? Evaluated by +#' \code{\link{evaluate_edge_query}}. +#' +#' @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 directed, and which undirected. + directed = evaluate_edge_query(x, directed) + 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 diff --git a/R/ids.R b/R/ids.R index d20cb84e..e3e72cea 100644 --- a/R/ids.R +++ b/R/ids.R @@ -236,26 +236,26 @@ edge_target_ids = function(x, focused = FALSE, matrix = FALSE) { #' @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) - 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) != n_edges(x, focused = focused) * 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) + 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 } - if (matrix) t(matrix(idxs_vct, nrow = 2)) else idxs_vct + idlist = mapply(find_indices, pids, nids, SIMPLIFY = FALSE) + idvect = do.call("c", idlist) + } + if (matrix) t(matrix(idvect, nrow = 2)) else idvect } diff --git a/R/morphers.R b/R/morphers.R index c9d3f2c5..ad20f9fc 100644 --- a/R/morphers.R +++ b/R/morphers.R @@ -151,6 +151,22 @@ to_spatial_implicit = function(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 directed Which edges should be directed? Evaluated by +#' \code{\link{evaluate_edge_query}}. +#' +#' @export +to_spatial_mixed = function(x, 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 source node to all other nodes in the diff --git a/man/autoplot.Rd b/man/autoplot.Rd index c490d106..7d00e0f3 100644 --- a/man/autoplot.Rd +++ b/man/autoplot.Rd @@ -5,7 +5,7 @@ \alias{autoplot.sfnetwork} \title{Plot sfnetwork geometries with ggplot2} \usage{ -\method{autoplot}{sfnetwork}(object, ...) +autoplot.sfnetwork(object, ...) } \arguments{ \item{object}{An object of class \code{\link{sfnetwork}}.} diff --git a/man/make_edges_directed.Rd b/man/make_edges_directed.Rd index 424f038d..2ff38b8c 100644 --- a/man/make_edges_directed.Rd +++ b/man/make_edges_directed.Rd @@ -7,10 +7,10 @@ make_edges_directed(x) } \arguments{ -\item{x}{An undirected network as object of class \code{\link{sfnetwork}}.} +\item{x}{An object of class \code{\link{sfnetwork}}.} } \value{ -An directed network as object of class \code{\link{sfnetwork}}. +A directed network as object of class \code{\link{sfnetwork}}. } \description{ This function converts an undirected network to a directed network following diff --git a/man/make_edges_mixed.Rd b/man/make_edges_mixed.Rd new file mode 100644 index 00000000..67e05ff9 --- /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}{Which edges should be directed? Evaluated by +\code{\link{evaluate_edge_query}}.} +} +\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/spatial_morphers.Rd b/man/spatial_morphers.Rd index e0f88908..fa17f5c1 100644 --- a/man/spatial_morphers.Rd +++ b/man/spatial_morphers.Rd @@ -6,6 +6,7 @@ \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} @@ -32,6 +33,8 @@ to_spatial_explicit(x, ...) to_spatial_implicit(x) +to_spatial_mixed(x, directed) + to_spatial_neighborhood(x, node, threshold, ...) to_spatial_reversed(x, protect = NULL) @@ -96,6 +99,9 @@ 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.} @@ -189,6 +195,12 @@ drawn between the source and target node of each edge. Returns a 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{st_network_cost}} to compute the travel cost from the source node to all other nodes in the From 89528882783c65a8ce1562b5186376a1dab4754b Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Mon, 23 Sep 2024 15:17:11 +0200 Subject: [PATCH 129/246] feat: Re-export tidygraphs with_graph function :gift: --- NAMESPACE | 1 + R/tidygraph.R | 4 ++++ man/reexports.Rd | 3 ++- 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/NAMESPACE b/NAMESPACE index cc2538dc..7d907d94 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -159,6 +159,7 @@ export(to_spatial_transformed) export(to_spatial_unique) export(unmorph) export(validate_network) +export(with_graph) importFrom(TSP,ATSP) importFrom(TSP,TSP) importFrom(TSP,solve_TSP) diff --git a/R/tidygraph.R b/R/tidygraph.R index d434434a..56a081e1 100644 --- a/R/tidygraph.R +++ b/R/tidygraph.R @@ -26,6 +26,10 @@ tidygraph::crystallize #' @export tidygraph::crystallise +#' @importFrom tidygraph with_graph +#' @export +tidygraph::with_graph + #' @importFrom tidygraph %>% #' @export tidygraph::`%>%` diff --git a/man/reexports.Rd b/man/reexports.Rd index eeba6988..325455d9 100644 --- a/man/reexports.Rd +++ b/man/reexports.Rd @@ -10,6 +10,7 @@ \alias{convert} \alias{crystallize} \alias{crystallise} +\alias{with_graph} \alias{\%>\%} \title{Objects exported from other packages} \keyword{internal} @@ -18,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}}, \code{\link[tidygraph:morph]{convert}}, \code{\link[tidygraph:morph]{crystallise}}, \code{\link[tidygraph:morph]{crystallize}}, \code{\link[tidygraph]{morph}}, \code{\link[tidygraph:morph]{unmorph}}} + \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}}} }} From a6f1e3aae1a27b97bbf0f7095041d25d6cc42aac Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Mon, 23 Sep 2024 16:09:18 +0200 Subject: [PATCH 130/246] fix: Correct calls to evaluate_node_query :wrench: --- R/cost.R | 4 ++-- R/paths.R | 4 ++-- R/travel.R | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/R/cost.R b/R/cost.R index 6d8ddc61..e4607d0b 100644 --- a/R/cost.R +++ b/R/cost.R @@ -99,10 +99,10 @@ st_network_cost.sfnetwork = function(x, from = node_ids(x), to = node_ids(x), direction = "out", Inf_as_NaN = FALSE, ...) { # Evaluate the given from node query. - from = evaluate_node_query(from) + from = evaluate_node_query(x, from) if (any(is.na(from))) raise_na_values("from") # Evaluate the given to node query. - to = evaluate_node_query(to) + to = evaluate_node_query(x, to) if (any(is.na(to))) raise_na_values("to") # Evaluate the given weights specification. weights = evaluate_weight_spec(x, weights) diff --git a/R/paths.R b/R/paths.R index fa222b41..be4438d6 100644 --- a/R/paths.R +++ b/R/paths.R @@ -162,11 +162,11 @@ st_network_paths.sfnetwork = function(x, from, to = node_ids(x), use_names = TRUE, return_cost = TRUE, return_geometry = TRUE, ...) { # Evaluate the given from node query. - from = evaluate_node_query(from) + from = evaluate_node_query(x, from) if (length(from) > 1) raise_multiple_elements("from"); from = from[1] if (any(is.na(from))) raise_na_values("from") # Evaluate the given to node query. - to = evaluate_node_query(to) + to = evaluate_node_query(x, to) if (any(is.na(to))) raise_na_values("to") # Evaluate the given weights specification. weights = evaluate_weight_spec(x, weights) diff --git a/R/travel.R b/R/travel.R index e3f5b875..94b4d880 100644 --- a/R/travel.R +++ b/R/travel.R @@ -29,7 +29,7 @@ st_network_travel = function(x, pois, weights = edge_length(), return_geometry = TRUE, ...) { # Evaluate the node query for the pois. - pois = evaluate_node_query(pois) + pois = evaluate_node_query(x, pois) if (any(is.na(pois))) raise_na_values("pois") # Evaluate the given weights specification. weights = evaluate_weight_spec(x, weights) From 89dfaef97532206b84e19cea0165fc09d3ebdfc6 Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Mon, 23 Sep 2024 16:09:38 +0200 Subject: [PATCH 131/246] feat: Allow multiple thresholds in to_spatial_neighborhood :gift: --- R/morphers.R | 26 ++++++++++++++++---------- man/spatial_morphers.Rd | 11 ++++++++--- 2 files changed, 24 insertions(+), 13 deletions(-) diff --git a/R/morphers.R b/R/morphers.R index ad20f9fc..9cc5951f 100644 --- a/R/morphers.R +++ b/R/morphers.R @@ -170,8 +170,11 @@ to_spatial_mixed = function(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 source node to all other nodes in the -#' network. Returns a \code{morphed_sfnetwork} containing a single element of -#' class \code{\link{sfnetwork}}. +#' 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 node The node for which the neighborhood will be calculated. #' Evaluated by \code{\link{evaluate_node_query}}. When multiple nodes are @@ -182,6 +185,8 @@ to_spatial_mixed = function(x, directed) { #' neighborhood. Should be a numeric value in the same units as the weight #' values used for the cost matrix computation. Alternatively, units can be #' specified explicitly by providing a \code{\link[units]{units}} object. +#' Multiple threshold values may be given, in which a neighborhood is created +#' for each of them separately. #' #' @importFrom igraph induced_subgraph #' @importFrom methods hasArg @@ -201,17 +206,18 @@ to_spatial_neighborhood = function(x, node, threshold, ...) { } else { costs = st_network_cost(x, from = node, ...) } - # Use the given threshold to define which nodes are in the neighborhood. + # Parse the given threshold values. if (inherits(costs, "units") && ! inherits(threshold, "units")) { threshold = as_units(threshold, deparse_unit(costs)) } - in_neighborhood = costs[1, ] <= threshold - # Subset the network to keep only the nodes in the neighborhood. - x_new = induced_subgraph(x, in_neighborhood) %preserve_all_attrs% x - # Return in a list. - list( - neighborhood = x_new - ) + # 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 diff --git a/man/spatial_morphers.Rd b/man/spatial_morphers.Rd index fa17f5c1..c3239882 100644 --- a/man/spatial_morphers.Rd +++ b/man/spatial_morphers.Rd @@ -110,7 +110,9 @@ given, only the first one is used.} 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 the cost matrix computation. Alternatively, units can be -specified explicitly by providing a \code{\link[units]{units}} object.} +specified explicitly by providing a \code{\link[units]{units}} object. +Multiple threshold values may be given, in which a neighborhood is created +for each of them separately.} \item{protect}{Nodes or edges to be protected from being changed in structure. Evaluated by \code{\link{evaluate_node_query}} in the case of @@ -204,8 +206,11 @@ 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{st_network_cost}} to compute the travel cost from the source node to all other nodes in the -network. Returns a \code{morphed_sfnetwork} containing a single element of -class \code{\link{sfnetwork}}. +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 From 6b14d96a6bfdae5700fb3868a4a8b214cd8fe5c3 Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Mon, 23 Sep 2024 16:11:37 +0200 Subject: [PATCH 132/246] refactor: Arrange node and edge tables in to_spatial_shortest_paths. Refs #155 :construction: --- R/morphers.R | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/R/morphers.R b/R/morphers.R index 9cc5951f..46fd5245 100644 --- a/R/morphers.R +++ b/R/morphers.R @@ -257,7 +257,7 @@ to_spatial_reversed = function(x, protect = NULL) { #' 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 +#' @importFrom igraph is_directed #' @export to_spatial_shortest_paths = function(x, ...) { # Call st_network_paths with the given arguments. @@ -271,15 +271,17 @@ to_spatial_shortest_paths = function(x, ...) { 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$edges[[i]]) - node_idxs = as.integer(paths$nodes[[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 + node_ids = as.integer(paths$nodes[[i]]) + edge_ids = as.integer(paths$edges[[i]]) + N = nodes[node_ids, ] + E = edges[edge_ids, ] + E$from = c(1:(length(node_ids) - 1)) + E$to = c(2:length(node_ids)) + sfnetwork_(N, E, directed = is_directed(x)) } lapply(seq_len(nrow(paths)), get_single_path) } From d89c0bfb05ed14c81e7e105e2907865b5232b6fa Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Mon, 23 Sep 2024 16:18:35 +0200 Subject: [PATCH 133/246] fix: Correctly print empty graph description :wrench: --- R/print.R | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/R/print.R b/R/print.R index 7c126fd6..3d44388b 100644 --- a/R/print.R +++ b/R/print.R @@ -85,7 +85,7 @@ as_named_tbl = function(x, name = "A tibble", suffix = "") { #' gorder count_components #' @noRd describe_graph = function(x, is_explicit = NULL) { - if (gorder(x) == 0) return("An empty graph") + if (gorder(x) == 0) return("# An empty graph") prop = list( simple = is_simple(x), directed = is_directed(x), From e031764129be7e7b0cf65fd013f289838e5d8868 Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Mon, 23 Sep 2024 16:19:03 +0200 Subject: [PATCH 134/246] fix: Make to_spatial_shortest_paths work when no path is found :wrench: --- R/morphers.R | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/R/morphers.R b/R/morphers.R index 46fd5245..a1d7202f 100644 --- a/R/morphers.R +++ b/R/morphers.R @@ -275,12 +275,17 @@ to_spatial_shortest_paths = function(x, ...) { edges = edge_data(x, focused = FALSE) # Subset the network for each computed shortest path. get_single_path = function(i) { - node_ids = as.integer(paths$nodes[[i]]) - edge_ids = as.integer(paths$edges[[i]]) - N = nodes[node_ids, ] - E = edges[edge_ids, ] - E$from = c(1:(length(node_ids) - 1)) - E$to = c(2:length(node_ids)) + if (paths[i, ]$path_found) { + node_ids = paths$nodes[[i]] + edge_ids = paths$edges[[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) From 3e52202f73bb980d30695487fb6bad4524d2fbd1 Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Mon, 23 Sep 2024 17:25:42 +0200 Subject: [PATCH 135/246] feat: Allow subdivision at every interior point. Refs #210 :gift: --- R/morphers.R | 23 ++++++++++------ R/subdivide.R | 60 +++++++++++++++++++++++++---------------- man/autoplot.Rd | 2 +- man/spatial_morphers.Rd | 21 ++++++++++----- man/subdivide_edges.Rd | 29 ++++++++++++-------- 5 files changed, 85 insertions(+), 50 deletions(-) diff --git a/R/morphers.R b/R/morphers.R index a1d7202f..082ce627 100644 --- a/R/morphers.R +++ b/R/morphers.R @@ -362,17 +362,24 @@ to_spatial_smooth = function(x, protect = NULL, require_equal = FALSE, } #' @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 are those points that -#' shape a linestring geometry feature but are not endpoints of it, while -#' boundary points are the endpoints of the linestrings, i.e. the existing -#' nodes in het network. Returns a \code{morphed_sfnetwork} containing a single +#' 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 -#' locationas an existing node be merged into that node? Defaults to +#' 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 @@ -380,9 +387,9 @@ to_spatial_smooth = function(x, protect = NULL, require_equal = FALSE, #' using \code{\link[sf]{st_set_precision}}. #' #' @export -to_spatial_subdivision = function(x, merge = TRUE) { +to_spatial_subdivision = function(x, all = FALSE, merge = TRUE) { # Subdivide. - x_new = subdivide_edges(x, merge = merge) + x_new = subdivide_edges(x, all = all, merge = merge) # Return in a list. list( subdivision = x_new diff --git a/R/subdivide.R b/R/subdivide.R index 24f8183d..f4f6f6b3 100644 --- a/R/subdivide.R +++ b/R/subdivide.R @@ -1,22 +1,28 @@ #' Subdivide edges at interior points #' -#' 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 are those points that shape a linestring geometry -#' feature but are not endpoints of it, while boundary points are the endpoints -#' of the linestrings, i.e. the existing nodes in het network. +#' 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 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 -#' locationas an existing node be merged into that node? Defaults to +#' 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}}. +#' 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}}. #' @@ -25,7 +31,7 @@ #' @importFrom sf st_geometry<- #' @importFrom sfheaders sf_to_df #' @export -subdivide_edges = function(x, merge = TRUE) { +subdivide_edges = function(x, all = FALSE, merge = TRUE) { nodes = nodes_as_sf(x) edges = edges_as_sf(x) ## =========================== @@ -44,12 +50,10 @@ subdivide_edges = function(x, merge = TRUE) { edge_pts$linestring_id = NULL ## ======================================= # 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. + # 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) @@ -61,13 +65,23 @@ subdivide_edges = function(x, merge = TRUE) { 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")] - edge_lids = st_match_points_df(edge_coords, attr(edges, "precision")) - edge_pts$lid = edge_lids - # Define which edge points are not unique. - has_duplicate = duplicated(edge_lids) | duplicated(edge_lids, fromLast = TRUE) - # Define at which edge points to split edges. - is_split = has_duplicate & !is_boundary + if (merge | !all) { + edge_lids = st_match_points_df(edge_coords, attr(edges, "precision")) + edge_pts$lid = edge_lids + } + # Define the subdivision points. + if (all) { + is_split = !is_boundary + } 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 + } ## ========================================== # STEP III: CONSTRUCT THE NEW EDGE GEOMETRIES # First we duplicate each split point. diff --git a/man/autoplot.Rd b/man/autoplot.Rd index 7d00e0f3..c490d106 100644 --- a/man/autoplot.Rd +++ b/man/autoplot.Rd @@ -5,7 +5,7 @@ \alias{autoplot.sfnetwork} \title{Plot sfnetwork geometries with ggplot2} \usage{ -autoplot.sfnetwork(object, ...) +\method{autoplot}{sfnetwork}(object, ...) } \arguments{ \item{object}{An object of class \code{\link{sfnetwork}}.} diff --git a/man/spatial_morphers.Rd b/man/spatial_morphers.Rd index c3239882..3461a776 100644 --- a/man/spatial_morphers.Rd +++ b/man/spatial_morphers.Rd @@ -57,7 +57,7 @@ to_spatial_smooth( store_original_data = FALSE ) -to_spatial_subdivision(x, merge = TRUE) +to_spatial_subdivision(x, all = FALSE, merge = TRUE) to_spatial_subset(x, ..., subset_by = NULL) @@ -131,9 +131,17 @@ smoothed. 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{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 -locationas an existing node be merged into that node? Defaults to +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 @@ -246,11 +254,10 @@ 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 are those points that -shape a linestring geometry feature but are not endpoints of it, while -boundary points are the endpoints of the linestrings, i.e. the existing -nodes in het network. Returns a \code{morphed_sfnetwork} containing a single +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. diff --git a/man/subdivide_edges.Rd b/man/subdivide_edges.Rd index 16329f2b..42839615 100644 --- a/man/subdivide_edges.Rd +++ b/man/subdivide_edges.Rd @@ -4,28 +4,35 @@ \alias{subdivide_edges} \title{Subdivide edges at interior points} \usage{ -subdivide_edges(x, merge = TRUE) +subdivide_edges(x, all = FALSE, merge = TRUE) } \arguments{ \item{x}{An object of class \code{\link{sfnetwork}} with spatially explicit edges.} +\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 -locationas an existing node be merged into that node? Defaults to +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}}.} +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 each interior -point that is equal to any other interior or boundary point in the edges -table. Interior points are those points that shape a linestring geometry -feature but are not endpoints of it, while boundary points are the endpoints -of the linestrings, i.e. the existing nodes in het network. +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}}. } From 54ab737f386974491a2177b2db6eecd0c740b396 Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Mon, 23 Sep 2024 17:37:22 +0200 Subject: [PATCH 136/246] feat: Allow to protect edges from subdivision :gift: --- R/morphers.R | 5 +++-- R/subdivide.R | 16 +++++++++++++--- man/spatial_morphers.Rd | 2 +- man/subdivide_edges.Rd | 6 +++++- 4 files changed, 22 insertions(+), 7 deletions(-) diff --git a/R/morphers.R b/R/morphers.R index 082ce627..3f8d66cc 100644 --- a/R/morphers.R +++ b/R/morphers.R @@ -387,9 +387,10 @@ to_spatial_smooth = function(x, protect = NULL, require_equal = FALSE, #' using \code{\link[sf]{st_set_precision}}. #' #' @export -to_spatial_subdivision = function(x, all = FALSE, merge = TRUE) { +to_spatial_subdivision = function(x, protect = NULL, all = FALSE, + merge = TRUE) { # Subdivide. - x_new = subdivide_edges(x, all = all, merge = merge) + x_new = subdivide_edges(x, protect = protect, all = all, merge = merge) # Return in a list. list( subdivision = x_new diff --git a/R/subdivide.R b/R/subdivide.R index f4f6f6b3..d0d67e2c 100644 --- a/R/subdivide.R +++ b/R/subdivide.R @@ -8,6 +8,10 @@ #' @param x An object of class \code{\link{sfnetwork}} with spatially explicit #' edges. #' +#' @param protect Edges to be protected from being subdivided. Evaluated by +#' \code{\link{evaluate_edge_query}}. 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 @@ -31,7 +35,7 @@ #' @importFrom sf st_geometry<- #' @importFrom sfheaders sf_to_df #' @export -subdivide_edges = function(x, all = FALSE, merge = TRUE) { +subdivide_edges = function(x, protect = NULL, all = FALSE, merge = TRUE) { nodes = nodes_as_sf(x) edges = edges_as_sf(x) ## =========================== @@ -73,14 +77,20 @@ subdivide_edges = function(x, all = FALSE, merge = TRUE) { edge_lids = st_match_points_df(edge_coords, attr(edges, "precision")) edge_pts$lid = edge_lids } + # Define which edges to protect from being subdivided. + is_protected = rep(FALSE, nrow(edge_pts)) + if (! is.null(protect)) { + protect = evaluate_edge_query(x, protect) + is_protected[edge_pts$eid %in% protect] = TRUE + } # Define the subdivision points. if (all) { - is_split = !is_boundary + 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_split = has_duplicate & !is_boundary & !is_protected } ## ========================================== # STEP III: CONSTRUCT THE NEW EDGE GEOMETRIES diff --git a/man/spatial_morphers.Rd b/man/spatial_morphers.Rd index 3461a776..820e782b 100644 --- a/man/spatial_morphers.Rd +++ b/man/spatial_morphers.Rd @@ -57,7 +57,7 @@ to_spatial_smooth( store_original_data = FALSE ) -to_spatial_subdivision(x, all = FALSE, merge = TRUE) +to_spatial_subdivision(x, protect = NULL, all = FALSE, merge = TRUE) to_spatial_subset(x, ..., subset_by = NULL) diff --git a/man/subdivide_edges.Rd b/man/subdivide_edges.Rd index 42839615..15e3e3c7 100644 --- a/man/subdivide_edges.Rd +++ b/man/subdivide_edges.Rd @@ -4,12 +4,16 @@ \alias{subdivide_edges} \title{Subdivide edges at interior points} \usage{ -subdivide_edges(x, all = FALSE, merge = TRUE) +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}{Edges to be protected from being subdivided. Evaluated by +\code{\link{evaluate_edge_query}}. 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 From 9099a1ce5d7384520780bd16c0b17af2aa76d7da Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Mon, 23 Sep 2024 17:39:50 +0200 Subject: [PATCH 137/246] breaking: Change default attribute summary to concat :warning: --- R/morphers.R | 6 +++--- man/spatial_morphers.Rd | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/R/morphers.R b/R/morphers.R index 3f8d66cc..a6ac5795 100644 --- a/R/morphers.R +++ b/R/morphers.R @@ -89,7 +89,7 @@ NULL #' @export to_spatial_contracted = function(x, ..., simplify = TRUE, compute_centroids = TRUE, - summarise_attributes = "ignore", + summarise_attributes = "concat", store_original_data = FALSE) { # Create groups. groups = group_by(st_drop_geometry(nodes_as_sf(x)), ...) @@ -344,7 +344,7 @@ to_spatial_simple = function(x, remove_multiple = TRUE, remove_loops = TRUE, #' #' @export to_spatial_smooth = function(x, protect = NULL, require_equal = FALSE, - summarise_attributes = "ignore", + summarise_attributes = "concat", store_original_data = FALSE) { # Smooth. x_new = smooth_pseudo_nodes( @@ -447,7 +447,7 @@ to_spatial_transformed = function(x, ...) { #' \code{\link[sf]{st_set_precision}}. #' #' @export -to_spatial_unique = function(x, summarise_attributes = "ignore", +to_spatial_unique = function(x, summarise_attributes = "concat", store_original_data = FALSE) { # Create groups. group_ids = st_match_points(pull_node_geom(x)) diff --git a/man/spatial_morphers.Rd b/man/spatial_morphers.Rd index 820e782b..d81ddc9c 100644 --- a/man/spatial_morphers.Rd +++ b/man/spatial_morphers.Rd @@ -23,7 +23,7 @@ to_spatial_contracted( ..., simplify = TRUE, compute_centroids = TRUE, - summarise_attributes = "ignore", + summarise_attributes = "concat", store_original_data = FALSE ) @@ -53,7 +53,7 @@ to_spatial_smooth( x, protect = NULL, require_equal = FALSE, - summarise_attributes = "ignore", + summarise_attributes = "concat", store_original_data = FALSE ) @@ -65,7 +65,7 @@ to_spatial_transformed(x, ...) to_spatial_unique( x, - summarise_attributes = "ignore", + summarise_attributes = "concat", store_original_data = FALSE ) } From 783ac679bc6bc23d7607c69d51ccbf7012bc0bd5 Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Tue, 24 Sep 2024 13:06:37 +0200 Subject: [PATCH 138/246] feat: New function to extract network faces. Refs #178 :gift: --- NAMESPACE | 6 ++++++ R/bbox.R | 11 ++++++++++- R/faces.R | 41 +++++++++++++++++++++++++++++++++++++++ R/iso.R | 26 +++++++++++++++++++++++++ man/st_network_faces.Rd | 43 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 126 insertions(+), 1 deletion(-) create mode 100644 R/faces.R create mode 100644 R/iso.R create mode 100644 man/st_network_faces.Rd diff --git a/NAMESPACE b/NAMESPACE index 7d907d94..c578605d 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -138,6 +138,8 @@ 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) @@ -225,6 +227,7 @@ importFrom(lifecycle,deprecate_stop) importFrom(lifecycle,deprecate_warn) importFrom(lifecycle,deprecated) importFrom(lwgeom,st_geod_azimuth) +importFrom(lwgeom,st_split) importFrom(methods,hasArg) importFrom(pillar,style_subtle) importFrom(rlang,"%||%") @@ -248,10 +251,12 @@ 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_coordinates) @@ -282,6 +287,7 @@ 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) diff --git a/R/bbox.R b/R/bbox.R index a7fa0ec1..60a8fe38 100644 --- a/R/bbox.R +++ b/R/bbox.R @@ -69,4 +69,13 @@ st_network_bbox.sfnetwork = function(x, ...) { 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/faces.R b/R/faces.R new file mode 100644 index 00000000..53a2a3ea --- /dev/null +++ b/R/faces.R @@ -0,0 +1,41 @@ +#' 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) +#' +#' @importFrom lwgeom st_split +#' @importFrom sf st_as_sfc st_collection_extract st_geometry +#' @export +st_network_faces = 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/iso.R b/R/iso.R new file mode 100644 index 00000000..ba7ae94d --- /dev/null +++ b/R/iso.R @@ -0,0 +1,26 @@ +#' @importFrom sf st_combine st_concave_hull st_sf +#' @importFrom units as_units deparse_unit +#' @export +st_network_iso = function(x, node, threshold, weights = edge_length(), ..., + delineate = TRUE, ratio = 1, allow_holes = FALSE) { + # Compute the cost matrix from the specified node to all other nodes. + costs = st_network_cost(x, from = node, ...) + # 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 inside the isoline. + # --> Extract and combine the geometries of those nodes. + node_geom = pull_node_geom(x) + get_single_iso = function(k) { + in_iso = costs[1, ] <= k + iso = st_combine(node_geom[in_iso]) + if (delineate) { + iso = st_concave_hull(iso, ratio = ratio, allow_holes = allow_holes) + } + iso + } + geoms = do.call("c", lapply(threshold, get_single_iso)) + st_sf(threshold = threshold, geometry = geoms) +} \ No newline at end of file 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) + +} From e525a6d502ca251cf9924fadca7c24ae64d58658 Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Tue, 24 Sep 2024 14:26:05 +0200 Subject: [PATCH 139/246] feat: Add new function to compute isochrones and isodistances :gift: --- NAMESPACE | 1 + R/iso.R | 100 ++++++++++++++++++++++++++++++++++++---- R/morphers.R | 6 +-- man/autoplot.Rd | 2 +- man/spatial_morphers.Rd | 6 +-- man/st_network_iso.Rd | 91 ++++++++++++++++++++++++++++++++++++ 6 files changed, 189 insertions(+), 17 deletions(-) create mode 100644 man/st_network_iso.Rd diff --git a/NAMESPACE b/NAMESPACE index c578605d..019c9493 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -47,6 +47,7 @@ S3method(st_nearest_points,sfnetwork) S3method(st_network_bbox,sfnetwork) S3method(st_network_blend,sfnetwork) S3method(st_network_cost,sfnetwork) +S3method(st_network_iso,sfnetwork) S3method(st_network_join,sfnetwork) S3method(st_network_paths,sfnetwork) S3method(st_normalize,sfnetwork) diff --git a/R/iso.R b/R/iso.R index ba7ae94d..2fb80825 100644 --- a/R/iso.R +++ b/R/iso.R @@ -1,26 +1,106 @@ +#' 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_edge_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}. +#' +#' @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), 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 sf st_combine st_concave_hull st_sf #' @importFrom units as_units deparse_unit #' @export -st_network_iso = function(x, node, threshold, weights = edge_length(), ..., - delineate = TRUE, ratio = 1, allow_holes = FALSE) { +st_network_iso.sfnetwork = function(x, node, cost, weights = edge_length(), + ..., delineate = TRUE, ratio = 1, + allow_holes = FALSE) { + x = unfocus(x) # Compute the cost matrix from the specified node to all other nodes. - costs = st_network_cost(x, from = node, ...) - # Parse the given threshold values. - if (inherits(costs, "units") && ! inherits(threshold, "units")) { - threshold = as_units(threshold, deparse_unit(costs)) + matrix = st_network_cost(x, from = node, weights = weights, ...) + # Parse the given cost values. + if (inherits(matrix, "units") && ! inherits(cost, "units")) { + cost = as_units(cost, deparse_unit(matrix)) } - # For each given threshold: + # 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 = costs[1, ] <= k + in_iso = matrix[1, ] <= k iso = st_combine(node_geom[in_iso]) if (delineate) { iso = st_concave_hull(iso, ratio = ratio, allow_holes = allow_holes) } iso } - geoms = do.call("c", lapply(threshold, get_single_iso)) - st_sf(threshold = threshold, geometry = geoms) + 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/morphers.R b/R/morphers.R index a6ac5795..7fbc56f0 100644 --- a/R/morphers.R +++ b/R/morphers.R @@ -169,7 +169,7 @@ to_spatial_mixed = function(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 source node to all other nodes in the +#' 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 @@ -185,8 +185,8 @@ to_spatial_mixed = function(x, directed) { #' neighborhood. Should be a numeric value in the same units as the weight #' values used for the cost matrix computation. Alternatively, units can be #' specified explicitly by providing a \code{\link[units]{units}} object. -#' Multiple threshold values may be given, in which a neighborhood is created -#' for each of them separately. +#' Multiple threshold values may be given, which will result in mutliple +#' neigborhoods being returned. #' #' @importFrom igraph induced_subgraph #' @importFrom methods hasArg diff --git a/man/autoplot.Rd b/man/autoplot.Rd index c490d106..7d00e0f3 100644 --- a/man/autoplot.Rd +++ b/man/autoplot.Rd @@ -5,7 +5,7 @@ \alias{autoplot.sfnetwork} \title{Plot sfnetwork geometries with ggplot2} \usage{ -\method{autoplot}{sfnetwork}(object, ...) +autoplot.sfnetwork(object, ...) } \arguments{ \item{object}{An object of class \code{\link{sfnetwork}}.} diff --git a/man/spatial_morphers.Rd b/man/spatial_morphers.Rd index d81ddc9c..b8f32af0 100644 --- a/man/spatial_morphers.Rd +++ b/man/spatial_morphers.Rd @@ -111,8 +111,8 @@ 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 the cost matrix computation. Alternatively, units can be specified explicitly by providing a \code{\link[units]{units}} object. -Multiple threshold values may be given, in which a neighborhood is created -for each of them separately.} +Multiple threshold values may be given, which will result in mutliple +neigborhoods being returned.} \item{protect}{Nodes or edges to be protected from being changed in structure. Evaluated by \code{\link{evaluate_node_query}} in the case of @@ -213,7 +213,7 @@ 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{st_network_cost}} to -compute the travel cost from the source node to all other nodes in the +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 diff --git a/man/st_network_iso.Rd b/man/st_network_iso.Rd new file mode 100644 index 00000000..d6d4c4d5 --- /dev/null +++ b/man/st_network_iso.Rd @@ -0,0 +1,91 @@ +% 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_edge_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}.} + +\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), ratio = 0.3) + +colors = c("#fee6ce90", "#fdae6b90", "#e6550d90") + +plot(net) +plot(st_geometry(iso), col = colors, add = TRUE) + +par(oldpar) + +} From 4c494f333550e3d82592f924ed41420688a7b267 Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Tue, 24 Sep 2024 14:28:35 +0200 Subject: [PATCH 140/246] refactor: Make st_network_faces a generic to follow design principles :construction: --- NAMESPACE | 1 + R/faces.R | 7 ++++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/NAMESPACE b/NAMESPACE index 019c9493..1d9ab05d 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -47,6 +47,7 @@ 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) diff --git a/R/faces.R b/R/faces.R index 53a2a3ea..e84ac0c6 100644 --- a/R/faces.R +++ b/R/faces.R @@ -31,10 +31,15 @@ #' #' 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 = function(x, boundary = NULL) { +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") From f12f9d470d51e38849e5a330791e1274f9b1106c Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Tue, 24 Sep 2024 15:38:19 +0200 Subject: [PATCH 141/246] fix: Correct index column creation :wrench: --- R/contract.R | 2 +- R/simplify.R | 2 +- R/smooth.R | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/R/contract.R b/R/contract.R index 0ed916be..8bb57dec 100644 --- a/R/contract.R +++ b/R/contract.R @@ -56,7 +56,7 @@ contract_nodes = function(x, groups, simplify = TRUE, store_original_data = FALSE) { # Add a index column if not present. if (! ".tidygraph_node_index" %in% vertex_attr_names(x)) { - vertex_attr(x, ".tidygraph_node_index") = seq_len(1:n_nodes(x)) + vertex_attr(x, ".tidygraph_node_index") = seq_len(n_nodes(x)) } # Extract nodes. nodes = nodes_as_sf(x) diff --git a/R/simplify.R b/R/simplify.R index 2b078adf..f8cdce88 100644 --- a/R/simplify.R +++ b/R/simplify.R @@ -41,7 +41,7 @@ simplify_network = function(x, remove_multiple = TRUE, remove_loops = TRUE, store_original_data = FALSE) { # Add a index column if not present. if (! ".tidygraph_edge_index" %in% edge_attr_names(x)) { - edge_attr(x, ".tidygraph_edge_index") = seq_len(1:n_edges(x)) + edge_attr(x, ".tidygraph_edge_index") = seq_len(n_edges(x)) } ## ================================================== # STEP I: REMOVE LOOP EDGES AND MERGE MULTIPLE EDGES diff --git a/R/smooth.R b/R/smooth.R index 22838b05..c6610063 100644 --- a/R/smooth.R +++ b/R/smooth.R @@ -60,7 +60,7 @@ smooth_pseudo_nodes = function(x, protect = NULL, on.exit(igraph_options(return.vs.es = default_igraph_opt)) # Add a index column if not present. if (! ".tidygraph_edge_index" %in% edge_attr_names(x)) { - edge_attr(x, ".tidygraph_edge_index") = seq_len(1:n_edges(x)) + edge_attr(x, ".tidygraph_edge_index") = seq_len(n_edges(x)) } # Retrieve nodes and edges from the network. nodes = nodes_as_sf(x) From 9ca99c9293cb2c356a589300a004e9dc464ed645 Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Tue, 24 Sep 2024 15:38:47 +0200 Subject: [PATCH 142/246] fix: Re-add assume constant warning for subdivision :wrench: --- R/morphers.R | 7 ++++++- R/subdivide.R | 1 + 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/R/morphers.R b/R/morphers.R index 7fbc56f0..e8267038 100644 --- a/R/morphers.R +++ b/R/morphers.R @@ -390,7 +390,12 @@ to_spatial_smooth = function(x, protect = NULL, require_equal = FALSE, to_spatial_subdivision = function(x, protect = NULL, all = FALSE, merge = TRUE) { # Subdivide. - x_new = subdivide_edges(x, protect = protect, all = all, merge = merge) + x_new = subdivide_edges( + x = x, + protect = protect, + all = all, + merge = merge + ) # Return in a list. list( subdivision = x_new diff --git a/R/subdivide.R b/R/subdivide.R index d0d67e2c..2ded1cf6 100644 --- a/R/subdivide.R +++ b/R/subdivide.R @@ -36,6 +36,7 @@ #' @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) ## =========================== From ac153b62e65d9dd3fe7944a2956c4b953c77c027 Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Tue, 24 Sep 2024 18:47:46 +0200 Subject: [PATCH 143/246] feat: New implementation of pseudo node smoothing :gift: --- DESCRIPTION | 1 + NAMESPACE | 2 + R/contract.R | 9 ++- R/ids.R | 35 ++++++++++ R/morphers.R | 14 ++-- R/node.R | 42 ++++++++++++ R/simplify.R | 9 ++- R/smooth.R | 135 ++++++++++++------------------------- R/summarize.R | 51 ++++++++++---- man/autoplot.Rd | 2 +- man/smooth_pseudo_nodes.Rd | 14 ++-- man/spatial_morphers.Rd | 12 ++-- man/spatial_node_types.Rd | 35 ++++++++++ 13 files changed, 223 insertions(+), 138 deletions(-) create mode 100644 man/spatial_node_types.Rd diff --git a/DESCRIPTION b/DESCRIPTION index 7ac6cdbf..18451fd7 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -48,6 +48,7 @@ Imports: stats, tibble, tidygraph, + tidyselect, units, utils Suggests: diff --git a/NAMESPACE b/NAMESPACE index 1d9ab05d..d7e53659 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -125,6 +125,7 @@ export(node_intersects) export(node_is_covered_by) export(node_is_disjoint) export(node_is_nearest) +export(node_is_pseudo) export(node_is_within) export(node_is_within_distance) export(node_touches) @@ -333,6 +334,7 @@ importFrom(tidygraph,tbl_graph) importFrom(tidygraph,unfocus) importFrom(tidygraph,unmorph) importFrom(tidygraph,with_graph) +importFrom(tidyselect,eval_select) importFrom(units,as_units) importFrom(units,deparse_unit) importFrom(units,drop_units) diff --git a/R/contract.R b/R/contract.R index 8bb57dec..94fe0be4 100644 --- a/R/contract.R +++ b/R/contract.R @@ -54,10 +54,9 @@ contract_nodes = function(x, groups, simplify = TRUE, summarise_attributes = "concat", store_original_ids = FALSE, store_original_data = FALSE) { - # Add a index column if not present. - if (! ".tidygraph_node_index" %in% vertex_attr_names(x)) { - vertex_attr(x, ".tidygraph_node_index") = seq_len(n_nodes(x)) - } + # 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") @@ -151,7 +150,7 @@ contract_nodes = function(x, groups, simplify = TRUE, } # Remove original indices if requested. if (! store_original_ids) { - x_new = delete_vertex_attr(x_new, ".tidygraph_node_index") + x_new = drop_original_ids(x_new) } x_new } \ No newline at end of file diff --git a/R/ids.R b/R/ids.R index e3e72cea..5914f677 100644 --- a/R/ids.R +++ b/R/ids.R @@ -259,3 +259,38 @@ edge_boundary_ids = function(x, focused = FALSE, matrix = FALSE) { } 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/morphers.R b/R/morphers.R index e8267038..638a836e 100644 --- a/R/morphers.R +++ b/R/morphers.R @@ -335,23 +335,21 @@ 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 require_equal Should nodes only be smoothed 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 -#' smoothed. 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. #' #' @export -to_spatial_smooth = function(x, protect = NULL, require_equal = FALSE, +to_spatial_smooth = function(x, protect = NULL, require_equal = NULL, summarise_attributes = "concat", store_original_data = FALSE) { # Smooth. x_new = smooth_pseudo_nodes( x = x, protect = protect, - summarise_attributes = summarise_attributes, require_equal = require_equal, + summarise_attributes = summarise_attributes, store_original_ids = TRUE, store_original_data = store_original_data ) diff --git a/R/node.R b/R/node.R index f763a8d6..288a04ea 100644 --- a/R/node.R +++ b/R/node.R @@ -1,3 +1,45 @@ +#' Query spatial node types +#' +#' These functions add to tidygraphs \code{\link[tidygraph][node_types]} +#' functions that allows to query whether each node is of a certain type. The +#' functions added here query node types that are commonly used in spatial +#' network analysis. +#' +#' @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. +#' +#' @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. +#' @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 + } +} + #' Query node coordinates #' #' These functions allow to query specific coordinate values from the diff --git a/R/simplify.R b/R/simplify.R index f8cdce88..48d25ed6 100644 --- a/R/simplify.R +++ b/R/simplify.R @@ -39,10 +39,9 @@ simplify_network = function(x, remove_multiple = TRUE, remove_loops = TRUE, summarise_attributes = "first", store_original_ids = FALSE, store_original_data = FALSE) { - # Add a index column if not present. - if (! ".tidygraph_edge_index" %in% edge_attr_names(x)) { - edge_attr(x, ".tidygraph_edge_index") = seq_len(n_edges(x)) - } + # 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 @@ -89,7 +88,7 @@ simplify_network = function(x, remove_multiple = TRUE, remove_loops = TRUE, } # Remove original indices if requested. if (! store_original_ids) { - x_new = delete_edge_attr(x_new, ".tidygraph_edge_index") + 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 index c6610063..1280be67 100644 --- a/R/smooth.R +++ b/R/smooth.R @@ -15,17 +15,15 @@ #' are a pseudo node or not. Evaluated by \code{\link{evaluate_node_query}}. #' Defaults to \code{NULL}, meaning that none of the nodes is protected. #' +#' @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. +#' #' @param summarise_attributes How should the attributes of concatenated edges #' be summarized? There are several options, see #' \code{\link[igraph]{igraph-attribute-combination}} for details. #' -#' @param require_equal Should nodes only be smoothed 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 -#' smoothed. 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 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 @@ -39,16 +37,17 @@ #' @returns The smoothed network as object of class \code{\link{sfnetwork}}. #' #' @importFrom cli cli_abort -#' @importFrom igraph adjacent_vertices decompose degree delete_edge_attr -#' delete_vertices edge_attr edge_attr<- edge_attr_names 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 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 rlang enquo try_fetch +#' @importFrom sf st_as_sf st_cast st_combine st_crs st_drop_geometry +#' st_equals st_is st_line_merge +#' @importFrom tidyselect eval_select #' @export smooth_pseudo_nodes = function(x, protect = NULL, + require_equal = NULL, summarise_attributes = "concat", - require_equal = FALSE, store_original_ids = FALSE, store_original_data = FALSE) { # Change default igraph options. @@ -58,10 +57,9 @@ smooth_pseudo_nodes = function(x, protect = NULL, 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 a index column if not present. - if (! ".tidygraph_edge_index" %in% edge_attr_names(x)) { - edge_attr(x, ".tidygraph_edge_index") = seq_len(n_edges(x)) - } + # 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) @@ -80,79 +78,40 @@ smooth_pseudo_nodes = function(x, protect = NULL, # 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. - ## =========================== + pseudo = is_pseudo_node(x) # Detected pseudo nodes that are protected should be filtered out. if (! is.null(protect)) { # Evaluate the given protected nodes query. protect = evaluate_node_query(x, protect) # Mark all protected nodes as not being a pseudo node. pseudo[protect] = FALSE - if (! any(pseudo)) return (x) } # 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_colnames(x, geom = FALSE) - } else { - # Check if all given attributes exist in the edges table of x. - attr_exists = require_equal %in% edge_colnames(x, geom = FALSE) - if (! all(attr_exists)) { - unknown_attrs = paste(require_equal[!attr_exists], collapse = ", ") - cli_abort(c( - "Failed to check for edge attribute equality.", - "x" = "The following edge attributes were not found: {unknown_attrs}" - )) - } + if (! try_fetch(is.null(require_equal), error = \(e) FALSE)) { + pseudo_ids = which(pseudo) + exclude = c("from", "to", ".tidygraph_edge_index") + edge_attrs = st_drop_geometry(edges) + edge_attrs = edge_attrs[, !(names(edge_attrs) %in% exclude)] + edge_attrs = edge_attrs[, eval_select(enquo(require_equal), edge_attrs)] + 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) } - # 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_attr(x, require_equal, incident_idxs) - # 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 + # Remove original indices if requested. + if (! store_original_ids) { + x = drop_original_ids(x) } - 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) + # Return x without smoothing. + return(x) } ## ==================================== # STEP II: INITIALIZE REPLACEMENT EDGES @@ -304,16 +263,8 @@ smooth_pseudo_nodes = function(x, protect = NULL, # 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 + ids = E$.tidygraph_edge_index + summarize_attributes(edge_attrs, summarise_attributes, subset = ids) } new_attrs = lapply(new_idxs, merge_attrs) ## =================================== @@ -399,7 +350,7 @@ smooth_pseudo_nodes = function(x, protect = NULL, } # Remove original indices if requested. if (! store_original_ids) { - x_new = delete_edge_attr(x_new, ".tidygraph_edge_index") + x_new = drop_original_ids(x_new) } x_new -} \ No newline at end of file +} diff --git a/R/summarize.R b/R/summarize.R index 0c3f477d..1635c760 100644 --- a/R/summarize.R +++ b/R/summarize.R @@ -1,27 +1,54 @@ +#' 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 spec Specification of the summary function belonging to each -#' 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, spec) { - if (!is.list(spec)) { - func = spec +get_summary_function = function(attr, summary) { + if (!is.list(summary)) { + func = summary } else { - names = names(spec) + names = names(summary) if (is.null(names)) { - func = spec[[1]] + func = summary[[1]] } else { - func = spec[[attr]] + func = summary[[attr]] if (is.null(func)) { default = which(names == "") if (length(default) > 0) { - func = spec[[default[1]]] + func = summary[[default[1]]] } else { func = "ignore" } @@ -31,13 +58,13 @@ get_summary_function = function(attr, spec) { if (is.function(func)) { func } else { - summariser(func) + summarizer(func) } } #' @importFrom stats median #' @importFrom utils head tail -summariser = function(name) { +summarizer = function(name) { switch( name, ignore = function(x) NA, @@ -51,6 +78,6 @@ summariser = function(name) { mean = function(x) mean(x), median = function(x) median(x), concat = function(x) c(x), - raise_unknown_summariser(name) + raise_unknown_summarizer(name) ) } diff --git a/man/autoplot.Rd b/man/autoplot.Rd index 7d00e0f3..c490d106 100644 --- a/man/autoplot.Rd +++ b/man/autoplot.Rd @@ -5,7 +5,7 @@ \alias{autoplot.sfnetwork} \title{Plot sfnetwork geometries with ggplot2} \usage{ -autoplot.sfnetwork(object, ...) +\method{autoplot}{sfnetwork}(object, ...) } \arguments{ \item{object}{An object of class \code{\link{sfnetwork}}.} diff --git a/man/smooth_pseudo_nodes.Rd b/man/smooth_pseudo_nodes.Rd index 8f505bd4..045a5bd9 100644 --- a/man/smooth_pseudo_nodes.Rd +++ b/man/smooth_pseudo_nodes.Rd @@ -7,8 +7,8 @@ smooth_pseudo_nodes( x, protect = NULL, + require_equal = NULL, summarise_attributes = "concat", - require_equal = FALSE, store_original_ids = FALSE, store_original_data = FALSE ) @@ -20,17 +20,15 @@ smooth_pseudo_nodes( are a pseudo node or not. Evaluated by \code{\link{evaluate_node_query}}. Defaults to \code{NULL}, meaning that none of the nodes is protected.} +\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{summarise_attributes}{How should the attributes of concatenated edges be summarized? There are several options, see \code{\link[igraph]{igraph-attribute-combination}} for details.} -\item{require_equal}{Should nodes only be smoothed 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 -smoothed. 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{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 diff --git a/man/spatial_morphers.Rd b/man/spatial_morphers.Rd index b8f32af0..1c761943 100644 --- a/man/spatial_morphers.Rd +++ b/man/spatial_morphers.Rd @@ -52,7 +52,7 @@ to_spatial_simple( to_spatial_smooth( x, protect = NULL, - require_equal = FALSE, + require_equal = NULL, summarise_attributes = "concat", store_original_data = FALSE ) @@ -124,12 +124,10 @@ to \code{TRUE}.} \item{remove_loops}{Should loop edges be removed. Defaults to \code{TRUE}.} -\item{require_equal}{Should nodes only be smoothed 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 -smoothed. 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 diff --git a/man/spatial_node_types.Rd b/man/spatial_node_types.Rd new file mode 100644 index 00000000..f8233112 --- /dev/null +++ b/man/spatial_node_types.Rd @@ -0,0 +1,35 @@ +% 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} +\title{Query spatial node types} +\usage{ +node_is_pseudo() +} +\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 add to tidygraphs \code{\link[tidygraph][node_types]} +functions that allows to query whether each node is of a certain type. The +functions added here query node types that are commonly used in spatial +network analysis. +} +\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. + +}} From 5a1edf169bf586c2818fc615f2a0c270a6e74961 Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Tue, 24 Sep 2024 19:03:00 +0200 Subject: [PATCH 144/246] feat: New edge measure to count segments :gift: --- NAMESPACE | 1 + R/edge.R | 21 ++++++++++++++++++--- R/node.R | 7 +++---- man/spatial_edge_measures.Rd | 15 ++++++++++++--- man/spatial_node_types.Rd | 7 +++---- 5 files changed, 37 insertions(+), 14 deletions(-) diff --git a/NAMESPACE b/NAMESPACE index d7e53659..6bdaf6dd 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -93,6 +93,7 @@ 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) diff --git a/R/edge.R b/R/edge.R index 022b946d..0abc13bb 100644 --- a/R/edge.R +++ b/R/edge.R @@ -26,9 +26,7 @@ evaluate_edge_query = function(data, edges) { #' 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 @@ -164,6 +162,23 @@ straight_line_distance = function(x) { 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 diff --git a/R/node.R b/R/node.R index 288a04ea..9ac72298 100644 --- a/R/node.R +++ b/R/node.R @@ -1,9 +1,8 @@ #' Query spatial node types #' -#' These functions add to tidygraphs \code{\link[tidygraph][node_types]} -#' functions that allows to query whether each node is of a certain type. The -#' functions added here query node types that are commonly used in spatial -#' network analysis. +#' 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. diff --git a/man/spatial_edge_measures.Rd b/man/spatial_edge_measures.Rd index 88f03a4e..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 @@ -61,6 +62,10 @@ 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) @@ -88,4 +93,8 @@ net |> activate(edges) |> mutate(displacement = edge_displacement()) +net |> + activate(edges) |> + mutate(n_segs = edge_segment_count()) + } diff --git a/man/spatial_node_types.Rd b/man/spatial_node_types.Rd index f8233112..70083c86 100644 --- a/man/spatial_node_types.Rd +++ b/man/spatial_node_types.Rd @@ -12,10 +12,9 @@ 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 add to tidygraphs \code{\link[tidygraph][node_types]} -functions that allows to query whether each node is of a certain type. The -functions added here query node types that are commonly used in spatial -network analysis. +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 From f69e81fb3215911b2044af4be99aedd84c29ce40 Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Tue, 24 Sep 2024 19:23:38 +0200 Subject: [PATCH 145/246] feat: Add new node type query for dangling nodes :gift: --- NAMESPACE | 1 + R/node.R | 52 ++++++++++++++++++++++++++++++++++++++- man/spatial_node_types.Rd | 43 +++++++++++++++++++++++++++++++- 3 files changed, 94 insertions(+), 2 deletions(-) diff --git a/NAMESPACE b/NAMESPACE index 6bdaf6dd..60b0ef18 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -124,6 +124,7 @@ 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) diff --git a/R/node.R b/R/node.R index 9ac72298..1b4dd4f9 100644 --- a/R/node.R +++ b/R/node.R @@ -15,12 +15,46 @@ #' 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. +#' pseudo nodes are those nodes with only two incident edges, i.e. nodes of +#' degree 2. #' @importFrom tidygraph .G #' @export node_is_pseudo = function() { @@ -39,6 +73,22 @@ is_pseudo_node = function(x) { } } +#' @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 diff --git a/man/spatial_node_types.Rd b/man/spatial_node_types.Rd index 70083c86..0fa91b33 100644 --- a/man/spatial_node_types.Rd +++ b/man/spatial_node_types.Rd @@ -3,9 +3,12 @@ \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 @@ -29,6 +32,44 @@ set the context temporarily while the algorithm is being evaluated. \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. +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) + +} From 3499a40d3ce3d7006bfcf1d2f71bb912f94c04d6 Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Wed, 25 Sep 2024 20:22:12 +0200 Subject: [PATCH 146/246] fix: Correct forwarding of diffused arguments :wrench: --- NAMESPACE | 2 ++ R/cost.R | 7 +++-- R/data.R | 41 ++++++++++++++++++++++++- R/edge.R | 7 ++--- R/ids.R | 24 ++++++++++----- R/iso.R | 17 +++++++++-- R/morphers.R | 61 +++++++++++++++++++++++++++++-------- R/paths.R | 7 +++-- R/smooth.R | 26 ++++++---------- R/subdivide.R | 7 ++--- R/travel.R | 5 +-- R/weights.R | 10 ++++-- man/autoplot.Rd | 2 +- man/evaluate_edge_query.Rd | 8 +++-- man/evaluate_node_query.Rd | 8 +++-- man/evaluate_weight_spec.Rd | 6 ++-- man/make_edges_mixed.Rd | 4 +-- man/smooth_pseudo_nodes.Rd | 14 ++++----- man/spatial_morphers.Rd | 20 +++++++----- man/subdivide_edges.Rd | 6 ++-- 20 files changed, 194 insertions(+), 88 deletions(-) diff --git a/NAMESPACE b/NAMESPACE index 60b0ef18..e0ba8da4 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -178,11 +178,13 @@ importFrom(dbscan,dbscan) importFrom(dplyr,across) importFrom(dplyr,arrange) importFrom(dplyr,bind_rows) +importFrom(dplyr,distinct) importFrom(dplyr,full_join) importFrom(dplyr,group_by) importFrom(dplyr,group_indices) importFrom(dplyr,join_by) importFrom(dplyr,mutate) +importFrom(dplyr,slice) importFrom(graphics,plot) importFrom(igraph,"edge_attr<-") importFrom(igraph,"graph_attr<-") diff --git a/R/cost.R b/R/cost.R index e4607d0b..44ec2ced 100644 --- a/R/cost.R +++ b/R/cost.R @@ -93,19 +93,20 @@ st_network_cost = function(x, from = node_ids(x), to = node_ids(x), 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, ...) { # Evaluate the given from node query. - from = evaluate_node_query(x, from) + 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, to) + 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, weights) + weights = evaluate_weight_spec(x, enquo(weights)) # Parse other arguments. # --> The direction argument is used instead of igraphs mode argument. # --> This means the mode argument should not be set. diff --git a/R/data.R b/R/data.R index 0cc44b01..10952a63 100644 --- a/R/data.R +++ b/R/data.R @@ -72,7 +72,7 @@ n_edges = function(x, focused = FALSE) { } } -#' Get column names of the nodes or edges table of of a sfnetwork +#' Get the column names of the node or edge data #' #' @param x An object of class \code{\link{sfnetwork}}. #' @@ -112,6 +112,45 @@ edge_colnames = function(x, idxs = FALSE, geom = TRUE) { 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", ".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_edge_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}}. diff --git a/R/edge.R b/R/edge.R index 0abc13bb..77e30f89 100644 --- a/R/edge.R +++ b/R/edge.R @@ -431,8 +431,8 @@ make_edges_directed = function(x) { #' #' @param x An object of class \code{\link{sfnetwork}}. #' -#' @param directed Which edges should be directed? Evaluated by -#' \code{\link{evaluate_edge_query}}. +#' @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}}. #' @@ -451,8 +451,7 @@ make_edges_mixed = function(x, directed) { raise_reserved_attr(".sfnetwork_index") } edges$.sfnetwork_index = edge_ids - # Define which edges should be directed, and which undirected. - directed = evaluate_edge_query(x, directed) + # Define which edges should be undirected. undirected = setdiff(edge_ids, directed) # Duplicate undirected edges. duplicates = edges[undirected, ] diff --git a/R/ids.R b/R/ids.R index 5914f677..ee9f5560 100644 --- a/R/ids.R +++ b/R/ids.R @@ -40,10 +40,14 @@ edge_ids = function(x, focused = TRUE) { #' 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. See -#' Details. +#' @param query The query that defines for which nodes to extract indices, +#' defused into a \code{\link[dplyr: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: @@ -81,12 +85,12 @@ edge_ids = function(x, focused = TRUE) { #' #' @importFrom cli cli_abort #' @importFrom igraph vertex_attr -#' @importFrom rlang enquo eval_tidy +#' @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(enquo(query), .N()) + nodes = eval_tidy(query, .N()) if (is_sf(nodes) | is_sfc(nodes)) { nodes = nearest_node_ids(data, nodes) } else if (is.logical(nodes)) { @@ -111,10 +115,14 @@ evaluate_node_query = function(data, query) { #' 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. See -#' Details. +#' @param query The query that defines for which edges to extract indices, +#' defused into a \code{\link[dplyr: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: @@ -152,12 +160,12 @@ evaluate_node_query = function(data, query) { #' #' @importFrom cli cli_abort #' @importFrom igraph edge_attr -#' @importFrom rlang enquo eval_tidy +#' @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(enquo(query), .E()) + edges = eval_tidy(query, .E()) if (is_sf(edges) | is_sfc(edges)) { edges = nearest_edge_ids(data, edges) } else if (is.logical(edges)) { diff --git a/R/iso.R b/R/iso.R index 2fb80825..e755d7a2 100644 --- a/R/iso.R +++ b/R/iso.R @@ -76,15 +76,28 @@ st_network_iso = function(x, node, cost, weights = edge_length(), ..., UseMethod("st_network_iso") } +#' @importFrom methods hasArg +#' @importFrom rlang enquo #' @importFrom sf st_combine st_concave_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) { - x = unfocus(x) + # Evaluate the given node query. + # Always only the first node is used. + node = evaluate_node_query(x, enquo(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 = st_network_cost(x, from = node, weights = weights, ...) + 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)) diff --git a/R/morphers.R b/R/morphers.R index 638a836e..1df7a97a 100644 --- a/R/morphers.R +++ b/R/morphers.R @@ -160,8 +160,10 @@ to_spatial_implicit = function(x) { #' @param directed Which edges should be directed? Evaluated by #' \code{\link{evaluate_edge_query}}. #' +#' @importFrom rlang enquo #' @export to_spatial_mixed = function(x, directed) { + directed = evaluate_edge_query(x, enquo(directed)) list( mixed = make_edges_mixed(x, directed) ) @@ -180,31 +182,49 @@ to_spatial_mixed = function(x, directed) { #' Evaluated by \code{\link{evaluate_node_query}}. When multiple nodes are #' given, only the first one is used. #' -#' @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 the cost matrix computation. 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 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_edge_spec}}. The default is +#' \code{\link{edge_length}}, which computes the geographic lengths of the +#' edges. #' #' @importFrom igraph induced_subgraph #' @importFrom methods hasArg +#' @importFrom rlang enquo #' @importFrom units as_units deparse_unit #' @export -to_spatial_neighborhood = function(x, node, threshold, ...) { +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))[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 = st_network_cost(x, from = node, direction = "in", ...) + costs = compute_costs(x, node, to, weights, direction = "in", ...) } else { - costs = st_network_cost(x, from = node, ...) + costs = compute_costs(x, node, to, weights, ...) } } else { - costs = st_network_cost(x, from = node, ...) + costs = compute_costs(x, node, to, weights, ...) } # Parse the given threshold values. if (inherits(costs, "units") && ! inherits(threshold, "units")) { @@ -224,14 +244,15 @@ to_spatial_neighborhood = function(x, node, threshold, ...) { #' \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_reversed = function(x, protect = NULL) { # Define which edges should be reversed. - if (is.null(protect)) { + if (try_fetch(is.null(protect), error = \(e) FALSE)) { reverse = edge_ids(x, focused = FALSE) } else { - protect = evaluate_edge_query(x, protect) + protect = evaluate_edge_query(x, enquo(protect)) reverse = setdiff(edge_ids(x, focused = FALSE), protect) } # Reverse the from and to indices of those edges. @@ -340,10 +361,19 @@ to_spatial_simple = function(x, remove_multiple = TRUE, remove_loops = TRUE, #' \code{\link[dplyr]{dplyr_tidy_select}} argument. Defaults to \code{NULL}, #' meaning that attribute equality is not considered for pseudo node removal. #' +#' @importFrom rlang enquo try_fetch #' @export to_spatial_smooth = function(x, protect = NULL, require_equal = NULL, summarise_attributes = "concat", store_original_data = FALSE) { + # Evaluate the node query of the protect argument. + if (! try_fetch(is.null(protect), error = \(e) FALSE)) { + protect = evaluate_node_query(x, enquo(protect)) + } + # 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)) + } # Smooth. x_new = smooth_pseudo_nodes( x = x, @@ -384,9 +414,14 @@ to_spatial_smooth = function(x, protect = NULL, require_equal = NULL, #' 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, 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)) + } # Subdivide. x_new = subdivide_edges( x = x, diff --git a/R/paths.R b/R/paths.R index be4438d6..187ac17b 100644 --- a/R/paths.R +++ b/R/paths.R @@ -155,6 +155,7 @@ st_network_paths = function(x, from, to = node_ids(x), UseMethod("st_network_paths") } +#' @importFrom rlang enquo #' @export st_network_paths.sfnetwork = function(x, from, to = node_ids(x), weights = edge_length(), @@ -162,14 +163,14 @@ st_network_paths.sfnetwork = function(x, from, to = node_ids(x), use_names = TRUE, return_cost = TRUE, return_geometry = TRUE, ...) { # Evaluate the given from node query. - from = evaluate_node_query(x, from) + from = evaluate_node_query(x, enquo(from)) if (length(from) > 1) raise_multiple_elements("from"); from = from[1] if (any(is.na(from))) raise_na_values("from") # Evaluate the given to node query. - to = evaluate_node_query(x, to) + 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, weights) + weights = evaluate_weight_spec(x, enquo(weights)) # Compute the shortest paths. find_paths( x, from, to, weights, diff --git a/R/smooth.R b/R/smooth.R index 1280be67..0877d5d9 100644 --- a/R/smooth.R +++ b/R/smooth.R @@ -11,14 +11,14 @@ #' #' @param x An object of class \code{\link{sfnetwork}}. #' -#' @param protect Nodes to be protected from being removed, no matter if they -#' are a pseudo node or not. Evaluated by \code{\link{evaluate_node_query}}. -#' Defaults to \code{NULL}, meaning that none of the nodes is protected. +#' @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 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. +#' @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 summarise_attributes How should the attributes of concatenated edges #' be summarized? There are several options, see @@ -37,13 +37,12 @@ #' @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 rlang enquo try_fetch #' @importFrom sf st_as_sf st_cast st_combine st_crs st_drop_geometry #' st_equals st_is st_line_merge -#' @importFrom tidyselect eval_select #' @export smooth_pseudo_nodes = function(x, protect = NULL, require_equal = NULL, @@ -81,19 +80,14 @@ smooth_pseudo_nodes = function(x, protect = NULL, pseudo = is_pseudo_node(x) # Detected pseudo nodes that are protected should be filtered out. if (! is.null(protect)) { - # Evaluate the given protected nodes query. - protect = evaluate_node_query(x, protect) - # Mark all protected nodes as not being a pseudo node. pseudo[protect] = FALSE } # Check for equality of certain attributes between incident edges. # Detected pseudo nodes that fail this check should be filtered out. - if (! try_fetch(is.null(require_equal), error = \(e) FALSE)) { + if (! is.null(require_equal)) { pseudo_ids = which(pseudo) - exclude = c("from", "to", ".tidygraph_edge_index") edge_attrs = st_drop_geometry(edges) - edge_attrs = edge_attrs[, !(names(edge_attrs) %in% exclude)] - edge_attrs = edge_attrs[, eval_select(enquo(require_equal), edge_attrs)] + 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)) diff --git a/R/subdivide.R b/R/subdivide.R index 2ded1cf6..7e63105d 100644 --- a/R/subdivide.R +++ b/R/subdivide.R @@ -8,9 +8,9 @@ #' @param x An object of class \code{\link{sfnetwork}} with spatially explicit #' edges. #' -#' @param protect Edges to be protected from being subdivided. Evaluated by -#' \code{\link{evaluate_edge_query}}. Defaults to \code{NULL}, meaning that -#' none of the edges is protected. +#' @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 @@ -81,7 +81,6 @@ subdivide_edges = function(x, protect = NULL, all = FALSE, merge = TRUE) { # Define which edges to protect from being subdivided. is_protected = rep(FALSE, nrow(edge_pts)) if (! is.null(protect)) { - protect = evaluate_edge_query(x, protect) is_protected[edge_pts$eid %in% protect] = TRUE } # Define the subdivision points. diff --git a/R/travel.R b/R/travel.R index 94b4d880..df0ab3e6 100644 --- a/R/travel.R +++ b/R/travel.R @@ -17,6 +17,7 @@ #' \code{\link[sf]{sf}} with one row per path, or a vector with ordered indices #' for `pois`. #' +#' @importFrom rlang enquo #' @importFrom stats as.dist #' @importFrom TSP solve_TSP TSP ATSP #' @export @@ -29,10 +30,10 @@ st_network_travel = function(x, pois, weights = edge_length(), return_geometry = TRUE, ...) { # Evaluate the node query for the pois. - pois = evaluate_node_query(x, pois) + pois = evaluate_node_query(x, enquo(pois)) if (any(is.na(pois))) raise_na_values("pois") # Evaluate the given weights specification. - weights = evaluate_weight_spec(x, weights) + weights = evaluate_weight_spec(x, enquo(weights)) # Compute cost matrix costmat = compute_costs(x, from = pois, to = pois, weights = weights) # Use nearest node indices as row and column names diff --git a/R/weights.R b/R/weights.R index 7a2f4aa0..7d543bdd 100644 --- a/R/weights.R +++ b/R/weights.R @@ -1,9 +1,13 @@ #' 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. See Details. +#' weights defused into a \code{\link[dplyr: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: @@ -34,12 +38,12 @@ #' @return A numeric vector of edge weights. #' #' @importFrom cli cli_abort -#' @importFrom rlang enquo eval_tidy expr +#' @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(enquo(spec), .E()) + weights = eval_tidy(spec, .E()) if (is_single_string(weights)) { # Allow character values for backward compatibility. deprecate_weights_is_string() diff --git a/man/autoplot.Rd b/man/autoplot.Rd index c490d106..7d00e0f3 100644 --- a/man/autoplot.Rd +++ b/man/autoplot.Rd @@ -5,7 +5,7 @@ \alias{autoplot.sfnetwork} \title{Plot sfnetwork geometries with ggplot2} \usage{ -\method{autoplot}{sfnetwork}(object, ...) +autoplot.sfnetwork(object, ...) } \arguments{ \item{object}{An object of class \code{\link{sfnetwork}}.} diff --git a/man/evaluate_edge_query.Rd b/man/evaluate_edge_query.Rd index 8ea54764..21044b6d 100644 --- a/man/evaluate_edge_query.Rd +++ b/man/evaluate_edge_query.Rd @@ -9,14 +9,16 @@ 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. See -Details.} +\item{query}{The query that defines for which edges to extract indices, +defused into a \code{\link[dplyr:topic-quosure]{quosure}}. See Details for +the different ways in which edge queries can be formulated.} } \value{ A vector of queried edge indices. } \description{ -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. } \details{ There are multiple ways in which edge indices can be queried in diff --git a/man/evaluate_node_query.Rd b/man/evaluate_node_query.Rd index 6993b6af..0a4e7416 100644 --- a/man/evaluate_node_query.Rd +++ b/man/evaluate_node_query.Rd @@ -9,14 +9,16 @@ 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. See -Details.} +\item{query}{The query that defines for which nodes to extract indices, +defused into a \code{\link[dplyr:topic-quosure]{quosure}}. See Details for +the different ways in which node queries can be formulated.} } \value{ A vector of queried node indices. } \description{ -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. } \details{ There are multiple ways in which node indices can be queried in diff --git a/man/evaluate_weight_spec.Rd b/man/evaluate_weight_spec.Rd index e976e5af..f87576eb 100644 --- a/man/evaluate_weight_spec.Rd +++ b/man/evaluate_weight_spec.Rd @@ -10,13 +10,15 @@ evaluate_weight_spec(data, spec) \item{data}{An object of class \code{\link{sfnetwork}}.} \item{spec}{The specification that defines how to compute or extract edge -weights. See Details.} +weights defused into a \code{\link[dplyr:topic-quosure]{quosure}}. See +Details for the different ways in which edge weights can be specified.} } \value{ A numeric vector of edge weights. } \description{ -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. } \details{ There are multiple ways in which edge weights can be specified in diff --git a/man/make_edges_mixed.Rd b/man/make_edges_mixed.Rd index 67e05ff9..da78c0ce 100644 --- a/man/make_edges_mixed.Rd +++ b/man/make_edges_mixed.Rd @@ -9,8 +9,8 @@ make_edges_mixed(x, directed) \arguments{ \item{x}{An object of class \code{\link{sfnetwork}}.} -\item{directed}{Which edges should be directed? Evaluated by -\code{\link{evaluate_edge_query}}.} +\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}}. diff --git a/man/smooth_pseudo_nodes.Rd b/man/smooth_pseudo_nodes.Rd index 045a5bd9..e85a0f80 100644 --- a/man/smooth_pseudo_nodes.Rd +++ b/man/smooth_pseudo_nodes.Rd @@ -16,14 +16,14 @@ smooth_pseudo_nodes( \arguments{ \item{x}{An object of class \code{\link{sfnetwork}}.} -\item{protect}{Nodes to be protected from being removed, no matter if they -are a pseudo node or not. Evaluated by \code{\link{evaluate_node_query}}. -Defaults to \code{NULL}, meaning that none of the nodes is protected.} +\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}{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{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{summarise_attributes}{How should the attributes of concatenated edges be summarized? There are several options, see diff --git a/man/spatial_morphers.Rd b/man/spatial_morphers.Rd index 1c761943..7e86ae47 100644 --- a/man/spatial_morphers.Rd +++ b/man/spatial_morphers.Rd @@ -35,7 +35,7 @@ to_spatial_implicit(x) to_spatial_mixed(x, directed) -to_spatial_neighborhood(x, node, threshold, ...) +to_spatial_neighborhood(x, node, threshold, weights = edge_length(), ...) to_spatial_reversed(x, protect = NULL) @@ -106,13 +106,17 @@ features be stored as an attribute of the new feature, in a column named Evaluated by \code{\link{evaluate_node_query}}. When multiple nodes are given, only the first one is used.} -\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 the cost matrix computation. 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{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_edge_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 diff --git a/man/subdivide_edges.Rd b/man/subdivide_edges.Rd index 15e3e3c7..f6000f42 100644 --- a/man/subdivide_edges.Rd +++ b/man/subdivide_edges.Rd @@ -10,9 +10,9 @@ subdivide_edges(x, protect = NULL, all = FALSE, merge = TRUE) \item{x}{An object of class \code{\link{sfnetwork}} with spatially explicit edges.} -\item{protect}{Edges to be protected from being subdivided. Evaluated by -\code{\link{evaluate_edge_query}}. Defaults to \code{NULL}, meaning that -none of the edges is protected.} +\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 From e9355c3ebb847b32d74e8e48742a7594b0f3ceaa Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Thu, 26 Sep 2024 16:39:22 +0200 Subject: [PATCH 147/246] feat: Add support for k shortest path and drop support for all simple paths. Refs #142 :gift: --- NAMESPACE | 2 +- R/morphers.R | 17 ++- R/paths.R | 213 ++++++++++++++++++++---------------- R/weights.R | 3 +- man/spatial_morphers.Rd | 11 +- man/st_network_paths.Rd | 67 +++++------- man/st_network_travel.Rd | 5 +- vignettes/sfn04_routing.qmd | 10 +- 8 files changed, 169 insertions(+), 159 deletions(-) diff --git a/NAMESPACE b/NAMESPACE index e0ba8da4..387ef5a2 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -191,7 +191,6 @@ importFrom(igraph,"graph_attr<-") importFrom(igraph,"vertex_attr<-") 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) @@ -221,6 +220,7 @@ 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) diff --git a/R/morphers.R b/R/morphers.R index 1df7a97a..71ca07cc 100644 --- a/R/morphers.R +++ b/R/morphers.R @@ -272,21 +272,18 @@ to_spatial_reversed = function(x, protect = NULL) { #' @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. +#' 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, ...) { # Call st_network_paths with the given arguments. - if (hasArg("type")) raise_unsupported_arg("type") paths = st_network_paths( x, ..., - type = "shortest", use_names = FALSE, return_cost = FALSE, return_geometry = FALSE @@ -297,8 +294,8 @@ to_spatial_shortest_paths = function(x, ...) { # Subset the network for each computed shortest path. get_single_path = function(i) { if (paths[i, ]$path_found) { - node_ids = paths$nodes[[i]] - edge_ids = paths$edges[[i]] + 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)) diff --git a/R/paths.R b/R/paths.R index 187ac17b..f4a7453f 100644 --- a/R/paths.R +++ b/R/paths.R @@ -1,9 +1,4 @@ -#' Find paths between nodes in a spatial network -#' -#' A function implementing one-to-one and one-to-many routing on spatial -#' networks. It can be used to either find one shortest path, all shortest -#' paths, or all simple paths between one node and one or -#' more other nodes in the network. +#' Find shortest paths between nodes in a spatial network #' #' @param x An object of class \code{\link{sfnetwork}}. #' @@ -11,7 +6,7 @@ #' \code{\link{evaluate_node_query}}. When multiple nodes are given, only the #' first one is used. #' -#' @param to The node where the paths should start. Evaluated by +#' @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. #' @@ -20,12 +15,16 @@ #' \code{\link{edge_length}}, which computes the geographic lengths of the #' edges. #' -#' @param type Character defining which type of path calculation should be -#' performed. If set to \code{'shortest'} paths are found using -#' \code{\link[igraph]{shortest_paths}}, if set to \code{'all_shortest'} paths -#' are found using \code{\link[igraph]{all_shortest_paths}}, if set to -#' \code{'all_simple'} paths are found using -#' \code{\link[igraph]{all_simple_paths}}. Defaults to \code{'shortest'}. +#' @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 @@ -41,36 +40,29 @@ #' not have a column named \code{name}. #' #' @param return_cost Should the total cost of each path be computed? Defaults -#' to \code{TRUE}. Ignored if \code{type = 'all_simple'}. +#' 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 if \code{type = 'all_simple'} and for networks with -#' spatially implicit edges. +#' the path. Ignored for networks with spatially implicit edges. #' #' @param ... Additional arguments passed on to the wrapped igraph functions. #' Arguments \code{predecessors} and \code{inbound.edges} are ignored. #' Instead of the \code{mode} argument, use the \code{direction} argument. #' #' @details For more details on the wrapped igraph functions see the -#' \code{\link[igraph]{distances}} and -#' \code{\link[igraph]{all_simple_paths}} documentation pages. -#' -#' @note When computing simple paths by setting \code{type = 'all_simple'}, -#' note that potentially there are exponentially many paths between two nodes, -#' and you may run out of memory especially in undirected, dense, and/or -#' lattice-like networks. +#' \code{\link[igraph]{distances}} and \code{\link[igraph]{k_shortest_paths}} +#' documentation pages. #' #' @seealso \code{\link{st_network_cost}}, \code{\link{st_network_travel}} #' -#' @return An object of class \code{\link[tibble]{tbl_df}} or -#' \code{\link[sf]{sf}} with one row per path. If \code{type = 'shortest'}, the -#' number of rows is always equal to the number of requested paths, meaning -#' that node pairs for which no path could be found are still part of the -#' output. For all other path types, the output only contains finite paths. +#' @return An object of class \code{\link[sf]{sf}} with one row per requested +#' path. If \code{return_geometry = FALSE}, 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 setting, the output may include the following +#' Depending on the argument settings, the output may include the following #' columns: #' #' \itemize{ @@ -79,16 +71,13 @@ #' \item \code{nodes}: A vector containing the indices of all nodes on the #' path, in order of visit. #' \item \code{edges}: A vector containing the indices of all edges on the -#' path, in order of visit. Not returned if \code{type = 'all_simple'}. -#' \item \code{path_found}: A boolean describing if a path was found between -#' the two nodes. Returned only if \code{type = 'shortest'}. +#' 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. Returned only if \code{return_cost = TRUE}. -#' Never returned if \code{type = 'all_simple'}. +#' 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. Returned only if -#' \code{return_geometry = TRUE} and the network has spatially explicit -#' edges. Never returned if \code{type = 'all_simple'}. +#' geometries of all visited edges. Included if \code{return_geometry = TRUE} +#' and the network has spatially explicit edges. #' } #' #' @examples @@ -149,7 +138,7 @@ #' #' @export st_network_paths = function(x, from, to = node_ids(x), - weights = edge_length(), type = "shortest", + weights = edge_length(), all = FALSE, k = 1, direction = "out", use_names = TRUE, return_cost = TRUE, return_geometry = TRUE, ...) { UseMethod("st_network_paths") @@ -159,12 +148,11 @@ st_network_paths = function(x, from, to = node_ids(x), #' @export st_network_paths.sfnetwork = function(x, from, to = node_ids(x), weights = edge_length(), - type = "shortest", direction = "out", + all = FALSE, k = 1, direction = "out", use_names = TRUE, return_cost = TRUE, return_geometry = TRUE, ...) { # Evaluate the given from node query. from = evaluate_node_query(x, enquo(from)) - if (length(from) > 1) raise_multiple_elements("from"); from = from[1] if (any(is.na(from))) raise_na_values("from") # Evaluate the given to node query. to = evaluate_node_query(x, enquo(to)) @@ -174,7 +162,8 @@ st_network_paths.sfnetwork = function(x, from, to = node_ids(x), # Compute the shortest paths. find_paths( x, from, to, weights, - type = type, + all = all, + k = k, direction = direction, use_names = use_names, return_cost = return_cost, @@ -186,47 +175,43 @@ st_network_paths.sfnetwork = function(x, from, to = node_ids(x), #' @importFrom igraph vertex_attr vertex_attr_names #' @importFrom rlang has_name #' @importFrom sf st_as_sf -find_paths = function(x, from, to, weights, type = "shortest", +find_paths = function(x, from, to, weights, all = FALSE, k = 1, direction = "out", use_names = TRUE, return_cost = TRUE, return_geometry = TRUE, ...) { # Find paths with the given router. - paths = igraph_paths(x, from, to, weights, type, direction, ...) + paths = igraph_paths(x, from, to, weights, all, k, direction, ...) # Convert node indices to node names if requested. if (use_names && "name" %in% vertex_attr_names(x)) { 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$nodes = lapply(paths$nodes, \(x) nnames[x]) + paths$node_path = lapply(paths$node_path, \(x) nnames[x]) } - # Enrich the paths with additional information. - if (has_name(paths, "edges")) { - E = paths$edges - # Compute total cost of each path if requested. - if (return_cost) { - if (length(weights) == 1 && is.na(weights)) { - costs = do.call("c", lapply(paths$edges, length)) - } else { - costs = do.call("c", lapply(paths$edges, \(x) sum(weights[x]))) - } - if (has_name(paths, "path_found")) costs[!paths$path_found] = Inf - paths$cost = costs - } - # Construct path geometries of requested. - if (return_geometry && has_explicit_edges(x)) { - egeom = pull_edge_geom(x) - pgeom = do.call("c", lapply(paths$edges, \(x) merge_lines(egeom[x]))) - paths$geometry = pgeom - paths = st_as_sf(paths) + # Compute total cost of each path if requested. + if (return_cost) { + if (length(weights) == 1 && is.na(weights)) { + costs = do.call("c", lapply(paths$edge_paths, length)) + } else { + costs = do.call("c", lapply(paths$edge_paths, \(x) sum(weights[x]))) } + costs[!paths$path_found] = Inf + paths$cost = costs + } + # Construct path geometries of requested. + if (return_geometry && has_explicit_edges(x)) { + egeom = pull_edge_geom(x) + pgeom = do.call("c", lapply(paths$edge_paths, \(x) merge_lines(egeom[x]))) + paths$geometry = pgeom + paths = st_as_sf(paths) } paths } -#' @importFrom igraph all_shortest_paths all_simple_paths shortest_paths +#' @importFrom igraph all_shortest_paths shortest_paths k_shortest_paths #' igraph_opt igraph_options #' @importFrom methods hasArg #' @importFrom tibble tibble -igraph_paths = function(x, from, to, weights, type = "shortest", +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. @@ -238,46 +223,84 @@ igraph_paths = function(x, from, to, weights, type = "shortest", # 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 paths calculation function according to type argument. - paths = switch( - type, - shortest = shortest_paths( - x, from, to, - weights = weights, - output = "both", - mode = direction, - ... - ), - all_shortest = all_shortest_paths( + # 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) raise_multiple_elements("from"); 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, ... - ), - all_simple = list(vpaths = all_simple_paths( - x, from, to, - mode = direction, - ... - )), - raise_unknown_input("type", type, c("shortest", "all_shortest", "all_simple")) - ) - # Extract the nodes in the paths, and the edges in the paths (if given). - npaths = paths[[1]] - epaths = if (length(paths) > 1) paths[[2]] else NULL - # Define the nodes from which the returned paths start and at which they end. - if (type == "shortest") { - starts = rep(from, length(to)) - ends = to - path_found = lengths(epaths) > 0 | starts == ends - } else { + ) + # 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, last_element)) - path_found = NULL + # Define for each path if the path was actually found or is empty. + # When all = TRUE we return only paths that exist. + # Hence each of the returned paths is found. + path_found = rep(TRUE, length(starts)) + } else { + 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 + # Define for each path if the path was actually found or is empty. + path_found = lengths(epaths) > 0 | starts == ends + } 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) raise_multiple_elements("to"); 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) + # Define for each path if the path was actually found or is empty. + path_found = c(rep(TRUE, n), rep(FALSE, k - n)) + } } # Return in a tibble. tibble( from = starts, to = ends, - nodes = npaths, edges = epaths, + node_path = npaths, edge_path = epaths, path_found = path_found ) } diff --git a/R/weights.R b/R/weights.R index 7d543bdd..e69cd86d 100644 --- a/R/weights.R +++ b/R/weights.R @@ -54,7 +54,8 @@ evaluate_weight_spec = function(data, spec) { deprecate_weights_is_null() weights = NA } - if (length(weights) != n_edges(data)) { + 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." diff --git a/man/spatial_morphers.Rd b/man/spatial_morphers.Rd index 7e86ae47..c84c5964 100644 --- a/man/spatial_morphers.Rd +++ b/man/spatial_morphers.Rd @@ -228,12 +228,11 @@ multiple neighborhoods. \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. +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 diff --git a/man/st_network_paths.Rd b/man/st_network_paths.Rd index 673335a1..dac7298e 100644 --- a/man/st_network_paths.Rd +++ b/man/st_network_paths.Rd @@ -2,14 +2,15 @@ % Please edit documentation in R/paths.R \name{st_network_paths} \alias{st_network_paths} -\title{Find paths between nodes in a spatial network} +\title{Find shortest paths between nodes in a spatial network} \usage{ st_network_paths( x, from, to = node_ids(x), weights = edge_length(), - type = "shortest", + all = FALSE, + k = 1, direction = "out", use_names = TRUE, return_cost = TRUE, @@ -24,7 +25,7 @@ st_network_paths( \code{\link{evaluate_node_query}}. When multiple nodes are given, only the first one is used.} -\item{to}{The node where the paths should start. Evaluated by +\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.} @@ -33,12 +34,16 @@ Evaluated by \code{\link{evaluate_edge_spec}}. The default is \code{\link{edge_length}}, which computes the geographic lengths of the edges.} -\item{type}{Character defining which type of path calculation should be -performed. If set to \code{'shortest'} paths are found using -\code{\link[igraph]{shortest_paths}}, if set to \code{'all_shortest'} paths -are found using \code{\link[igraph]{all_shortest_paths}}, if set to -\code{'all_simple'} paths are found using -\code{\link[igraph]{all_simple_paths}}. Defaults to \code{'shortest'}.} +\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 @@ -54,26 +59,24 @@ the node indices? Defaults to \code{TRUE}. Ignored when the nodes table does not have a column named \code{name}.} \item{return_cost}{Should the total cost of each path be computed? Defaults -to \code{TRUE}. Ignored if \code{type = 'all_simple'}.} +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 if \code{type = 'all_simple'} and for networks with -spatially implicit edges.} +the path. Ignored for networks with spatially implicit edges.} \item{...}{Additional arguments passed on to the wrapped igraph functions. Arguments \code{predecessors} and \code{inbound.edges} are ignored. Instead of the \code{mode} argument, use the \code{direction} argument.} } \value{ -An object of class \code{\link[tibble]{tbl_df}} or -\code{\link[sf]{sf}} with one row per path. If \code{type = 'shortest'}, the -number of rows is always equal to the number of requested paths, meaning -that node pairs for which no path could be found are still part of the -output. For all other path types, the output only contains finite paths. +An object of class \code{\link[sf]{sf}} with one row per requested +path. If \code{return_geometry = FALSE}, 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 setting, the output may include the following +Depending on the argument settings, the output may include the following columns: \itemize{ @@ -82,34 +85,22 @@ columns: \item \code{nodes}: A vector containing the indices of all nodes on the path, in order of visit. \item \code{edges}: A vector containing the indices of all edges on the - path, in order of visit. Not returned if \code{type = 'all_simple'}. - \item \code{path_found}: A boolean describing if a path was found between - the two nodes. Returned only if \code{type = 'shortest'}. + 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. Returned only if \code{return_cost = TRUE}. - Never returned if \code{type = 'all_simple'}. + 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. Returned only if - \code{return_geometry = TRUE} and the network has spatially explicit - edges. Never returned if \code{type = 'all_simple'}. + geometries of all visited edges. Included if \code{return_geometry = TRUE} + and the network has spatially explicit edges. } } \description{ -A function implementing one-to-one and one-to-many routing on spatial -networks. It can be used to either find one shortest path, all shortest -paths, or all simple paths between one node and one or -more other nodes in the network. +Find shortest paths between nodes in a spatial network } \details{ For more details on the wrapped igraph functions see the -\code{\link[igraph]{distances}} and -\code{\link[igraph]{all_simple_paths}} documentation pages. -} -\note{ -When computing simple paths by setting \code{type = 'all_simple'}, -note that potentially there are exponentially many paths between two nodes, -and you may run out of memory especially in undirected, dense, and/or -lattice-like networks. +\code{\link[igraph]{distances}} and \code{\link[igraph]{k_shortest_paths}} +documentation pages. } \examples{ library(sf, quietly = TRUE) diff --git a/man/st_network_travel.Rd b/man/st_network_travel.Rd index 294b6034..e62752c4 100644 --- a/man/st_network_travel.Rd +++ b/man/st_network_travel.Rd @@ -37,13 +37,12 @@ the node indices? Defaults to \code{TRUE}. Ignored when the nodes table does not have a column named \code{name}.} \item{return_cost}{Should the total cost of each path be computed? Defaults -to \code{TRUE}. Ignored if \code{type = 'all_simple'}.} +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 if \code{type = 'all_simple'} and for networks with -spatially implicit edges.} +the path. Ignored for networks with spatially implicit edges.} \item{...}{Additional arguments passed on to the `TSP::solve_tsp()` function.} } diff --git a/vignettes/sfn04_routing.qmd b/vignettes/sfn04_routing.qmd index f8d243d7..69d00b4f 100644 --- a/vignettes/sfn04_routing.qmd +++ b/vignettes/sfn04_routing.qmd @@ -79,12 +79,12 @@ paths = st_network_paths(net, from = 495, to = c(458, 121), weights = "weight") paths paths |> slice(1) |> - pull(nodes) |> + pull(node_path) |> unlist() paths |> slice(1) |> - pull(edges) |> + pull(edge_path) |> unlist() ``` @@ -102,7 +102,7 @@ colors = sf.colors(3, categorical = TRUE) plot(net, col = "grey") paths |> - pull(nodes) |> + pull(node_path) |> walk(plot_path) net |> activate("nodes") |> @@ -126,7 +126,7 @@ paths = st_network_paths(net, from = p1, to = c(p2, p3), weights = "weight") plot(net, col = "grey") paths |> - pull(nodes) |> + pull(node_path) |> walk(plot_path) plot(c(p1, p2, p3), col = colors, pch = 8, cex = 2, lwd = 2, add = TRUE) ``` @@ -317,7 +317,7 @@ tsp_paths = mapply(st_network_paths, from = from_idxs, to = to_idxs, MoreArgs = list(x = net, weights = "weight") - )["nodes", ] |> + )["node_path", ] |> unlist(recursive = FALSE) # Plot the results. From 17d76e6e72250db9e7c6ffc7ba97a33ce3837b3f Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Thu, 26 Sep 2024 17:00:23 +0200 Subject: [PATCH 148/246] refactor: Deprecate the type argument of st_network_paths :construction: --- NAMESPACE | 1 - R/messages.R | 16 ++++++++++++++++ R/paths.R | 10 ++++++---- 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/NAMESPACE b/NAMESPACE index 387ef5a2..ee368135 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -243,7 +243,6 @@ importFrom(rlang,dots_n) importFrom(rlang,enquo) importFrom(rlang,eval_tidy) importFrom(rlang,expr) -importFrom(rlang,has_name) importFrom(rlang,is_installed) importFrom(rlang,try_fetch) importFrom(sf,"st_agr<-") diff --git a/R/messages.R b/R/messages.R index 0559296d..e75bc207 100644 --- a/R/messages.R +++ b/R/messages.R @@ -147,6 +147,21 @@ deprecate_edges_as_lines = function() { ) } +#' @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( @@ -188,6 +203,7 @@ deprecate_weights_is_null = function() { ) } +#' @importFrom lifecycle deprecate_warn deprecate_from = function() { deprecate_warn( when = "v1.0", diff --git a/R/paths.R b/R/paths.R index f4a7453f..79e96924 100644 --- a/R/paths.R +++ b/R/paths.R @@ -144,6 +144,7 @@ st_network_paths = function(x, from, to = node_ids(x), UseMethod("st_network_paths") } +#' @importFrom methods hasArg #' @importFrom rlang enquo #' @export st_network_paths.sfnetwork = function(x, from, to = node_ids(x), @@ -151,6 +152,8 @@ st_network_paths.sfnetwork = function(x, from, to = node_ids(x), all = FALSE, k = 1, direction = "out", use_names = TRUE, 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") @@ -173,7 +176,6 @@ st_network_paths.sfnetwork = function(x, from, to = node_ids(x), } #' @importFrom igraph vertex_attr vertex_attr_names -#' @importFrom rlang has_name #' @importFrom sf st_as_sf find_paths = function(x, from, to, weights, all = FALSE, k = 1, direction = "out", use_names = TRUE, return_cost = TRUE, @@ -190,9 +192,9 @@ find_paths = function(x, from, to, weights, all = FALSE, k = 1, # Compute total cost of each path if requested. if (return_cost) { if (length(weights) == 1 && is.na(weights)) { - costs = do.call("c", lapply(paths$edge_paths, length)) + costs = do.call("c", lapply(paths$edge_path, length)) } else { - costs = do.call("c", lapply(paths$edge_paths, \(x) sum(weights[x]))) + costs = do.call("c", lapply(paths$edge_path, \(x) sum(weights[x]))) } costs[!paths$path_found] = Inf paths$cost = costs @@ -200,7 +202,7 @@ find_paths = function(x, from, to, weights, all = FALSE, k = 1, # Construct path geometries of requested. if (return_geometry && has_explicit_edges(x)) { egeom = pull_edge_geom(x) - pgeom = do.call("c", lapply(paths$edge_paths, \(x) merge_lines(egeom[x]))) + pgeom = do.call("c", lapply(paths$edge_path, \(x) merge_lines(egeom[x]))) paths$geometry = pgeom paths = st_as_sf(paths) } From 7af4ef2c8055b5354610a2fe62b18328e8bb79f6 Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Fri, 27 Sep 2024 12:32:36 +0200 Subject: [PATCH 149/246] feat: Support dodgr as a new routing backend :gift: --- DESCRIPTION | 2 + R/cost.R | 97 +++++++++++++++------- R/dodgr.R | 28 +++++++ R/iso.R | 5 +- R/morphers.R | 5 +- R/paths.R | 172 +++++++++++++++++++++++++++++++-------- R/utils.R | 16 ---- man/spatial_morphers.Rd | 2 +- man/st_network_cost.Rd | 28 +++++-- man/st_network_iso.Rd | 2 +- man/st_network_paths.Rd | 50 +++++++++--- man/st_network_travel.Rd | 2 +- 12 files changed, 308 insertions(+), 101 deletions(-) create mode 100644 R/dodgr.R diff --git a/DESCRIPTION b/DESCRIPTION index 18451fd7..36d03686 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -53,6 +53,7 @@ Imports: utils Suggests: dbscan, + dodgr, fansi, ggplot2 (>= 3.0.0), knitr, @@ -62,6 +63,7 @@ Suggests: s2 (>= 1.0.1), spatstat.geom, spatstat.linnet, + spdep, testthat, TSP VignetteBuilder: diff --git a/R/cost.R b/R/cost.R index 44ec2ced..5ccf5425 100644 --- a/R/cost.R +++ b/R/cost.R @@ -14,7 +14,7 @@ #' included. #' #' @param weights The edge weights to be used in the shortest path calculation. -#' Evaluated by \code{\link{evaluate_edge_spec}}. The default is +#' Evaluated by \code{\link{evaluate_weight_spec}}. The default is #' \code{\link{edge_length}}, which computes the geographic lengths of the #' edges. #' @@ -29,11 +29,27 @@ #' @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 ... Additional arguments passed on to \code{\link[igraph]{distances}}. -#' Instead of the \code{mode} argument, use the \code{direction} argument. +#' @param router The routing backend to use for the cost matrix computation. +#' Currently supported options are \code{'igraph'} and \code{'dodgr'}. See +#' Details. #' -#' @details For more details on the wrapped igraph function see the -#' \code{\link[igraph]{distances}} documentation page. +#' @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. #' #' @seealso \code{\link{st_network_paths}}, \code{\link{st_network_travel}} #' @@ -89,7 +105,7 @@ #' @export st_network_cost = function(x, from = node_ids(x), to = node_ids(x), weights = edge_length(), direction = "out", - Inf_as_NaN = FALSE, ...) { + Inf_as_NaN = FALSE, router = "igraph", ...) { UseMethod("st_network_cost") } @@ -98,7 +114,8 @@ st_network_cost = function(x, from = node_ids(x), to = node_ids(x), st_network_cost.sfnetwork = function(x, from = node_ids(x), to = node_ids(x), weights = edge_length(), direction = "out", - Inf_as_NaN = FALSE, ...) { + Inf_as_NaN = FALSE, + router = "igraph", ...) { # Evaluate the given from node query. from = evaluate_node_query(x, enquo(from)) if (any(is.na(from))) raise_na_values("from") @@ -107,15 +124,12 @@ st_network_cost.sfnetwork = function(x, from = node_ids(x), to = node_ids(x), if (any(is.na(to))) raise_na_values("to") # Evaluate the given weights specification. weights = evaluate_weight_spec(x, enquo(weights)) - # Parse other arguments. - # --> 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") # Compute the cost matrix. compute_costs( x, from, to, weights, direction = direction, Inf_as_NaN = Inf_as_NaN, + router = router, ... ) } @@ -123,40 +137,67 @@ st_network_cost.sfnetwork = function(x, from = node_ids(x), to = node_ids(x), #' @name st_network_cost #' @export st_network_distance = function(x, from = node_ids(x), to = node_ids(x), - direction = "out", Inf_as_NaN = FALSE, ...) { + direction = "out", Inf_as_NaN = FALSE, + router = "igraph", ...) { st_network_cost( x, from, to, weights = edge_length(), direction = direction, Inf_as_NaN = Inf_as_NaN, + router = router, ... ) } -#' @importFrom igraph distances -#' @importFrom methods hasArg #' @importFrom units as_units deparse_unit compute_costs = function(x, from, to, weights, direction = "out", - Inf_as_NaN = FALSE, ...) { - # Call the igraph distances function to compute the cost matrix. + Inf_as_NaN = FALSE, router = "igraph", ...) { + # Compute cost matrix with the given router. + costs = switch( + router, + igraph = igraph_costs(x, from, to, weights, direction, ...), + dodgr = dodgr_costs(x, from, to, weights, direction, ...), + 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", ...) { + # 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) - matrix = distances(x, from, tou, weights = weights, mode = direction, ...) - matrix = matrix[, match(to, tou), drop = FALSE] + mat = distances(x, from, tou, weights = weights, mode = direction, ...) + mat = mat[, match(to, tou), drop = FALSE] } else { - matrix = distances(x, from, to, weights = weights, mode = direction, ...) - } - # Post-process and return. - # --> Convert Inf to NaN if requested. - # --> Attach units if the provided weights had units. - if (Inf_as_NaN) matrix[is.infinite(matrix)] = NaN - if (inherits(weights, "units")) { - as_units(matrix, deparse_unit(weights)) - } else { - matrix + mat = distances(x, from, to, weights = weights, mode = direction, ...) } + mat +} + +#' @importFrom rlang check_installed +dodgr_costs = function(x, from, to, weights, direction = "out", ...) { + 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), ...) + mat[is.na(mat)] = Inf + mat } diff --git a/R/dodgr.R b/R/dodgr.R new file mode 100644 index 00000000..c9ae612b --- /dev/null +++ b/R/dodgr.R @@ -0,0 +1,28 @@ +#' @importFrom igraph as_edgelist is_directed +sfnetwork_to_minimal_dodgr = function(x, weights, direction = "out") { + edgelist = as_edgelist(x, names = FALSE) + 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(weights, 2) + ) + } else { + if (direction == "out") { + x_dodgr = data.frame( + from = as.character(edgelist[, 1]), + to = as.character(edgelist[, 2]), + d = weights + ) + } else if (direction == "in") { + x_dodgr = data.frame( + from = as.character(edgelist[, 2]), + to = as.character(edgelist[, 1]), + d = weights + ) + } else { + raise_unknown_input("direction", direction, c("out", "in", "all")) + } + } + x_dodgr +} \ No newline at end of file diff --git a/R/iso.R b/R/iso.R index e755d7a2..57c361ac 100644 --- a/R/iso.R +++ b/R/iso.R @@ -20,7 +20,7 @@ #' drawn. #' #' @param weights The edge weights to be used in the shortest path calculation. -#' Evaluated by \code{\link{evaluate_edge_spec}}. The default is +#' Evaluated by \code{\link{evaluate_weight_spec}}. The default is #' \code{\link{edge_length}}, which computes the geographic lengths of the #' edges. #' @@ -86,7 +86,8 @@ st_network_iso.sfnetwork = function(x, node, cost, weights = edge_length(), allow_holes = FALSE) { # Evaluate the given node query. # Always only the first node is used. - node = evaluate_node_query(x, enquo(node))[1] + 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. diff --git a/R/morphers.R b/R/morphers.R index 71ca07cc..3e9af786 100644 --- a/R/morphers.R +++ b/R/morphers.R @@ -190,7 +190,7 @@ to_spatial_mixed = function(x, directed) { #' 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_edge_spec}}. The default is +#' Evaluated by \code{\link{evaluate_weight_spec}}. The default is #' \code{\link{edge_length}}, which computes the geographic lengths of the #' edges. #' @@ -203,7 +203,8 @@ 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))[1] + 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. diff --git a/R/paths.R b/R/paths.R index 79e96924..d00796cc 100644 --- a/R/paths.R +++ b/R/paths.R @@ -3,15 +3,14 @@ #' @param x An object of class \code{\link{sfnetwork}}. #' #' @param from The node where the paths should start. Evaluated by -#' \code{\link{evaluate_node_query}}. When multiple nodes are given, only the -#' first one is used. +#' \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. -#' Evaluated by \code{\link{evaluate_edge_spec}}. The default is +#' Evaluated by \code{\link{evaluate_weight_spec}}. The default is #' \code{\link{edge_length}}, which computes the geographic lengths of the #' edges. #' @@ -34,6 +33,10 @@ #' 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 @@ -47,13 +50,33 @@ #' \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 wrapped igraph functions. -#' Arguments \code{predecessors} and \code{inbound.edges} are ignored. -#' Instead of the \code{mode} argument, use the \code{direction} argument. +#' @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. #' -#' @details For more details on the wrapped igraph functions see the -#' \code{\link[igraph]{distances}} and \code{\link[igraph]{k_shortest_paths}} -#' documentation pages. +#' 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. #' #' @seealso \code{\link{st_network_cost}}, \code{\link{st_network_travel}} #' @@ -68,10 +91,10 @@ #' \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{nodes}: A vector containing the indices of all nodes on the -#' path, in order of visit. -#' \item \code{edges}: A vector containing the indices of all edges on the -#' path, in order of visit. +#' \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}. @@ -139,8 +162,9 @@ #' @export st_network_paths = function(x, from, to = node_ids(x), weights = edge_length(), all = FALSE, k = 1, - direction = "out", use_names = TRUE, - return_cost = TRUE, return_geometry = TRUE, ...) { + direction = "out", router = "igraph", + use_names = TRUE, return_cost = TRUE, + return_geometry = TRUE, ...) { UseMethod("st_network_paths") } @@ -149,7 +173,8 @@ st_network_paths = function(x, from, to = node_ids(x), #' @export st_network_paths.sfnetwork = function(x, from, to = node_ids(x), weights = edge_length(), - all = FALSE, k = 1, direction = "out", + all = FALSE, k = 1, + direction = "out", router = "igraph", use_names = TRUE, return_cost = TRUE, return_geometry = TRUE, ...) { # Deprecate the type argument. @@ -168,6 +193,7 @@ st_network_paths.sfnetwork = function(x, from, to = node_ids(x), all = all, k = k, direction = direction, + router = router, use_names = use_names, return_cost = return_cost, return_geometry = return_geometry, @@ -178,10 +204,15 @@ st_network_paths.sfnetwork = function(x, from, to = node_ids(x), #' @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", use_names = TRUE, return_cost = TRUE, - return_geometry = TRUE, ...) { + direction = "out", router = "igraph", use_names = TRUE, + return_cost = TRUE, return_geometry = TRUE, ...) { # Find paths with the given router. - paths = igraph_paths(x, from, to, weights, all, k, direction, ...) + 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)) { nnames = vertex_attr(x, "name") @@ -189,6 +220,8 @@ find_paths = function(x, from, to, weights, all = FALSE, k = 1, paths$to = do.call("c", lapply(paths$to, \(x) nnames[x])) paths$node_path = lapply(paths$node_path, \(x) nnames[x]) } + # 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 (length(weights) == 1 && is.na(weights)) { @@ -213,6 +246,7 @@ find_paths = function(x, from, to, weights, all = FALSE, k = 1, #' 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. @@ -227,7 +261,13 @@ igraph_paths = function(x, from, to, weights, all = FALSE, k = 1, if (hasArg("mode")) raise_unsupported_arg("mode", replacement = "direction") # 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) raise_multiple_elements("from"); from = from[1] + 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." + )) + from = from[1] + } # Call igraph paths calculation function depending on the settings. if (all) { # Call igraph::all_shortest_paths to obtain the requested paths. @@ -242,11 +282,7 @@ igraph_paths = function(x, from, to, weights, all = FALSE, k = 1, 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, last_element)) - # Define for each path if the path was actually found or is empty. - # When all = TRUE we return only paths that exist. - # Hence each of the returned paths is found. - path_found = rep(TRUE, length(starts)) + ends = do.call("c", lapply(npaths, tail, 1)) } else { k = as.integer(k) if (k == 1) { @@ -264,13 +300,20 @@ igraph_paths = function(x, from, to, weights, all = FALSE, k = 1, # Define for each path where it starts and ends. starts = rep(from, length(to)) ends = to - # Define for each path if the path was actually found or is empty. - path_found = lengths(epaths) > 0 | starts == ends } 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) raise_multiple_elements("to"); to = to[1] + 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, @@ -295,14 +338,79 @@ igraph_paths = function(x, from, to, weights, all = FALSE, k = 1, # Since we do one-to-one routing these are always the same nodes. starts = rep(from, k) ends = rep(to, k) - # Define for each path if the path was actually found or is empty. - path_found = c(rep(TRUE, n), rep(FALSE, k - n)) } } # Return in a tibble. tibble( from = starts, to = ends, - node_path = npaths, edge_path = epaths, - path_found = path_found + node_path = npaths, edge_path = epaths ) } + +#' @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}." + ) + } + if (k > 1) { + cli_abort( + "Router {.pkg dodgr} does not support setting {.code k > 1}." + ) + } + + # 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 = length(weights) + 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 + E + } + epaths = lapply(epaths, update_edge_path) + } else { + 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/utils.R b/R/utils.R index 45a1dce5..e02f2a70 100644 --- a/R/utils.R +++ b/R/utils.R @@ -471,19 +471,3 @@ bind_rows_list = function(...) { is_listcol = vapply(out, function(x) any(lengths(x) > 1), logical(1)) mutate(out, across(which(!is_listcol), unlist)) } - -#' Get the last element of a vector -#' -#' @param x A vector. -#' -#' @return The last element of \code{x}. -#' -#' @noRd -last_element = function(x) { - n = length(x) - if (n > 0) { - x[n] - } else { - x[1] - } -} \ No newline at end of file diff --git a/man/spatial_morphers.Rd b/man/spatial_morphers.Rd index c84c5964..8c97445c 100644 --- a/man/spatial_morphers.Rd +++ b/man/spatial_morphers.Rd @@ -114,7 +114,7 @@ weights. Alternatively, units can be specified explicitly by providing a 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_edge_spec}}. The default is +Evaluated by \code{\link{evaluate_weight_spec}}. The default is \code{\link{edge_length}}, which computes the geographic lengths of the edges.} diff --git a/man/st_network_cost.Rd b/man/st_network_cost.Rd index 49f42e2e..400842b4 100644 --- a/man/st_network_cost.Rd +++ b/man/st_network_cost.Rd @@ -12,6 +12,7 @@ st_network_cost( weights = edge_length(), direction = "out", Inf_as_NaN = FALSE, + router = "igraph", ... ) @@ -21,6 +22,7 @@ st_network_distance( to = node_ids(x), direction = "out", Inf_as_NaN = FALSE, + router = "igraph", ... ) } @@ -36,7 +38,7 @@ included.} included.} \item{weights}{The edge weights to be used in the shortest path calculation. -Evaluated by \code{\link{evaluate_edge_spec}}. The default is +Evaluated by \code{\link{evaluate_weight_spec}}. The default is \code{\link{edge_length}}, which computes the geographic lengths of the edges.} @@ -51,8 +53,12 @@ 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{...}{Additional arguments passed on to \code{\link[igraph]{distances}}. -Instead of the \code{mode} argument, use the \code{direction} argument.} +\item{router}{The routing backend to use for the cost matrix computation. +Currently supported options are \code{'igraph'} and \code{'dodgr'}. See +Details.} + +\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} @@ -63,8 +69,20 @@ Compute total travel costs of shortest paths between nodes in a spatial network. } \details{ -For more details on the wrapped igraph function 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. } \examples{ library(sf, quietly = TRUE) diff --git a/man/st_network_iso.Rd b/man/st_network_iso.Rd index d6d4c4d5..fc686a8f 100644 --- a/man/st_network_iso.Rd +++ b/man/st_network_iso.Rd @@ -29,7 +29,7 @@ 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_edge_spec}}. The default is +Evaluated by \code{\link{evaluate_weight_spec}}. The default is \code{\link{edge_length}}, which computes the geographic lengths of the edges.} diff --git a/man/st_network_paths.Rd b/man/st_network_paths.Rd index dac7298e..9bc555e4 100644 --- a/man/st_network_paths.Rd +++ b/man/st_network_paths.Rd @@ -12,6 +12,7 @@ st_network_paths( all = FALSE, k = 1, direction = "out", + router = "igraph", use_names = TRUE, return_cost = TRUE, return_geometry = TRUE, @@ -22,15 +23,14 @@ st_network_paths( \item{x}{An object of class \code{\link{sfnetwork}}.} \item{from}{The node where the paths should start. Evaluated by -\code{\link{evaluate_node_query}}. When multiple nodes are given, only the -first one is used.} +\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. -Evaluated by \code{\link{evaluate_edge_spec}}. The default is +Evaluated by \code{\link{evaluate_weight_spec}}. The default is \code{\link{edge_length}}, which computes the geographic lengths of the edges.} @@ -53,6 +53,10 @@ 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 @@ -66,9 +70,8 @@ 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 wrapped igraph functions. -Arguments \code{predecessors} and \code{inbound.edges} are ignored. -Instead of the \code{mode} argument, use the \code{direction} argument.} +\item{...}{Additional arguments passed on to the underlying function of the +chosen routing backend. See Details.} } \value{ An object of class \code{\link[sf]{sf}} with one row per requested @@ -82,10 +85,10 @@ 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{nodes}: A vector containing the indices of all nodes on the - path, in order of visit. - \item \code{edges}: A vector containing the indices of all edges on the - path, in order of visit. + \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}. @@ -98,9 +101,30 @@ columns: Find shortest paths between nodes in a spatial network } \details{ -For more details on the wrapped igraph functions see the -\code{\link[igraph]{distances}} and \code{\link[igraph]{k_shortest_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. } \examples{ library(sf, quietly = TRUE) diff --git a/man/st_network_travel.Rd b/man/st_network_travel.Rd index e62752c4..0d414285 100644 --- a/man/st_network_travel.Rd +++ b/man/st_network_travel.Rd @@ -23,7 +23,7 @@ st_network_travel( \code{\link{evaluate_node_query}}.} \item{weights}{The edge weights to be used in the shortest path calculation. -Evaluated by \code{\link{evaluate_edge_spec}}. The default is +Evaluated by \code{\link{evaluate_weight_spec}}. The default is \code{\link{edge_length}}, which computes the geographic lengths of the edges.} From bce1c65889fc07b168e2fc4b558360bb469e1eea Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Fri, 27 Sep 2024 14:16:06 +0200 Subject: [PATCH 150/246] refactor: Update st_network_travel :construction: --- NAMESPACE | 4 +- R/paths.R | 6 +- R/travel.R | 241 +++++++++++++++++++++++++++++---------- man/st_network_paths.Rd | 6 +- man/st_network_travel.Rd | 121 ++++++++++++++++---- 5 files changed, 289 insertions(+), 89 deletions(-) diff --git a/NAMESPACE b/NAMESPACE index ee368135..37dcf4b3 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -51,6 +51,7 @@ 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_reverse,sfnetwork) @@ -167,9 +168,6 @@ export(to_spatial_unique) export(unmorph) export(validate_network) export(with_graph) -importFrom(TSP,ATSP) -importFrom(TSP,TSP) -importFrom(TSP,solve_TSP) importFrom(cli,cli_abort) importFrom(cli,cli_alert) importFrom(cli,cli_alert_success) diff --git a/R/paths.R b/R/paths.R index d00796cc..5a664df4 100644 --- a/R/paths.R +++ b/R/paths.R @@ -81,9 +81,9 @@ #' @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}, 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. +#' 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: diff --git a/R/travel.R b/R/travel.R index df0ab3e6..c1906d8e 100644 --- a/R/travel.R +++ b/R/travel.R @@ -1,73 +1,198 @@ -#' Compute route optimization algorithms +#' Find the optimal route through a set of nodes in a spatial network #' -#' The travelling salesman problem is currently implemented +#' Solve the travelling salesman problem by finding the shortest route through +#' a set of nodes that visits each of those nodes once. #' -#' @param pois Locations that the travelling salesman will visit. Evaluated by +#' @param nodes Nodes to be visited. Evaluated by #' \code{\link{evaluate_node_query}}. #' -#' @param return_paths Should the shortest paths between `pois` be computed? -#' Defaults to `TRUE`. If `FALSE`, a vector with indices in the visiting order -#' is returned. +#' @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 the `TSP::solve_tsp()` function. +#' @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. #' -#' @inheritParams st_network_paths +#' @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. #' -#' @return An object of class \code{\link[tibble]{tbl_df}} or -#' \code{\link[sf]{sf}} with one row per path, or a vector with ordered indices -#' for `pois`. +#' @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{TRUE}. 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(route), col = "orange", lwd = 3, add = TRUE) +#' +#' par(oldpar) #' -#' @importFrom rlang enquo -#' @importFrom stats as.dist -#' @importFrom TSP solve_TSP TSP ATSP #' @export +st_network_travel = function(x, nodes, weights = edge_length(), + optimizer = "TSP", router = "igraph", + return_paths = TRUE, use_names = TRUE, + return_cost = TRUE, return_geometry = TRUE, ...) { + UseMethod("st_network_travel") +} -st_network_travel = function(x, pois, weights = edge_length(), - algorithm = "tsp", - return_paths = TRUE, - use_names = TRUE, - return_cost = TRUE, - return_geometry = TRUE, - ...) { - # Evaluate the node query for the pois. - pois = evaluate_node_query(x, enquo(pois)) - if (any(is.na(pois))) raise_na_values("pois") +#' @importFrom rlang enquo +#' @export +st_network_travel.sfnetwork = function(x, nodes, weights = edge_length(), + optimizer = "TSP", router = "igraph", + return_paths = TRUE, use_names = TRUE, + 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 cost matrix - costmat = compute_costs(x, from = pois, to = pois, weights = weights) - # Use nearest node indices as row and column names - row.names(costmat) = pois - colnames(costmat) = pois - # Convert to tsp object - tsp_obj = switch( - algorithm, - "tsp" = TSP(as.dist(costmat)), - "atsp" = ATSP(as.dist(costmat)) + # 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, + ... ) - # Solve TSP - tour = solve_TSP(tsp_obj, ...) - # Return only the TSP result as node indices - if(!return_paths) { - as.numeric(tour) - } else { - tour_idxs = as.numeric(names(tour)) - # 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. - find_leg = function(...) { - find_paths( - x = net, - ..., - weights = weights, - use_names = use_names, - return_cost = return_cost, - return_geometry = return_geometry - ) - } - bind_rows(Map(find_leg, from = from_idxs, to = to_idxs)) +} + +#' @importFrom dplyr bind_rows +find_optimal_route = function(x, nodes, weights = edge_length(), + optimizer = "TSP", router = "igraph", + return_paths = TRUE, use_names = TRUE, + 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)) +} \ No newline at end of file diff --git a/man/st_network_paths.Rd b/man/st_network_paths.Rd index 9bc555e4..a0f4b026 100644 --- a/man/st_network_paths.Rd +++ b/man/st_network_paths.Rd @@ -75,9 +75,9 @@ chosen routing backend. See Details.} } \value{ An object of class \code{\link[sf]{sf}} with one row per requested -path. If \code{return_geometry = FALSE}, 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. +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: diff --git a/man/st_network_travel.Rd b/man/st_network_travel.Rd index 0d414285..27f19c35 100644 --- a/man/st_network_travel.Rd +++ b/man/st_network_travel.Rd @@ -2,13 +2,14 @@ % Please edit documentation in R/travel.R \name{st_network_travel} \alias{st_network_travel} -\title{Compute route optimization algorithms} +\title{Find the optimal route through a set of nodes in a spatial network} \usage{ st_network_travel( x, - pois, + nodes, weights = edge_length(), - algorithm = "tsp", + optimizer = "TSP", + router = "igraph", return_paths = TRUE, use_names = TRUE, return_cost = TRUE, @@ -17,9 +18,7 @@ st_network_travel( ) } \arguments{ -\item{x}{An object of class \code{\link{sfnetwork}}.} - -\item{pois}{Locations that the travelling salesman will visit. Evaluated by +\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. @@ -27,30 +26,108 @@ Evaluated by \code{\link{evaluate_weight_spec}}. The default is \code{\link{edge_length}}, which computes the geographic lengths of the edges.} -\item{return_paths}{Should the shortest paths between `pois` be computed? -Defaults to `TRUE`. If `FALSE`, a vector with indices in the visiting order -is returned.} +\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 a path, instead of -the node indices? Defaults to \code{TRUE}. Ignored when the nodes table does -not have a column named \code{name}.} +table, should these names be used to encode the nodes in the route, instead +of the node indices? Defaults to \code{TRUE}. 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 be computed? Defaults -to \code{TRUE}.} +\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? 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.} +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 `TSP::solve_tsp()` function.} +\item{...}{Additional arguments passed on to the underlying function of the +chosen optimization backend. See Details.} } \value{ -An object of class \code{\link[tibble]{tbl_df}} or -\code{\link[sf]{sf}} with one row per path, or a vector with ordered indices -for `pois`. +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{ -The travelling salesman problem is currently implemented +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(route), col = "orange", lwd = 3, add = TRUE) + +par(oldpar) + +} +\seealso{ +\code{\link{st_network_paths}}, \code{\link{st_network_cost}} } From aa500fa064ba91cb69bde09ba32c6bce9689e564 Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Fri, 27 Sep 2024 15:09:55 +0200 Subject: [PATCH 151/246] feat: Allow to change default router through options :gift: --- R/cost.R | 17 +++++++++++++---- R/paths.R | 15 +++++++++++---- R/print.R | 4 ++-- R/travel.R | 9 ++++++--- man/st_network_cost.Rd | 7 +++++-- man/st_network_paths.Rd | 5 ++++- man/st_network_travel.Rd | 2 +- 7 files changed, 42 insertions(+), 17 deletions(-) diff --git a/R/cost.R b/R/cost.R index 5ccf5425..b2d5ba59 100644 --- a/R/cost.R +++ b/R/cost.R @@ -51,6 +51,9 @@ #' 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} @@ -105,7 +108,9 @@ #' @export st_network_cost = function(x, from = node_ids(x), to = node_ids(x), weights = edge_length(), direction = "out", - Inf_as_NaN = FALSE, router = "igraph", ...) { + Inf_as_NaN = FALSE, + router = getOption("sfn_default_router", "igraph"), + ...) { UseMethod("st_network_cost") } @@ -115,7 +120,8 @@ st_network_cost.sfnetwork = function(x, from = node_ids(x), to = node_ids(x), weights = edge_length(), direction = "out", Inf_as_NaN = FALSE, - router = "igraph", ...) { + router = getOption("sfn_default_router", "igraph"), + ...) { # Evaluate the given from node query. from = evaluate_node_query(x, enquo(from)) if (any(is.na(from))) raise_na_values("from") @@ -138,7 +144,8 @@ st_network_cost.sfnetwork = function(x, from = node_ids(x), to = node_ids(x), #' @export st_network_distance = function(x, from = node_ids(x), to = node_ids(x), direction = "out", Inf_as_NaN = FALSE, - router = "igraph", ...) { + router = getOption("sfn_default_router", "igraph"), + ...) { st_network_cost( x, from, to, weights = edge_length(), @@ -151,7 +158,9 @@ st_network_distance = function(x, from = node_ids(x), to = node_ids(x), #' @importFrom units as_units deparse_unit compute_costs = function(x, from, to, weights, direction = "out", - Inf_as_NaN = FALSE, router = "igraph", ...) { + Inf_as_NaN = FALSE, + router = getOption("sfn_default_router", "igraph"), + ...) { # Compute cost matrix with the given router. costs = switch( router, diff --git a/R/paths.R b/R/paths.R index 5a664df4..99e79b5f 100644 --- a/R/paths.R +++ b/R/paths.R @@ -78,6 +78,9 @@ #' 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 @@ -162,7 +165,8 @@ #' @export st_network_paths = function(x, from, to = node_ids(x), weights = edge_length(), all = FALSE, k = 1, - direction = "out", router = "igraph", + direction = "out", + router = getOption("sfn_default_router", "igraph"), use_names = TRUE, return_cost = TRUE, return_geometry = TRUE, ...) { UseMethod("st_network_paths") @@ -174,7 +178,8 @@ st_network_paths = function(x, from, to = node_ids(x), st_network_paths.sfnetwork = function(x, from, to = node_ids(x), weights = edge_length(), all = FALSE, k = 1, - direction = "out", router = "igraph", + direction = "out", + router = getOption("sfn_default_router", "igraph"), use_names = TRUE, return_cost = TRUE, return_geometry = TRUE, ...) { # Deprecate the type argument. @@ -204,8 +209,10 @@ st_network_paths.sfnetwork = function(x, from, to = node_ids(x), #' @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 = "igraph", use_names = TRUE, - return_cost = TRUE, return_geometry = TRUE, ...) { + direction = "out", + router = getOption("sfn_default_router", "igraph"), + use_names = TRUE, return_cost = TRUE, + return_geometry = TRUE, ...) { # Find paths with the given router. paths = switch( router, diff --git a/R/print.R b/R/print.R index 3d44388b..3a742e14 100644 --- a/R/print.R +++ b/R/print.R @@ -1,7 +1,7 @@ #' @export print.sfnetwork = function(x, ..., - n = getOption("sfn_max_print_active", default = 6), - n_non_active = getOption("sfn_max_print_inactive", default = 3)) { + 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) diff --git a/R/travel.R b/R/travel.R index c1906d8e..29f6012e 100644 --- a/R/travel.R +++ b/R/travel.R @@ -110,7 +110,8 @@ #' #' @export st_network_travel = function(x, nodes, weights = edge_length(), - optimizer = "TSP", router = "igraph", + optimizer = "TSP", + router = getOption("sfn_default_router", "igraph"), return_paths = TRUE, use_names = TRUE, return_cost = TRUE, return_geometry = TRUE, ...) { UseMethod("st_network_travel") @@ -119,7 +120,8 @@ st_network_travel = function(x, nodes, weights = edge_length(), #' @importFrom rlang enquo #' @export st_network_travel.sfnetwork = function(x, nodes, weights = edge_length(), - optimizer = "TSP", router = "igraph", + optimizer = "TSP", + router = getOption("sfn_default_router", "igraph"), return_paths = TRUE, use_names = TRUE, return_cost = TRUE, return_geometry = TRUE, ...) { @@ -143,7 +145,8 @@ st_network_travel.sfnetwork = function(x, nodes, weights = edge_length(), #' @importFrom dplyr bind_rows find_optimal_route = function(x, nodes, weights = edge_length(), - optimizer = "TSP", router = "igraph", + optimizer = "TSP", + router = getOption("sfn_default_router", "igraph"), return_paths = TRUE, use_names = TRUE, return_cost = TRUE, return_geometry = TRUE, ...) { # Compute cost matrix with the given router. diff --git a/man/st_network_cost.Rd b/man/st_network_cost.Rd index 400842b4..57aad0ad 100644 --- a/man/st_network_cost.Rd +++ b/man/st_network_cost.Rd @@ -12,7 +12,7 @@ st_network_cost( weights = edge_length(), direction = "out", Inf_as_NaN = FALSE, - router = "igraph", + router = getOption("sfn_default_router", "igraph"), ... ) @@ -22,7 +22,7 @@ st_network_distance( to = node_ids(x), direction = "out", Inf_as_NaN = FALSE, - router = "igraph", + router = getOption("sfn_default_router", "igraph"), ... ) } @@ -83,6 +83,9 @@ 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) diff --git a/man/st_network_paths.Rd b/man/st_network_paths.Rd index a0f4b026..42617f89 100644 --- a/man/st_network_paths.Rd +++ b/man/st_network_paths.Rd @@ -12,7 +12,7 @@ st_network_paths( all = FALSE, k = 1, direction = "out", - router = "igraph", + router = getOption("sfn_default_router", "igraph"), use_names = TRUE, return_cost = TRUE, return_geometry = TRUE, @@ -125,6 +125,9 @@ 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) diff --git a/man/st_network_travel.Rd b/man/st_network_travel.Rd index 27f19c35..9695a0ec 100644 --- a/man/st_network_travel.Rd +++ b/man/st_network_travel.Rd @@ -9,7 +9,7 @@ st_network_travel( nodes, weights = edge_length(), optimizer = "TSP", - router = "igraph", + router = getOption("sfn_default_router", "igraph"), return_paths = TRUE, use_names = TRUE, return_cost = TRUE, From 5359ab169c85ac3fd3e505a70899c0c8dfd4e677 Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Fri, 27 Sep 2024 18:04:33 +0200 Subject: [PATCH 152/246] feat: Add support for dual-weighted routing :gift: --- NAMESPACE | 1 + R/dodgr.R | 16 +++++++++++++--- R/paths.R | 16 +++++++++++++--- R/weights.R | 35 +++++++++++++++++++++++++++++++++++ man/dual_weights.Rd | 27 +++++++++++++++++++++++++++ man/evaluate_weight_spec.Rd | 5 +++++ 6 files changed, 94 insertions(+), 6 deletions(-) create mode 100644 man/dual_weights.Rd diff --git a/NAMESPACE b/NAMESPACE index 37dcf4b3..106aa396 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -76,6 +76,7 @@ export(create_from_spatial_lines) export(create_from_spatial_points) export(crystallise) export(crystallize) +export(dual_weights) export(edge_azimuth) export(edge_circuity) export(edge_contains) diff --git a/R/dodgr.R b/R/dodgr.R index c9ae612b..b1abed92 100644 --- a/R/dodgr.R +++ b/R/dodgr.R @@ -1,28 +1,38 @@ #' @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(weights, 2) + 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 = weights + d = d ) } else if (direction == "in") { x_dodgr = data.frame( from = as.character(edgelist[, 2]), to = as.character(edgelist[, 1]), - d = weights + 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/paths.R b/R/paths.R index 99e79b5f..b2dcd8e2 100644 --- a/R/paths.R +++ b/R/paths.R @@ -231,6 +231,7 @@ find_paths = function(x, from, to, weights, all = FALSE, k = 1, 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 { @@ -249,6 +250,7 @@ find_paths = function(x, from, to, weights, all = FALSE, k = 1, paths } +#' @importFrom cli cli_abort cli_warn #' @importFrom igraph all_shortest_paths shortest_paths k_shortest_paths #' igraph_opt igraph_options #' @importFrom methods hasArg @@ -266,12 +268,20 @@ igraph_paths = function(x, from, to, weights, all = FALSE, k = 1, # 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" = "Only the first {.arg from} node is considered.", + "i" = "Use the {.pkg dodgr} router for many-to-many routing." )) from = from[1] } @@ -397,12 +407,12 @@ dodgr_paths = function(x, from, to, weights, all = FALSE, k = 1, # --> 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 = length(weights) + 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 - E + as.integer(E) } epaths = lapply(epaths, update_edge_path) } else { diff --git a/R/weights.R b/R/weights.R index e69cd86d..d60a74f7 100644 --- a/R/weights.R +++ b/R/weights.R @@ -22,6 +22,11 @@ #' \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 @@ -44,6 +49,11 @@ 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() @@ -62,4 +72,29 @@ evaluate_weight_spec = function(data, spec) { )) } 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/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_weight_spec.Rd b/man/evaluate_weight_spec.Rd index f87576eb..8b7f83ec 100644 --- a/man/evaluate_weight_spec.Rd +++ b/man/evaluate_weight_spec.Rd @@ -34,6 +34,11 @@ sfnetworks. The specification can be formatted as follows: \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 From 4560371d99494715d0fc61d132e54f454877eb48 Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Fri, 27 Sep 2024 19:09:09 +0200 Subject: [PATCH 153/246] refactor: Update group_spatial :construction: --- NAMESPACE | 1 - R/group.R | 80 ++++++++++++++++++++++++++++---------------- man/group_spatial.Rd | 50 ++++++++++++++++++++------- 3 files changed, 89 insertions(+), 42 deletions(-) diff --git a/NAMESPACE b/NAMESPACE index 106aa396..848f95b5 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -173,7 +173,6 @@ importFrom(cli,cli_abort) importFrom(cli,cli_alert) importFrom(cli,cli_alert_success) importFrom(cli,cli_warn) -importFrom(dbscan,dbscan) importFrom(dplyr,across) importFrom(dplyr,arrange) importFrom(dplyr,bind_rows) diff --git a/R/group.R b/R/group.R index a5dff024..4b0e571f 100644 --- a/R/group.R +++ b/R/group.R @@ -1,46 +1,70 @@ #' Group nodes based on spatial distance #' -#' @param dist distance within which nodes are clustered to each other +#' This function forms a spatial extension to the +#' \code{\link[tidygraph:group_graph]{grouping}} functions in tidygraph, +#' allowing to detect communities with spatial clustering algorithms. #' -#' @param algorithm the grouping algorithm used to perform spatial clustering. -#' Defaults to the `dbscan::dbscan()` algorithm. +#' @param dist The distance within which nodes are clustered together. Should +#' be specified in meters. #' -#' @param network_distance should the distance be based on the network distance -#' (default, uses `st_network_distance()` internally) or euclidean distance -#' (uses `sf::st_distance()` internally)? +#' @param algorithm The spatial clustering algorithm to use. See Details. #' -#' @param min_nodes minimum number of nodes assigned to each cluster. Defaults -#' to 1 so that every node is assigned a cluster even if it is the only member -#' of that cluster. +#' @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 ... other arguments passed onto the algorithm, -#' e.g. `dbscan::dbscan()` +#' @param min_nodes The minimum number of nodes in each cluster. Defaults to 1. +#' +#' @param ... Additional arguments passed on to the clustering algorithm. See +#' Details. +#' +#' @details The currently supported spatial clustering algorithms are the +#' following: +#' +#' \itemize{ +#' \item \code{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. +#' } +#' +#' @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_spatial(10, 0.5) |> +#' activate(nodes) |> +#' mutate(group = group_spatial(0.25)) #' -#' @importFrom dbscan dbscan #' @importFrom stats as.dist #' @export group_spatial = function(dist, algorithm = "dbscan", - network_distance = TRUE, - min_nodes = 1, - ...) { + use_network_distance = TRUE, min_nodes = 1, ...) { require_active_nodes() - if(algorithm == "dbscan") { - if (network_distance) { - distmat = as.dist(st_network_distance(.G())) - } else { - distmat = as.dist(st_distance(.G())) - } - group = dbscan(distmat, eps = dist, minPts = min_nodes, ...)$cluster + if (use_network_distance) { + distmat = as.dist(st_network_distance(.G())) + } else { + distmat = as.dist(st_distance(.G())) } - desc_enumeration(group) + groups = switch( + algorithm, + dbscan = group_spatial_dbscan(distmat, dist, min_nodes, ...), + raise_unknown_input("algorithm", algorithm, c("dbscan")) + ) + desc_enumeration(groups) } - -# HELPERS ----------------------------------------------------------------- +#' @importFrom rlang check_installed +group_spatial_dbscan = function(x, dist, min_nodes, ...) { + check_installed("dbscan") # Package dbscan is required for this function. + dbscan::dbscan(x, eps = dist, minPts = min_nodes, ...)$cluster +} # 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 and so -# forth -desc_enumeration <- function(group) { +# 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/man/group_spatial.Rd b/man/group_spatial.Rd index ebaf032b..106f606e 100644 --- a/man/group_spatial.Rd +++ b/man/group_spatial.Rd @@ -7,28 +7,52 @@ group_spatial( dist, algorithm = "dbscan", - network_distance = TRUE, + use_network_distance = TRUE, min_nodes = 1, ... ) } \arguments{ -\item{dist}{distance within which nodes are clustered to each other} +\item{dist}{The distance within which nodes are clustered together. Should +be specified in meters.} -\item{algorithm}{the grouping algorithm used to perform spatial clustering. -Defaults to the `dbscan::dbscan()` algorithm.} +\item{algorithm}{The spatial clustering algorithm to use. See Details.} -\item{network_distance}{should the distance be based on the network distance -(default, uses `st_network_distance()` internally) or euclidean distance -(uses `sf::st_distance()` internally)?} +\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{min_nodes}{minimum number of nodes assigned to each cluster. Defaults -to 1 so that every node is assigned a cluster even if it is the only member -of that cluster.} +\item{min_nodes}{The minimum number of nodes in each cluster. Defaults to 1.} -\item{...}{other arguments passed onto the algorithm, -e.g. `dbscan::dbscan()`} +\item{...}{Additional arguments passed on to the clustering algorithm. See +Details.} +} +\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{ -Group nodes based on spatial distance +This function forms a spatial extension to the +\code{\link[tidygraph:group_graph]{grouping}} functions in tidygraph, +allowing to detect communities with spatial clustering algorithms. +} +\details{ +The currently supported spatial clustering algorithms are the +following: + +\itemize{ + \item \code{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. +} +} +\examples{ +library(tidygraph, quietly = TRUE) + +play_spatial(10, 0.5) |> + activate(nodes) |> + mutate(group = group_spatial(0.25)) + } From fca135e39e856b389e9298ac21bbeb5acb40ca5f Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Fri, 27 Sep 2024 19:52:36 +0200 Subject: [PATCH 154/246] feat: New functions to bind spatial nodes and edges. Refs #29 :gift: --- NAMESPACE | 4 ++ R/bind.R | 116 ++++++++++++++++++++++++++++++++++++++++++++ R/validate.R | 4 +- man/bind_spatial.Rd | 57 ++++++++++++++++++++++ 4 files changed, 179 insertions(+), 2 deletions(-) create mode 100644 R/bind.R create mode 100644 man/bind_spatial.Rd diff --git a/NAMESPACE b/NAMESPACE index 848f95b5..61aea40c 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -70,6 +70,8 @@ export("%>%") export(activate) export(active) export(as_sfnetwork) +export(bind_spatial_edges) +export(bind_spatial_nodes) export(contract_nodes) export(convert) export(create_from_spatial_lines) @@ -324,6 +326,8 @@ 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) diff --git a/R/bind.R b/R/bind.R new file mode 100644 index 00000000..85e92ba0 --- /dev/null +++ b/R/bind.R @@ -0,0 +1,116 @@ +#' 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 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. + st_geometry(new_net) = new_geom + new_net +} + +#' @name bind_spatial +#' @importFrom cli cli_abort +#' @importFrom igraph is_directed +#' @importFrom sf st_drop_geometry st_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_lines(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. + st_geometry(new_net) = new_geom + # Validate if binded edges meet the valid spatial network structure. + if (! force) { + 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") + } + } + } + new_net +} \ No newline at end of file diff --git a/R/validate.R b/R/validate.R index bbbb93c8..b89596e4 100644 --- a/R/validate.R +++ b/R/validate.R @@ -20,7 +20,7 @@ validate_network = function(x, message = TRUE) { # 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 POINT") + 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)) { @@ -28,7 +28,7 @@ validate_network = function(x, message = TRUE) { # 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 LINESTRING") + 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? 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) + +} From b747103f3cb7674d6eae33abe60b7a0b4b792eee Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Sat, 28 Sep 2024 13:40:43 +0200 Subject: [PATCH 155/246] fix: Correct function args of mst_neighbors :wrench: --- R/create.R | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/R/create.R b/R/create.R index 4b21b3a9..3feb3953 100644 --- a/R/create.R +++ b/R/create.R @@ -691,7 +691,7 @@ sequential_neighbors = function(x) { #' @importFrom igraph as_edgelist graph_from_adjacency_matrix mst #' @importFrom sf st_distance st_geometry -mst_neighbors = function(x, directed = TRUE, edges_as_lines = TRUE) { +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)) From ffba3c942e23b331c62c1e6cfd795dc98364a7d6 Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Sat, 28 Sep 2024 13:41:24 +0200 Subject: [PATCH 156/246] fix: Deal correctly with empty elements in neighbor lists :wrench: --- R/nb.R | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/R/nb.R b/R/nb.R index 8d362329..60d6ca80 100644 --- a/R/nb.R +++ b/R/nb.R @@ -155,7 +155,7 @@ validate_nb = function(x, nodes) { ) } # Check 3: Are all referenced node indices referring to a provided node? - ids_in_bounds = function(x) all(x > 0 & x <= n_nodes) + 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", From fb26d3e70b07ae7ec949cc73b44c9068170ab540 Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Sat, 28 Sep 2024 13:41:40 +0200 Subject: [PATCH 157/246] fix: Debug bind functions :wrench: --- R/bind.R | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/R/bind.R b/R/bind.R index 85e92ba0..76fb75d2 100644 --- a/R/bind.R +++ b/R/bind.R @@ -43,7 +43,7 @@ #' #' @name bind_spatial #' @importFrom cli cli_abort -#' @importFrom sf st_drop_geometry st_geometry st_geometry<- +#' @importFrom sf st_drop_geometry st_geometry #' @importFrom tidygraph bind_nodes #' @export bind_spatial_nodes = function(.data, ...) { @@ -60,14 +60,14 @@ bind_spatial_nodes = function(.data, ...) { add = lapply(list(...), st_drop_geometry) new_net = bind_nodes(net, add) # Add geometries back to the network. - st_geometry(new_net) = new_geom + new_net = mutate_node_geom(new_net, new_geom) new_net } #' @name bind_spatial #' @importFrom cli cli_abort #' @importFrom igraph is_directed -#' @importFrom sf st_drop_geometry st_geometry st_geometry<- +#' @importFrom sf st_drop_geometry st_geometry #' @importFrom tidygraph bind_edges #' @export bind_spatial_edges = function(.data, ..., node_key = "name", force = FALSE) { @@ -87,7 +87,7 @@ bind_spatial_edges = function(.data, ..., node_key = "name", force = FALSE) { add_geom = lapply(list(...), st_geometry) new_geom = do.call("c", c(net_geom, add_geom)) # Validate if binded edges are lines. - if (! are_lines(new_geom)) { + if (! are_linestrings(new_geom)) { cli_abort("Not all edges have geometry type {.cls LINESTRING}") } # Bind other data. @@ -95,7 +95,7 @@ bind_spatial_edges = function(.data, ..., node_key = "name", force = FALSE) { add = lapply(list(...), st_drop_geometry) new_net = bind_edges(net, add, node_key = node_key) # Add geometries back to the network. - st_geometry(new_net) = new_geom + new_net = mutate_edge_geom(new_net, new_geom) # Validate if binded edges meet the valid spatial network structure. if (! force) { if (is_directed(x)) { From b2b38a01df39e2b0c76a66ab8004b4169a9424e8 Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Mon, 30 Sep 2024 16:56:05 +0200 Subject: [PATCH 158/246] feat: New implementation of st_network_blend :gift: --- NAMESPACE | 3 +- R/blend.R | 743 ++++++++++++++++++---------------------- R/utils.R | 37 +- man/st_network_blend.Rd | 117 ++++--- 4 files changed, 436 insertions(+), 464 deletions(-) diff --git a/NAMESPACE b/NAMESPACE index 61aea40c..74bd44b7 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -179,10 +179,10 @@ importFrom(dplyr,across) importFrom(dplyr,arrange) importFrom(dplyr,bind_rows) importFrom(dplyr,distinct) -importFrom(dplyr,full_join) importFrom(dplyr,group_by) importFrom(dplyr,group_indices) importFrom(dplyr,join_by) +importFrom(dplyr,left_join) importFrom(dplyr,mutate) importFrom(dplyr,slice) importFrom(graphics,plot) @@ -310,6 +310,7 @@ 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) diff --git a/R/blend.R b/R/blend.R index 60880eae..91980dd8 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,88 +19,94 @@ #' 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)) +#' +#' # 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 = c(st_point(c(0.5, 0.5))) +#' p2 = c(st_point(c(0.5, -1))) +#' p3 = c(st_point(c(1, 1))) +#' p4 = c(st_point(c(1.75, 1))) +#' p5 = c(st_point(c(1.25, 0.5))) #' -#' 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)) #' -#' # 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(pts, pch = 20, col = "orange") +#' plot(net, add = TRUE) +#' plot(pts, pch = 20, col = "orange") +#' plot(b1, add = TRUE) +#' +#' # Blend points within a tolerance distance. +#' tol = units::set_units(0.6, "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(pts, pch = 20, col = "orange") +#' plot(net, add = TRUE) +#' plot(pts, pch = 20, col = "orange") +#' plot(b2, 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) { +st_network_blend.sfnetwork = function(x, y, tolerance = Inf, + ignore_duplicates = TRUE) { x = unfocus(x) if (! has_explicit_edges(x)) { cli_abort(c( @@ -123,112 +129,95 @@ st_network_blend.sfnetwork = function(x, y, tolerance = Inf) { if (will_assume_projected(x)) { raise_assume_projected("st_network_blend") } - blend_(x, y, tolerance) + blend(x, y, tolerance = tolerance, ignore_duplicates = ignore_duplicates) } #' @importFrom cli cli_warn -#' @importFrom dplyr bind_rows full_join +#' @importFrom dplyr bind_rows left_join #' @importFrom igraph is_directed -#' @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 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 = n_nodes(x) - geom_colname = attr(nodes, "sf_column") + node_colname = attr(nodes, "sf_column") ## =========================== - # 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) { cli_warn(c( "{.fn st_network_blend} did not blend any points into the network.", @@ -238,156 +227,165 @@ blend_ = function(x, y, tolerance) { } 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_points(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. + ## ============================================ + # 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) + P_dups = P[is_duplicated] + P = P[!is_duplicated] + nearest = nearest[!is_duplicated] + ## ===================================================== + # 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 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 - } - # 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)] + # --> 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, st_precision(y)) + # 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, st_precision(edges)) + 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, st_precision(edges)) + 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, st_precision(edges)) + trg_coords = df_to_coords(trg, st_precision(edges)) + 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: + # 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. + ## ========================================== + # Infer the new number of edge points after including the projected features. + n = nrow(edge_pts) + # 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 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 = rep(1L, n) 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 + # Create the new set of edge points by duplicating split points. + new_edge_pts = edge_pts[rep(seq_len(n), reps), ] + # Define the total number of new edge points. + nn = nrow(new_edge_pts) + # 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, nn) + incs[which(is_split) + 1:sum(is_split)] = 1L + new_edge_ids = new_edge_pts$eid + cumsum(incs) + # Use the new edge coordinates to create their linestring geometries. + edge_coords = edge_pts[names(edge_pts) %in% c("x", "y", "z", "m")] + new_edge_coords = edge_coords[rep(seq_len(n), reps), ] + new_edge_coords$eid = new_edge_ids + new_edge_geoms = df_to_lines(new_edge_coords, edges, "eid", select = FALSE) + ## ===================================== + # 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: @@ -395,133 +393,74 @@ 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_incident_ids(x) - } else { - edge_pts[is_boundary, ]$node_id = edge_boundary_ids(x) - } - # 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. - ## ================================================== - # 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. + ## ====================================== + # 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) { - # 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), ] + # Subset y to contain only attributes (not geometries) of blended features. + y_blended = st_drop_geometry(y)[do_blend, ][!is_duplicated, ] + # 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_blended$.sfnetwork_index = seq_len(nrow(y_blended)) + 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_blended, by = ".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) + # Add features with duplicated projection locations if requested. + if (!ignore_duplicates && any(is_duplicated)) { + y_dups = y[do_blend, ][is_duplicated, ] + st_geometry(y_dups) = P_dups + st_geometry(y_dups) = node_colname # Use correct name. + new_nodes = bind_rows(new_nodes, y_dups) + } } 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 + # 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) + } } + ## ================================================== + # 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. + ## ================================================== + 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/utils.R b/R/utils.R index e02f2a70..d70a8a82 100644 --- a/R/utils.R +++ b/R/utils.R @@ -137,8 +137,8 @@ sfc_to_sf = function(x, colname = "geometry") { #' @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 these columns in exactly that order. Defaults to -#' \code{TRUE}. +#' data frame only contains (a subset of) these columns in exactly that order. +#' Defaults to \code{TRUE}. #' #' @return An object of class \code{\link[sf]{sfc}} with \code{POINT} #' geometries. @@ -170,8 +170,8 @@ df_to_points = function(x_df, x_sf, select = TRUE) { #' 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 these coordinate columns in exactly that order. -#' Defaults to \code{TRUE}. +#' 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. @@ -187,6 +187,32 @@ df_to_lines = function(x_df, x_sf, id_col = "linestring_id", select = TRUE) { lns } +#' 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 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 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 A character vector with each element being the concatenated +#' coordinate values of a row in \code{x}. +#' +#' @noRd +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 boundary points of linestring geometries #' #' @param x An object of class \code{\link[sf]{sf}} or \code{\link[sf]{sfc}} @@ -283,7 +309,7 @@ linestring_end_points = function(x ,return_df = FALSE) { #' @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. line_points = sfc_to_df(st_geometry(x)) # Define which of the points are a startpoint of a line. @@ -299,6 +325,7 @@ linestring_segments = function(x) { # Construct the 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") } diff --git a/man/st_network_blend.Rd b/man/st_network_blend.Rd index bce86741..dde95d56 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 = c(st_point(c(0.5, 0.5))) +p2 = c(st_point(c(0.5, -1))) +p3 = c(st_point(c(1, 1))) +p4 = c(st_point(c(1.75, 1))) +p5 = c(st_point(c(1.25, 0.5))) -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)) -# 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(pts, pch = 20, col = "orange") +plot(net, add = TRUE) +plot(pts, pch = 20, col = "orange") +plot(b1, add = TRUE) + +# Blend points within a tolerance distance. +tol = units::set_units(0.6, "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(pts, pch = 20, col = "orange") +plot(net, add = TRUE) +plot(pts, pch = 20, col = "orange") +plot(b2, add = TRUE) + +# Add points with duplicated projected location as isolated nodes. +b3 = st_network_blend(net, pts, ignore_duplicates = FALSE) +b3 + par(oldpar) } From b149edb7f6f1a1855a7faf87b9badd5943008abe Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Mon, 30 Sep 2024 17:18:19 +0200 Subject: [PATCH 159/246] fix: Debug new st_network_blend :wrench: --- R/blend.R | 39 ++++++++++++++++++++------------------- man/st_network_blend.Rd | 30 +++++++++++++++--------------- 2 files changed, 35 insertions(+), 34 deletions(-) diff --git a/R/blend.R b/R/blend.R index 91980dd8..024f46a8 100644 --- a/R/blend.R +++ b/R/blend.R @@ -64,32 +64,32 @@ #' net = as_sfnetwork(c(e1, e2)) #' #' # Create spatial points to blend in. -#' p1 = c(st_point(c(0.5, 0.5))) -#' p2 = c(st_point(c(0.5, -1))) -#' p3 = c(st_point(c(1, 1))) -#' p4 = c(st_point(c(1.75, 1))) -#' p5 = c(st_point(c(1.25, 0.5))) +#' 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 = st_sf(foo = letters[1:5], geometry = c(p1, p2, p3, p4, p5)) +#' 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 #' -#' plot(pts, pch = 20, col = "orange") -#' plot(net, add = TRUE) -#' plot(pts, pch = 20, col = "orange") -#' plot(b1, add = TRUE) +#' 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.6, "m") +#' tol = units::set_units(0.1, "m") #' b2 = st_network_blend(net, pts, tolerance = tol) #' b2 #' -#' plot(pts, pch = 20, col = "orange") -#' plot(net, add = TRUE) -#' plot(pts, pch = 20, col = "orange") -#' plot(b2, 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) @@ -424,21 +424,22 @@ blend = function(x, y, tolerance, ignore_duplicates = TRUE) { # 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_blended = st_drop_geometry(y)[do_blend, ][!is_duplicated, ] + 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_blended$.sfnetwork_index = seq_len(nrow(y_blended)) + 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_blended, by = ".sfnetwork_index") + 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, ][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) diff --git a/man/st_network_blend.Rd b/man/st_network_blend.Rd index dde95d56..42559ea4 100644 --- a/man/st_network_blend.Rd +++ b/man/st_network_blend.Rd @@ -75,32 +75,32 @@ e2 = st_sfc(st_linestring(c(n2, n3)), crs = 3857) net = as_sfnetwork(c(e1, e2)) # Create spatial points to blend in. -p1 = c(st_point(c(0.5, 0.5))) -p2 = c(st_point(c(0.5, -1))) -p3 = c(st_point(c(1, 1))) -p4 = c(st_point(c(1.75, 1))) -p5 = c(st_point(c(1.25, 0.5))) +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 = st_sf(foo = letters[1:5], geometry = c(p1, p2, p3, p4, p5)) +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 -plot(pts, pch = 20, col = "orange") -plot(net, add = TRUE) -plot(pts, pch = 20, col = "orange") -plot(b1, add = TRUE) +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.6, "m") +tol = units::set_units(0.1, "m") b2 = st_network_blend(net, pts, tolerance = tol) b2 -plot(pts, pch = 20, col = "orange") -plot(net, add = TRUE) -plot(pts, pch = 20, col = "orange") -plot(b2, 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) From 7c201bc8350a29a01efe2f64e7e311a3b73f0f6a Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Mon, 30 Sep 2024 17:42:28 +0200 Subject: [PATCH 160/246] feat: New function to project points onto the network :gift: --- NAMESPACE | 1 + R/project.R | 102 ++++++++++++++++++++++++++++++++++++++ man/project_on_network.Rd | 77 ++++++++++++++++++++++++++++ 3 files changed, 180 insertions(+) create mode 100644 R/project.R create mode 100644 man/project_on_network.Rd diff --git a/NAMESPACE b/NAMESPACE index 74bd44b7..56908a66 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -136,6 +136,7 @@ export(node_is_within) export(node_is_within_distance) export(node_touches) export(play_spatial) +export(project_on_network) export(sf_attr) export(sfnetwork) export(sfnetwork_to_nb) diff --git a/R/project.R b/R/project.R new file mode 100644 index 00000000..5ced028b --- /dev/null +++ b/R/project.R @@ -0,0 +1,102 @@ +#' Project a spatial point on the network +#' +#' @param x The spatial features to be blended, 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{y}. Setting it to \code{'nodes'} +#' will find the nearest node to each point in \code{y}. +#' +#' @details This function used \code{\link[sf]{st_nearest_feature}} to find +#' the nearest edge or node to each feature in \code{y}. 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{y} but with its geometries replaced by the +#' projected points. +#' +#' @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 = 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 = 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 +project_on_network = 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_geometry<- 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. + # Then, the endpoint of that line is the location we are looking for. + L = st_nearest_points(x, E[nearest], pairwise = TRUE) + L = sfc_cast(L, "LINESTRING") + P = linestring_end_points(L) + # Replace geometry of y with the projected points. + st_geometry(x) = P + x +} + +#' @importFrom sf st_geometry<- 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) + # Replace geometry of y with the nearest nodes. + st_geometry(x) = N[nearest] + x +} \ No newline at end of file diff --git a/man/project_on_network.Rd b/man/project_on_network.Rd new file mode 100644 index 00000000..a79cd2b6 --- /dev/null +++ b/man/project_on_network.Rd @@ -0,0 +1,77 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/project.R +\name{project_on_network} +\alias{project_on_network} +\title{Project a spatial point on the network} +\usage{ +project_on_network(x, network, on = "edges") +} +\arguments{ +\item{x}{The spatial features to be blended, 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{y}. Setting it to \code{'nodes'} +will find the nearest node to each point in \code{y}.} +} +\value{ +The same object as \code{y} but with its geometries replaced by the +projected points. +} +\description{ +Project a spatial point on the network +} +\details{ +This function used \code{\link[sf]{st_nearest_feature}} to find +the nearest edge or node to each feature in \code{y}. 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 = 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 = 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) + +} From def962883c0ee5498b557c77f3603bff7d89a6be Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Mon, 30 Sep 2024 18:10:32 +0200 Subject: [PATCH 161/246] feat: Allow duplicated matches in st_join :gift: --- R/blend.R | 35 +++++++++++++++++++++++--- R/sf.R | 54 ++++++++++++++++++++++++++++++++--------- man/sf_methods.Rd | 13 ++++++++-- man/st_network_blend.Rd | 2 +- 4 files changed, 86 insertions(+), 18 deletions(-) diff --git a/R/blend.R b/R/blend.R index 024f46a8..6548bb38 100644 --- a/R/blend.R +++ b/R/blend.R @@ -24,7 +24,7 @@ #' 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. +#' 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}}. @@ -246,9 +246,36 @@ blend = function(x, y, tolerance, ignore_duplicates = TRUE) { # 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) - P_dups = P[is_duplicated] - P = P[!is_duplicated] - nearest = nearest[!is_duplicated] + 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] + } + } ## ===================================================== # STEP V: INCLUDE PROJECTED FEATURES IN EDGE GEOMETRIES # The projected features should be included in the edge geometries. diff --git a/R/sf.R b/R/sf.R index 01e95fd4..0be653f2 100644 --- a/R/sf.R +++ b/R/sf.R @@ -27,6 +27,14 @@ #' @param precision The precision to be assigned. See #' \code{\link[sf]{st_precision}} 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 @@ -57,7 +65,8 @@ #' \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. +#' 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 @@ -539,12 +548,12 @@ geom_unary_ops = function(op, x, active, ...) { #' @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_invalid_active(active) ) @@ -561,7 +570,7 @@ st_join.morphed_sfnetwork = function(x, y, ...) { #' @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) @@ -580,13 +589,32 @@ spatial_join_nodes = function(x, y, ...) { duplicated_match = duplicated(n_new$.sfnetwork_index) if (any(duplicated_match)) { n_new = n_new[!duplicated_match, ] - cli_warn(c( - "{.fn st_join} for {.cls sfnetwork} objects only joins one feature per node.", - "!" = paste( - "Multiple matches were detected for some nodes,", - "of which all but the first one are ignored." - ) - )) + 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}." + ) + )) + } 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, ] + } } # 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. @@ -599,6 +627,10 @@ spatial_join_nodes = function(x, y, ...) { # Update node attributes of the original network. n_new$.sfnetwork_index = NULL node_data(x) = n_new + # Add duplicated matches as isolated nodes. + if (any(duplicated_match) & !ignore_multiple) { + x = bind_spatial_nodes(x, n_dups) + } x } diff --git a/man/sf_methods.Rd b/man/sf_methods.Rd index 7c0d4a76..4645a5b7 100644 --- a/man/sf_methods.Rd +++ b/man/sf_methods.Rd @@ -93,7 +93,7 @@ \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, ...) @@ -146,6 +146,14 @@ 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 methods for \code{\link[sf]{st_join}}, @@ -182,7 +190,8 @@ have a special behavior: \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. + 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 diff --git a/man/st_network_blend.Rd b/man/st_network_blend.Rd index 42559ea4..00cec7a3 100644 --- a/man/st_network_blend.Rd +++ b/man/st_network_blend.Rd @@ -24,7 +24,7 @@ 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. +Nodes at equal locations can then be merged using the spatial morpher \code{\link{to_spatial_unique}}. Defaults to \code{TRUE}.} } \value{ From afe4247977a9fc88bf09e364cac6d5df824cd53c Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Mon, 30 Sep 2024 18:38:40 +0200 Subject: [PATCH 162/246] docs: Update function examples :books: --- R/bbox.R | 36 ++++++++++++++++++++---------------- R/cost.R | 10 +++++++++- R/data.R | 5 +++++ R/join.R | 40 +++++++++++++++++++++++----------------- R/paths.R | 32 +++++++++++++++++++++++++++----- R/subdivide.R | 4 +--- R/travel.R | 1 + man/data.Rd | 6 ++++++ man/st_network_bbox.Rd | 36 ++++++++++++++++++++---------------- man/st_network_cost.Rd | 10 +++++++++- man/st_network_join.Rd | 39 ++++++++++++++++++++++----------------- man/st_network_paths.Rd | 32 +++++++++++++++++++++++++++----- man/st_network_travel.Rd | 1 + 13 files changed, 171 insertions(+), 81 deletions(-) diff --git a/R/bbox.R b/R/bbox.R index 60a8fe38..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 diff --git a/R/cost.R b/R/cost.R index b2d5ba59..a2d062d7 100644 --- a/R/cost.R +++ b/R/cost.R @@ -98,7 +98,15 @@ #' #' # 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 = NULL) +#' 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. diff --git a/R/data.R b/R/data.R index 10952a63..e4aebc1b 100644 --- a/R/data.R +++ b/R/data.R @@ -11,6 +11,11 @@ #' 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) { diff --git a/R/join.R b/R/join.R index 0dd96df4..762a7ffd 100644 --- a/R/join.R +++ b/R/join.R @@ -24,26 +24,32 @@ #' @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(neb2, 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 diff --git a/R/paths.R b/R/paths.R index b2dcd8e2..17016d3c 100644 --- a/R/paths.R +++ b/R/paths.R @@ -122,11 +122,17 @@ #' paths #' #' plot(net, col = "grey") -#' plot(st_geometry(paths), col = "red", lwd = 1.5, add = TRUE) +#' 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. -#' st_network_paths(net, from = 495, to = c(121, 131, 141)) +#' paths = st_network_paths(net, from = 495, to = c(121, 131, 141)) +#' paths +#' +#' 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 path between two spatial point features. #' # These are snapped to their nearest node before finding the path. @@ -139,8 +145,9 @@ #' paths #' #' plot(net, col = "grey") -#' plot(c(p1, p2), col = "black", pch = 8, add = TRUE) -#' plot(st_geometry(paths), col = "red", lwd = 1.5, 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)) @@ -158,7 +165,22 @@ #' #' # 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 = NULL) +#' 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) #' diff --git a/R/subdivide.R b/R/subdivide.R index 7e63105d..ee7bedcc 100644 --- a/R/subdivide.R +++ b/R/subdivide.R @@ -109,11 +109,9 @@ subdivide_edges = function(x, protect = NULL, all = FALSE, merge = TRUE) { reps[is_split] = 2L # Create the new set of edge points by duplicating split points. new_edge_pts = edge_pts[rep(seq_len(n), reps), ] - # Define the total number of new edge points. - nn = nrow(new_edge_pts) # 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, nn) + incs = rep(0L, nrow(new_edge_pts)) incs[which(is_split) + 1:sum(is_split)] = 1L new_edge_ids = new_edge_pts$eid + cumsum(incs) # Use the new edge coordinates to create their linestring geometries. diff --git a/R/travel.R b/R/travel.R index 29f6012e..12634ecc 100644 --- a/R/travel.R +++ b/R/travel.R @@ -104,6 +104,7 @@ #' #' 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) diff --git a/man/data.Rd b/man/data.Rd index 6aa516f3..112ef011 100644 --- a/man/data.Rd +++ b/man/data.Rd @@ -30,3 +30,9 @@ 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/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_cost.Rd b/man/st_network_cost.Rd index 57aad0ad..d8efcd93 100644 --- a/man/st_network_cost.Rd +++ b/man/st_network_cost.Rd @@ -126,7 +126,15 @@ net |> # 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 = NULL) +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. diff --git a/man/st_network_join.Rd b/man/st_network_join.Rd index 1512195d..e13e9a3e 100644 --- a/man/st_network_join.Rd +++ b/man/st_network_join.Rd @@ -34,26 +34,31 @@ setting the precision of the networks using \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(neb2, 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 42617f89..db0f3f61 100644 --- a/man/st_network_paths.Rd +++ b/man/st_network_paths.Rd @@ -145,11 +145,17 @@ paths = st_network_paths(net, from = 495, to = 121) paths plot(net, col = "grey") -plot(st_geometry(paths), col = "red", lwd = 1.5, add = TRUE) +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. -st_network_paths(net, from = 495, to = c(121, 131, 141)) +paths = st_network_paths(net, from = 495, to = c(121, 131, 141)) +paths + +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 path between two spatial point features. # These are snapped to their nearest node before finding the path. @@ -162,8 +168,9 @@ paths = st_network_paths(net, from = p1, to = p2) paths plot(net, col = "grey") -plot(c(p1, p2), col = "black", pch = 8, add = TRUE) -plot(st_geometry(paths), col = "red", lwd = 1.5, 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)) @@ -181,7 +188,22 @@ net |> # 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 = NULL) +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) diff --git a/man/st_network_travel.Rd b/man/st_network_travel.Rd index 9695a0ec..11aa1641 100644 --- a/man/st_network_travel.Rd +++ b/man/st_network_travel.Rd @@ -123,6 +123,7 @@ 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) From daa6b7204b4a08790afc737768e3e108d92d4310 Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Tue, 1 Oct 2024 12:03:02 +0200 Subject: [PATCH 163/246] feat: Allow subdivision when creating network :gift: --- R/checks.R | 39 ++++++++------- R/create.R | 81 ++++++++++++++++++++++++++----- R/data.R | 18 ++++++- R/subdivide.R | 50 ++++++++++++------- man/create_from_spatial_lines.Rd | 23 +++++++-- man/create_from_spatial_points.Rd | 3 ++ 6 files changed, 163 insertions(+), 51 deletions(-) diff --git a/R/checks.R b/R/checks.R index c7d10a39..cfc813c4 100644 --- a/R/checks.R +++ b/R/checks.R @@ -292,30 +292,35 @@ nodes_equal_edge_boundaries = function(x) { 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_node_index", - ".tidygraph_edge_index", - ".tidygraph_index", - ".tbl_graph_index", - ".sfnetwork_node_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 projected coordinates will be assumed for a network diff --git a/R/create.R b/R/create.R index 3feb3953..1e250005 100644 --- a/R/create.R +++ b/R/create.R @@ -410,6 +410,12 @@ as_sfnetwork.focused_tbl_graph = function(x, ...) { #' 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. @@ -419,32 +425,83 @@ as_sfnetwork.focused_tbl_graph = function(x, ...) { #' 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) #' -#' as_sfnetwork(roxel) -#' #' 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(as_sfnetwork(roxel)) +#' plot(net) #' #' par(oldpar) #' -#' @importFrom sf st_as_sf st_precision st_sf +#' @importFrom sf st_agr st_as_sf st_precision st_sf #' @export -create_from_spatial_lines = function(x, directed = TRUE, - compute_length = FALSE) { +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) - # Get the coordinates of the boundary points of the edges. - # These will form the nodes of the network. - node_coords = linestring_boundary_points(edges, return_df = TRUE) - # Give each unique location a unique ID. - indices = st_match_points_df(node_coords, attr(x, "precision")) + # 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. @@ -561,6 +618,8 @@ create_from_spatial_lines = function(x, directed = TRUE, #' package to be installed. #' } #' +#' @seealso \code{\link{create_from_spatial_lines}}, \code{\link{play_spatial}} +#' #' @return An object of class \code{\link{sfnetwork}}. #' #' @examples diff --git a/R/data.R b/R/data.R index e4aebc1b..d3eafd70 100644 --- a/R/data.R +++ b/R/data.R @@ -140,7 +140,13 @@ edge_colnames = function(x, idxs = FALSE, geom = TRUE) { #' @noRd evaluate_node_attribute_query = function(x, query) { nodes = st_drop_geometry(nodes_as_sf(x)) - exclude = c(".tidygraph_node_index", ".sfnetwork_index") + 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)] } @@ -151,7 +157,15 @@ evaluate_node_attribute_query = function(x, query) { #' @noRd evaluate_edge_attribute_query = function(x, query) { edges = st_drop_geometry(edge_data(x)) - exclude = c("from", "to", ".tidygraph_edge_index", ".sfnetwork_index") + 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)] } diff --git a/R/subdivide.R b/R/subdivide.R index ee7bedcc..9c0544b5 100644 --- a/R/subdivide.R +++ b/R/subdivide.R @@ -75,7 +75,7 @@ subdivide_edges = function(x, protect = NULL, all = FALSE, merge = TRUE) { # --> 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, attr(edges, "precision")) + edge_lids = st_match_points_df(edge_coords, st_precision(edges)) edge_pts$lid = edge_lids } # Define which edges to protect from being subdivided. @@ -100,24 +100,12 @@ subdivide_edges = function(x, protect = NULL, all = FALSE, merge = TRUE) { # 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 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[is_split] = 2L # Create the new set of edge points by duplicating split points. - new_edge_pts = edge_pts[rep(seq_len(n), reps), ] + new_edge_pts = create_new_edge_df(edge_pts, is_split) # 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(new_edge_pts)) - incs[which(is_split) + 1:sum(is_split)] = 1L - new_edge_ids = new_edge_pts$eid + cumsum(incs) - # Use the new edge coordinates to create their linestring geometries. - new_edge_coords = edge_coords[rep(seq_len(n), reps), ] - new_edge_coords$eid = new_edge_ids - new_edge_geoms = df_to_lines(new_edge_coords, edges, "eid", select = FALSE) + 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. @@ -215,3 +203,31 @@ subdivide_edges = function(x, protect = NULL, all = FALSE, merge = TRUE) { 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/man/create_from_spatial_lines.Rd b/man/create_from_spatial_lines.Rd index 6741990f..65b40c93 100644 --- a/man/create_from_spatial_lines.Rd +++ b/man/create_from_spatial_lines.Rd @@ -4,7 +4,12 @@ \alias{create_from_spatial_lines} \title{Create a spatial network from linestring geometries} \usage{ -create_from_spatial_lines(x, directed = TRUE, compute_length = FALSE) +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}} @@ -20,6 +25,12 @@ the length of the edge geometries. If there is already a column named 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}}. @@ -41,14 +52,18 @@ setting the precision of the linestrings using \examples{ library(sf, quietly = TRUE) -as_sfnetwork(roxel) - 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(as_sfnetwork(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 index d936b8b3..7b3f0f35 100644 --- a/man/create_from_spatial_points.Rd +++ b/man/create_from_spatial_points.Rd @@ -163,3 +163,6 @@ plot(knet, main = "k nearest neighbor graph (k = 2)") par(oldpar) } +\seealso{ +\code{\link{create_from_spatial_lines}}, \code{\link{play_spatial}} +} From e66f3aa25ab8f13a37c36d7cdd362753121bcfe9 Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Tue, 1 Oct 2024 12:03:28 +0200 Subject: [PATCH 164/246] refactor: Tidy :construction: --- R/attrs.R | 31 ++++++++++++++----------------- R/edge.R | 26 -------------------------- man/sf_attr.Rd | 12 ++++-------- 3 files changed, 18 insertions(+), 51 deletions(-) diff --git a/R/attrs.R b/R/attrs.R index 974ec41d..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) @@ -120,7 +117,7 @@ update_edge_agr = function(x) { #' #' @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 +#' targeted element of x. Attribute columns do not involve the geometry list #' column, but do involve the from and to columns. #' #' @noRd @@ -158,8 +155,8 @@ make_agr_valid = function(agr, names) { #' #' @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). +#' @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 @@ -168,9 +165,9 @@ make_agr_valid = function(agr, names) { #' 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. +#' 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 @@ -185,7 +182,7 @@ make_agr_valid = function(agr, names) { #' @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 +#' 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. @@ -208,9 +205,9 @@ make_agr_valid = function(agr, names) { #' 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. +#' 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) { diff --git a/R/edge.R b/R/edge.R index 77e30f89..9a5f8164 100644 --- a/R/edge.R +++ b/R/edge.R @@ -1,29 +1,3 @@ -#' @importFrom cli cli_abort -#' @importFrom igraph edge_attr -#' @importFrom rlang enquo eval_tidy -#' @importFrom tidygraph .E .register_graph_context -evaluate_edge_query = function(data, edges) { - .register_graph_context(data, free = TRUE) - edges = eval_tidy(enquo(edges), .E()) - 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) - } - edges -} - #' Query spatial edge measures #' #' These functions are a collection of edge measures in spatial networks. 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") From 716f41dcc0fe5113623b4af98d884aba0ad945ed Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Tue, 1 Oct 2024 12:19:49 +0200 Subject: [PATCH 165/246] refactor: Update precision workflow :construction: --- R/blend.R | 38 ++++++++++++-------------------------- R/print.R | 2 +- R/sf.R | 22 +++++++++++++++++++--- R/subdivide.R | 2 +- R/utils.R | 4 ++-- 5 files changed, 35 insertions(+), 33 deletions(-) diff --git a/R/blend.R b/R/blend.R index 6548bb38..a937b7cc 100644 --- a/R/blend.R +++ b/R/blend.R @@ -149,7 +149,10 @@ blend = function(x, y, tolerance, ignore_duplicates = TRUE) { Y = st_geometry(y) # For later use: # --> Retrieve the name of the geometry column of the nodes in x. + # --> Retrieve the precision of x and y. node_colname = attr(nodes, "sf_column") + xp = network_precision(x) + yp = st_precision(y) ## =========================== # STEP I: DECOMPOSE THE EDGES # Decompose the edges linestring geometries into the points that shape them. @@ -300,7 +303,7 @@ blend = function(x, y, tolerance, ignore_duplicates = TRUE) { 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, st_precision(y)) + 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, ] @@ -311,13 +314,13 @@ blend = function(x, y, tolerance, ignore_duplicates = TRUE) { 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, st_precision(edges)) + 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, st_precision(edges)) + trg_coords = df_to_coords(trg, xp) if (fts_coords == trg_coords) { trg$fid = fts$fid fts = trg @@ -330,8 +333,8 @@ blend = function(x, y, tolerance, ignore_duplicates = TRUE) { # 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, st_precision(edges)) - trg_coords = df_to_coords(trg, st_precision(edges)) + 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) @@ -383,34 +386,17 @@ blend = function(x, y, tolerance, ignore_duplicates = TRUE) { # Now we can subdivide edge geometries at each projected feature. # Then we need to build a linestring geometry for each new edge. ## ========================================== - # Infer the new number of edge points after including the projected features. - n = nrow(edge_pts) # 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 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[is_split] = 2L # Create the new set of edge points by duplicating split points. - new_edge_pts = edge_pts[rep(seq_len(n), reps), ] - # Define the total number of new edge points. - nn = nrow(new_edge_pts) + new_edge_pts = create_new_edge_df(edge_pts, is_split) # 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, nn) - incs[which(is_split) + 1:sum(is_split)] = 1L - new_edge_ids = new_edge_pts$eid + cumsum(incs) - # Use the new edge coordinates to create their linestring geometries. - edge_coords = edge_pts[names(edge_pts) %in% c("x", "y", "z", "m")] - new_edge_coords = edge_coords[rep(seq_len(n), reps), ] - new_edge_coords$eid = new_edge_ids - new_edge_geoms = df_to_lines(new_edge_coords, edges, "eid", select = FALSE) + 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. diff --git a/R/print.R b/R/print.R index 3a742e14..ef0d18f9 100644 --- a/R/print.R +++ b/R/print.R @@ -231,7 +231,7 @@ describe_space = function(x, is_explicit = NULL) { } } # Precision. - prc = attr(node_geom, "precision") + prc = network_precision(x) if (prc < 0.0) { desc = append(desc, paste("# Precision: float (single precision)")) } else if (prc > 0.0) { diff --git a/R/sf.R b/R/sf.R index 0be653f2..d1446bcc 100644 --- a/R/sf.R +++ b/R/sf.R @@ -118,24 +118,30 @@ st_as_sf.sfnetwork = function(x, active = NULL, focused = TRUE, ...) { #' @importFrom sf st_as_sf nodes_as_sf = function(x, focused = FALSE, ...) { - st_as_sf( + out = st_as_sf( nodes_as_regular_tibble(x, focused = focused), agr = node_agr(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 edges_as_sf = function(x, focused = FALSE, ...) { geom_colname = edge_geom_colname(x) if (is.null(geom_colname)) raise_require_explicit() - st_as_sf( + out = st_as_sf( edges_as_regular_tibble(x, focused = focused), agr = edge_agr(x), sf_column_name = geom_colname, ... ) + p = network_precision(x) + if (! is.null(p)) st_precision(out) = p + out } # ============================================================================= @@ -343,7 +349,17 @@ st_crs.sfnetwork = function(x, ...) { #' @importFrom sf st_precision #' @export st_precision.sfnetwork = function(x) { - st_precision(pull_geom(x)) + network_precision(x) +} + +#' @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 diff --git a/R/subdivide.R b/R/subdivide.R index 9c0544b5..65d4b0bb 100644 --- a/R/subdivide.R +++ b/R/subdivide.R @@ -75,7 +75,7 @@ subdivide_edges = function(x, protect = NULL, all = FALSE, merge = TRUE) { # --> 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, st_precision(edges)) + edge_lids = st_match_points_df(edge_coords, network_precision(x)) edge_pts$lid = edge_lids } # Define which edges to protect from being subdivided. diff --git a/R/utils.R b/R/utils.R index d70a8a82..32a406d9 100644 --- a/R/utils.R +++ b/R/utils.R @@ -27,7 +27,7 @@ st_duplicated = function(x) { #' @importFrom sf st_geometry #' @importFrom sfheaders sfc_to_df st_duplicated_points = function(x, precision = attr(x, "precision")) { - x_df = sfc_to_df(st_geometry(x)) + 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) } @@ -66,7 +66,7 @@ st_match = function(x) { #' @importFrom sf st_geometry #' @importFrom sfheaders sfc_to_df st_match_points = function(x, precision = attr(x, "precision")) { - x_df = sfc_to_df(st_geometry(x)) + 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) } From 95285cd9b54ac05955cda3db00de5937fbf1326c Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Tue, 1 Oct 2024 12:34:36 +0200 Subject: [PATCH 166/246] fix: Debug node binding and joining with multiple matches :wrench: --- R/bind.R | 15 +++++++++++---- R/sf.R | 4 +++- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/R/bind.R b/R/bind.R index 76fb75d2..ef8e38e0 100644 --- a/R/bind.R +++ b/R/bind.R @@ -43,8 +43,8 @@ #' #' @name bind_spatial #' @importFrom cli cli_abort -#' @importFrom sf st_drop_geometry st_geometry -#' @importFrom tidygraph bind_nodes +#' @importFrom sf st_drop_geometry st_geometry st_geometry<- +#' @importFrom tidygraph activate bind_nodes #' @export bind_spatial_nodes = function(.data, ...) { # Bind geometries @@ -60,8 +60,15 @@ bind_spatial_nodes = function(.data, ...) { add = lapply(list(...), st_drop_geometry) new_net = bind_nodes(net, add) # Add geometries back to the network. - new_net = mutate_node_geom(new_net, new_geom) - new_net + 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 diff --git a/R/sf.R b/R/sf.R index d1446bcc..0cdc5664 100644 --- a/R/sf.R +++ b/R/sf.R @@ -604,7 +604,6 @@ spatial_join_nodes = function(x, y, ..., ignore_multiple = TRUE) { # --> See the package vignettes for more info. duplicated_match = duplicated(n_new$.sfnetwork_index) if (any(duplicated_match)) { - n_new = n_new[!duplicated_match, ] if (ignore_multiple) { cli_warn(c( "{.fn st_join} did not join all features.", @@ -617,6 +616,7 @@ spatial_join_nodes = function(x, y, ..., ignore_multiple = TRUE) { "set {.arg ignore_multiple} to {.code FALSE}." ) )) + n_new = n_new[!duplicated_match, ] } else { cli_warn(c( "{.fn st_join} created isolated nodes.", @@ -630,6 +630,7 @@ spatial_join_nodes = function(x, y, ..., ignore_multiple = TRUE) { ) )) n_dups = n_new[duplicated_match, ] + n_new = n_new[!duplicated_match, ] } } # If an inner join was requested instead of a left join: @@ -645,6 +646,7 @@ spatial_join_nodes = function(x, y, ..., ignore_multiple = TRUE) { 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 From 73c241650ccf3a80b5e93bbda62ffb0e5ef5576f Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Tue, 1 Oct 2024 13:36:38 +0200 Subject: [PATCH 167/246] feat: Add conversion functions for dodgr streetnets :gift: --- NAMESPACE | 2 + R/dodgr.R | 105 ++++++++++++++++++++++++++++++++++++++ man/sfnetwork_to_dodgr.Rd | 44 ++++++++++++++++ 3 files changed, 151 insertions(+) create mode 100644 man/sfnetwork_to_dodgr.Rd diff --git a/NAMESPACE b/NAMESPACE index 56908a66..3e944606 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -78,6 +78,7 @@ 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) @@ -139,6 +140,7 @@ export(play_spatial) export(project_on_network) export(sf_attr) export(sfnetwork) +export(sfnetwork_to_dodgr) export(sfnetwork_to_nb) export(simplify_network) export(smooth_pseudo_nodes) diff --git a/R/dodgr.R b/R/dodgr.R index b1abed92..cae2448a 100644 --- a/R/dodgr.R +++ b/R/dodgr.R @@ -1,3 +1,108 @@ +#' 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) 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. +} From 1383b17971833835542f0a761cafaaebd847c675 Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Tue, 1 Oct 2024 14:14:43 +0200 Subject: [PATCH 168/246] feat: Update as_sfnetwork functions, add one for dodgr :gift: --- NAMESPACE | 1 + R/create.R | 40 +++++++++++++++++++++++++++++++--------- man/as_sfnetwork.Rd | 37 ++++++++++++++++++++++++++++--------- man/autoplot.Rd | 2 +- 4 files changed, 61 insertions(+), 19 deletions(-) diff --git a/NAMESPACE b/NAMESPACE index 3e944606..3620544c 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -6,6 +6,7 @@ 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) diff --git a/R/create.R b/R/create.R index 1e250005..607453aa 100644 --- a/R/create.R +++ b/R/create.R @@ -192,8 +192,7 @@ tbg_to_sfn = function(x) { #' #' @param x Object to be converted into a \code{\link{sfnetwork}}. #' -#' @param ... Additional arguments passed on to the \code{\link{sfnetwork}} -#' construction function, unless specified otherwise. +#' @param ... Additional arguments passed on to other functions. #' #' @return An object of class \code{\link{sfnetwork}}. #' @@ -283,9 +282,27 @@ 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)) { +#' as_sfnetwork(dodgr::weight_streetnet(hampi)) +#' } +#' +#' @export +as_sfnetwork.dodgr_streetnet = function(x, edges_as_lines = TRUE) { + dodgr_to_sfnetwork(x, edges_as_lines = edges_as_lines) +} + #' @describeIn as_sfnetwork Convert spatial linear networks of class -#' \code{\link[spatstat.linnet]{linnet}} directly into an -#' \code{\link{sfnetwork}}. This requires the +#' \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. #' @@ -310,7 +327,8 @@ as_sfnetwork.linnet = function(x, ...) { #' @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. +#' boundary points. Additional arguments are forwarded to +#' \code{\link{create_from_spatial_lines}}. #' #' @examples #' # From a psp object. @@ -340,7 +358,8 @@ as_sfnetwork.psp = function(x, ...) { #' @describeIn as_sfnetwork Convert spatial networks of class #' \code{\link[stplanr:sfNetwork-class]{sfNetwork}} directly into a #' \code{\link{sfnetwork}}. This will extract the edges as an -#' \code{\link[sf]{sf}} object and re-create the network structure. The +#' \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. #' @@ -358,9 +377,12 @@ as_sfnetwork.sfNetwork = function(x, ...) { #' @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}}. The -#' directness of the original graph is preserved unless specified otherwise -#' through the \code{directed} argument. +#' \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. diff --git a/man/as_sfnetwork.Rd b/man/as_sfnetwork.Rd index a3906010..1120c0c0 100644 --- a/man/as_sfnetwork.Rd +++ b/man/as_sfnetwork.Rd @@ -5,6 +5,7 @@ \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.sfNetwork} @@ -19,6 +20,8 @@ as_sfnetwork(x, ...) \method{as_sfnetwork}{sfc}(x, ...) +\method{as_sfnetwork}{dodgr_streetnet}(x, edges_as_lines = TRUE) + \method{as_sfnetwork}{linnet}(x, ...) \method{as_sfnetwork}{psp}(x, ...) @@ -30,8 +33,7 @@ as_sfnetwork(x, ...) \arguments{ \item{x}{Object to be converted into a \code{\link{sfnetwork}}.} -\item{...}{Additional arguments passed on to the \code{\link{sfnetwork}} -construction function, unless specified otherwise.} +\item{...}{Additional arguments passed on to other functions.} } \value{ An object of class \code{\link{sfnetwork}}. @@ -70,30 +72,42 @@ 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 an -\code{\link{sfnetwork}}. This requires the +\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. +boundary points. Additional arguments are forwarded to +\code{\link{create_from_spatial_lines}}. \item \code{as_sfnetwork(sfNetwork)}: Convert spatial networks of class \code{\link[stplanr:sfNetwork-class]{sfNetwork}} directly into a \code{\link{sfnetwork}}. This will extract the edges as an -\code{\link[sf]{sf}} object and re-create the network structure. The +\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}}. The -directness of the original graph is preserved unless specified otherwise -through the \code{directed} argument. +\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{ @@ -117,6 +131,11 @@ plot(as_sfnetwork(mozart)) par(oldpar) +# From a dodgr_streetnet object. +if (require(dodgr, quietly = TRUE)) { + as_sfnetwork(dodgr::weight_streetnet(hampi)) +} + # From a linnet object. if (require(spatstat.geom, quietly = TRUE)) { as_sfnetwork(simplenet) diff --git a/man/autoplot.Rd b/man/autoplot.Rd index 7d00e0f3..c490d106 100644 --- a/man/autoplot.Rd +++ b/man/autoplot.Rd @@ -5,7 +5,7 @@ \alias{autoplot.sfnetwork} \title{Plot sfnetwork geometries with ggplot2} \usage{ -autoplot.sfnetwork(object, ...) +\method{autoplot}{sfnetwork}(object, ...) } \arguments{ \item{object}{An object of class \code{\link{sfnetwork}}.} From 1db059adc26d790f1c85f96e0a9fafaf9c3c5cc2 Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Tue, 1 Oct 2024 14:29:38 +0200 Subject: [PATCH 169/246] refactor: Deprecate summarise_attributes in favor of attribute_summary :construction: --- NAMESPACE | 1 + R/contract.R | 12 ++++++------ R/messages.R | 11 ++++++++++- R/morphers.R | 29 ++++++++++++++++++++--------- R/simplify.R | 14 +++++++------- R/smooth.R | 8 ++++---- man/contract_nodes.Rd | 4 ++-- man/simplify_network.Rd | 4 ++-- man/smooth_pseudo_nodes.Rd | 4 ++-- man/spatial_morphers.Rd | 19 ++++++++++--------- 10 files changed, 64 insertions(+), 42 deletions(-) diff --git a/NAMESPACE b/NAMESPACE index 3620544c..f3a9b2cf 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -237,6 +237,7 @@ 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(lwgeom,st_split) importFrom(methods,hasArg) diff --git a/R/contract.R b/R/contract.R index 94fe0be4..5100fc97 100644 --- a/R/contract.R +++ b/R/contract.R @@ -27,7 +27,7 @@ #' to \code{FALSE} if you know the node geometries did not change, otherwise #' the valid spatial network structure is broken. #' -#' @param summarise_attributes How should the attributes of contracted nodes be +#' @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. #' @@ -51,7 +51,7 @@ #' @export contract_nodes = function(x, groups, simplify = TRUE, compute_centroids = TRUE, reconnect_edges = TRUE, - summarise_attributes = "concat", + attribute_summary = "ignore", store_original_ids = FALSE, store_original_data = FALSE) { # Add index columns if not present. @@ -83,16 +83,16 @@ contract_nodes = function(x, groups, simplify = TRUE, # 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(summarise_attributes, "list")) { - summarise_attributes = list(summarise_attributes) + if (! inherits(attribute_summary, "list")) { + attribute_summary = list(attribute_summary) } - summarise_attributes[".tidygraph_node_index"] = "concat" + 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, summarise_attributes)) + 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. diff --git a/R/messages.R b/R/messages.R index e75bc207..d0a9fafd 100644 --- a/R/messages.R +++ b/R/messages.R @@ -82,7 +82,7 @@ raise_unknown_input = function(arg, value, options = NULL) { } #' @importFrom cli cli_abort -raise_unknown_summariser = function(value) { +raise_unknown_summarizer = function(value) { cli_abort(c( "Unknown attribute summary function: {value}.", "i" = "For supported values see {.fn igraph::attribute.combination}." @@ -216,4 +216,13 @@ deprecate_from = function() { ) ) ) +} + +#' @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 3e9af786..8e30fd56 100644 --- a/R/morphers.R +++ b/R/morphers.R @@ -11,11 +11,13 @@ #' nodes and by \code{\link{evaluate_edge_query}} in the case of edges. #' Defaults to \code{NULL}, meaning that no features are protected. #' -#' @param summarise_attributes Whenever groups of nodes or edges are merged +#' @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 @@ -84,13 +86,16 @@ NULL #' to \code{FALSE}, the geometry of the first node in each group will be used #' instead, which requires considerably less computing time. #' +#' @importFrom lifecycle deprecated is_present #' @importFrom dplyr group_by group_indices #' @importFrom sf st_drop_geometry #' @export to_spatial_contracted = function(x, ..., simplify = TRUE, compute_centroids = TRUE, - summarise_attributes = "concat", + attribute_summary = "ignore", + summarise_attributes = deprecated(), store_original_data = FALSE) { + 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) @@ -101,7 +106,7 @@ to_spatial_contracted = function(x, ..., simplify = TRUE, simplify = simplify, compute_centroids = compute_centroids, reconnect_edges = TRUE, - summarise_attributes = summarise_attributes, + attribute_summary = attribute_summary, store_original_ids = TRUE, store_original_data = store_original_data ) @@ -324,16 +329,19 @@ to_spatial_shortest_paths = function(x, ...) { #' #' @param remove_loops Should loop edges be removed. Defaults to \code{TRUE}. #' +#' @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) { + 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, - summarise_attributes = summarise_attributes, + attribute_summary = attribute_summary, store_original_ids = TRUE, store_original_data = store_original_data ) @@ -359,11 +367,14 @@ to_spatial_simple = function(x, remove_multiple = TRUE, remove_loops = TRUE, #' \code{\link[dplyr]{dplyr_tidy_select}} argument. Defaults to \code{NULL}, #' meaning that attribute equality is not considered for pseudo node removal. #' +#' @importFrom lifecycle deprecated is_present #' @importFrom rlang enquo try_fetch #' @export to_spatial_smooth = function(x, protect = NULL, require_equal = NULL, - summarise_attributes = "concat", + attribute_summary = "ignore", + summarise_attributes = deprecated(), store_original_data = FALSE) { + 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)) @@ -377,7 +388,7 @@ to_spatial_smooth = function(x, protect = NULL, require_equal = NULL, x = x, protect = protect, require_equal = require_equal, - summarise_attributes = summarise_attributes, + attribute_summary = attribute_summary, store_original_ids = TRUE, store_original_data = store_original_data ) @@ -483,7 +494,7 @@ to_spatial_transformed = function(x, ...) { #' \code{\link[sf]{st_set_precision}}. #' #' @export -to_spatial_unique = function(x, summarise_attributes = "concat", +to_spatial_unique = function(x, attribute_summary = "ignore", store_original_data = FALSE) { # Create groups. group_ids = st_match_points(pull_node_geom(x)) @@ -494,7 +505,7 @@ to_spatial_unique = function(x, summarise_attributes = "concat", simplify = FALSE, compute_centroids = FALSE, reconnect_edges = FALSE, - summarise_attributes = summarise_attributes, + attribute_summary = attribute_summary, store_original_ids = TRUE, store_original_data = store_original_data ) diff --git a/R/simplify.R b/R/simplify.R index 48d25ed6..794890d2 100644 --- a/R/simplify.R +++ b/R/simplify.R @@ -12,7 +12,7 @@ #' #' @param remove_loops Should loop edges be removed. Defaults to \code{TRUE}. #' -#' @param summarise_attributes How should the attributes of merged multiple +#' @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. #' @@ -36,7 +36,7 @@ #' @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, - summarise_attributes = "first", + attribute_summary = "first", store_original_ids = FALSE, store_original_data = FALSE) { # Add index columns if not present. @@ -51,18 +51,18 @@ simplify_network = function(x, remove_multiple = TRUE, remove_loops = TRUE, # On top of those, we need to include: # --> The geometry column, if present. # --> The tidygraph edge index column. - if (! inherits(summarise_attributes, "list")) { - summarise_attributes = list(summarise_attributes) + if (! inherits(attribute_summary, "list")) { + attribute_summary = list(attribute_summary) } edge_geomcol = edge_geom_colname(x) - if (! is.null(edge_geomcol)) summarise_attributes[edge_geomcol] = "first" - summarise_attributes[".tidygraph_edge_index"] = "concat" + 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 = summarise_attributes + edge.attr.comb = attribute_summary ) %preserve_all_attrs% x ## ==================================== # STEP II: RECONSTRUCT EDGE GEOMETRIES diff --git a/R/smooth.R b/R/smooth.R index 0877d5d9..4445aa2c 100644 --- a/R/smooth.R +++ b/R/smooth.R @@ -20,7 +20,7 @@ #' order for the pseudo node to be removed? Defaults to \code{NULL}, meaning #' that attribute equality is not considered for pseudo node removal. #' -#' @param summarise_attributes How should the attributes of concatenated edges +#' @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. #' @@ -46,7 +46,7 @@ #' @export smooth_pseudo_nodes = function(x, protect = NULL, require_equal = NULL, - summarise_attributes = "concat", + attribute_summary = "ignore", store_original_ids = FALSE, store_original_data = FALSE) { # Change default igraph options. @@ -247,7 +247,7 @@ smooth_pseudo_nodes = function(x, protect = NULL, # 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. + # 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. @@ -258,7 +258,7 @@ smooth_pseudo_nodes = function(x, protect = NULL, # --> Summarise the attributes of the edges it replaces into single values. merge_attrs = function(E) { ids = E$.tidygraph_edge_index - summarize_attributes(edge_attrs, summarise_attributes, subset = ids) + summarize_attributes(edge_attrs, attribute_summary, subset = ids) } new_attrs = lapply(new_idxs, merge_attrs) ## =================================== diff --git a/man/contract_nodes.Rd b/man/contract_nodes.Rd index 450251dc..fb8d97c3 100644 --- a/man/contract_nodes.Rd +++ b/man/contract_nodes.Rd @@ -10,7 +10,7 @@ contract_nodes( simplify = TRUE, compute_centroids = TRUE, reconnect_edges = TRUE, - summarise_attributes = "concat", + attribute_summary = "ignore", store_original_ids = FALSE, store_original_data = FALSE ) @@ -38,7 +38,7 @@ 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{summarise_attributes}{How should the attributes of contracted nodes be +\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.} diff --git a/man/simplify_network.Rd b/man/simplify_network.Rd index 253761ef..356f7a40 100644 --- a/man/simplify_network.Rd +++ b/man/simplify_network.Rd @@ -8,7 +8,7 @@ simplify_network( x, remove_multiple = TRUE, remove_loops = TRUE, - summarise_attributes = "first", + attribute_summary = "first", store_original_ids = FALSE, store_original_data = FALSE ) @@ -21,7 +21,7 @@ to \code{TRUE}.} \item{remove_loops}{Should loop edges be removed. Defaults to \code{TRUE}.} -\item{summarise_attributes}{How should the attributes of merged multiple +\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.} diff --git a/man/smooth_pseudo_nodes.Rd b/man/smooth_pseudo_nodes.Rd index e85a0f80..ae10eda7 100644 --- a/man/smooth_pseudo_nodes.Rd +++ b/man/smooth_pseudo_nodes.Rd @@ -8,7 +8,7 @@ smooth_pseudo_nodes( x, protect = NULL, require_equal = NULL, - summarise_attributes = "concat", + attribute_summary = "ignore", store_original_ids = FALSE, store_original_data = FALSE ) @@ -25,7 +25,7 @@ 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{summarise_attributes}{How should the attributes of concatenated edges +\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.} diff --git a/man/spatial_morphers.Rd b/man/spatial_morphers.Rd index 8c97445c..fa947441 100644 --- a/man/spatial_morphers.Rd +++ b/man/spatial_morphers.Rd @@ -23,7 +23,8 @@ to_spatial_contracted( ..., simplify = TRUE, compute_centroids = TRUE, - summarise_attributes = "concat", + attribute_summary = "ignore", + summarise_attributes = deprecated(), store_original_data = FALSE ) @@ -45,7 +46,8 @@ to_spatial_simple( x, remove_multiple = TRUE, remove_loops = TRUE, - summarise_attributes = "first", + attribute_summary = "first", + summarise_attributes = deprecated(), store_original_data = FALSE ) @@ -53,7 +55,8 @@ to_spatial_smooth( x, protect = NULL, require_equal = NULL, - summarise_attributes = "concat", + attribute_summary = "ignore", + summarise_attributes = deprecated(), store_original_data = FALSE ) @@ -63,11 +66,7 @@ to_spatial_subset(x, ..., subset_by = NULL) to_spatial_transformed(x, ...) -to_spatial_unique( - x, - summarise_attributes = "concat", - store_original_data = FALSE -) +to_spatial_unique(x, attribute_summary = "ignore", store_original_data = FALSE) } \arguments{ \item{x}{An object of class \code{\link{sfnetwork}}.} @@ -88,11 +87,13 @@ 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{summarise_attributes}{Whenever groups of nodes or edges are merged +\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{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 From d5e225264e2eb0e221e6f75bfcdfac4787b78d52 Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Tue, 1 Oct 2024 14:47:58 +0200 Subject: [PATCH 170/246] feat: Add function to wrap igraph functions for sfnetwork objects :gift: --- NAMESPACE | 1 + R/utils.R | 53 +++++++++++++++++++++++++++++++++++++++++++ man/wrap_igraph.Rd | 56 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 110 insertions(+) create mode 100644 man/wrap_igraph.Rd diff --git a/NAMESPACE b/NAMESPACE index f3a9b2cf..8a9d4898 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -175,6 +175,7 @@ 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) diff --git a/R/utils.R b/R/utils.R index 32a406d9..63ae9c3f 100644 --- a/R/utils.R +++ b/R/utils.R @@ -1,3 +1,56 @@ +#' Run an igraph function on an sfnetwork object +#' +#' 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. +#' +#' @param .data An object of class \code{\link{sfnetwork}}. +#' +#' @param .f An function from the \code{\link[igraph]{igraph}} package that +#' accepts a graph as its first argument, and returns a graph. +#' +#' @param ... Arguments passed on to \code{.f}. +#' +#' @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. +#' +#' @param .message Should informational messages (those messages that are +#' neither warnings nor errors) be printed when constructing the network? +#' Defaults to \code{TRUE}. +#' +#' @return An object of class \code{\link{sfnetwork}}. +#' +#' @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) +#' +#' @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 +} + #' Determine duplicated geometries #' #' @param x An object of class \code{\link[sf]{sf}} or \code{\link[sf]{sfc}}. 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) + +} From 65510472e2a40fef2531891d29caf76361461282 Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Tue, 1 Oct 2024 15:17:11 +0200 Subject: [PATCH 171/246] feat: Add function to compute straightness centrality :gift: --- NAMESPACE | 1 + R/centrality.R | 57 +++++++++++++++++++++++++++++++++++++++ man/spatial_centrality.Rd | 49 +++++++++++++++++++++++++++++++++ 3 files changed, 107 insertions(+) create mode 100644 R/centrality.R create mode 100644 man/spatial_centrality.Rd diff --git a/NAMESPACE b/NAMESPACE index 8a9d4898..6704bd95 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -73,6 +73,7 @@ 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) 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/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()) + +} From c8608d09770d8e8dec7526aaab173684c2cea423 Mon Sep 17 00:00:00 2001 From: Luuk van der Meer Date: Tue, 1 Oct 2024 16:46:24 +0200 Subject: [PATCH 172/246] fix: Fix docs for create_from_spatial_points :wrench: --- R/create.R | 8 +------- man/create_from_spatial_points.Rd | 8 +------- 2 files changed, 2 insertions(+), 14 deletions(-) diff --git a/R/create.R b/R/create.R index 607453aa..bbfdbaf1 100644 --- a/R/create.R +++ b/R/create.R @@ -652,14 +652,8 @@ create_from_spatial_lines = function(x, directed = TRUE, compute_length = FALSE, #' #' pts = st_transform(mozart, 3035) #' -#' # Using an adjacency matrix -#' adj = matrix(c(rep(TRUE, 10), rep(FALSE, 90)), nrow = 10) -#' net = as_sfnetwork(pts, connections = adj) -#' -#' plot(net) -#' #' # Using a custom adjacency matrix -#' adj = matrix(c(rep(1, 21), rep(rep(0, 21), 20)), nrow = 21) +#' adj = matrix(c(rep(TRUE, 21), rep(rep(FALSE, 21), 20)), nrow = 21) #' net = as_sfnetwork(pts, connections = adj) #' #' plot(net) diff --git a/man/create_from_spatial_points.Rd b/man/create_from_spatial_points.Rd index 7b3f0f35..20208d25 100644 --- a/man/create_from_spatial_points.Rd +++ b/man/create_from_spatial_points.Rd @@ -120,14 +120,8 @@ par(mar = c(1,1,1,1)) pts = st_transform(mozart, 3035) -# Using an adjacency matrix -adj = matrix(c(rep(TRUE, 10), rep(FALSE, 90)), nrow = 10) -net = as_sfnetwork(pts, connections = adj) - -plot(net) - # Using a custom adjacency matrix -adj = matrix(c(rep(1, 21), rep(rep(0, 21), 20)), nrow = 21) +adj = matrix(c(rep(TRUE, 21), rep(rep(FALSE, 21), 20)), nrow = 21) net = as_sfnetwork(pts, connections = adj) plot(net) From ef48fd917c14d950f38084127e2cd2cd865c4bdf Mon Sep 17 00:00:00 2001 From: loreabad6 Date: Sun, 3 Nov 2024 16:22:27 +0100 Subject: [PATCH 173/246] docs: Add data structure schema :books: --- man/figures/data-structure-dark.png | Bin 0 -> 589680 bytes man/figures/data-structure.drawio | 618 ++++++++++++++++++++++++++++ man/figures/data-structure.png | Bin 0 -> 565590 bytes 3 files changed, 618 insertions(+) create mode 100644 man/figures/data-structure-dark.png create mode 100644 man/figures/data-structure.drawio create mode 100644 man/figures/data-structure.png diff --git a/man/figures/data-structure-dark.png b/man/figures/data-structure-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..23ef07dd0021c49daacbff27fb482c556f172f19 GIT binary patch literal 589680 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