This vignette generates synthetic spatial data over
the state of North Carolina and walks through the core
spatialkit workflow:
Everything is self-contained — the boundary comes from the
nc.shp demo shapefile bundled with the sf
package, so no external files are needed.
library(spatialkit)
library(sf)
library(dplyr)
library(ggplot2)
set.seed(42)
We load the North Carolina county boundaries shipped with
sf, dissolve them into a single state outline, and project
to NAD83 / North Carolina (ftUS) (EPSG:2264) for proper
distance-based tessellations:
nc_counties <- st_read(system.file("shape/nc.shp", package = "sf"), quiet = TRUE)
nc_boundary <- nc_counties |>
st_union() |>
st_transform(2264) |>
st_as_sf()
We scatter 300 points inside the state boundary with two predictors and a spatially-varying response:
elevation — gradient increasing west → east, plus
noisepop_density — decays with distance from two fake
“cities” (Charlotte, Raleigh)y — response driven by predictors + a spatial
sine/cosine trendn_points <- 300
# Sample points inside the NC boundary
pts_raw <- st_sample(nc_boundary, size = n_points, type = "random")
pts_coords <- st_coordinates(pts_raw)
x_coords <- pts_coords[, 1]
y_coords <- pts_coords[, 2]
# Predictors
elevation <- scale(x_coords)[, 1] * 500 + rnorm(n_points, 3000, 400)
# Approximate projected coords for Charlotte & Raleigh in EPSG:2264
city1 <- c(1530000, 550000) # Charlotte-ish
city2 <- c(2150000, 750000) # Raleigh-ish
dist_to_city <- pmin(
sqrt((x_coords - city1[1])^2 + (y_coords - city1[2])^2),
sqrt((x_coords - city2[1])^2 + (y_coords - city2[2])^2)
)
pop_density <- exp(-dist_to_city / 400000) * 5000 + rnorm(n_points, 200, 100)
pop_density <- pmax(pop_density, 10)
# Response
y_response <- 50 +
0.01 * elevation +
0.005 * pop_density +
2.0 * sin(x_coords / 300000) * cos(y_coords / 300000) +
rnorm(n_points, 0, 5)
points_sf <- st_sf(
y = y_response,
elevation = elevation,
pop_density = pop_density,
geometry = pts_raw
)
Quick sanity check — points over boundary:
ggplot() +
geom_sf(data = nc_boundary, fill = "grey95", color = "black") +
geom_sf(data = points_sf, aes(color = y), size = 1.2) +
scale_color_viridis_c(name = "Response (y)") +
theme_void() +
ggtitle("Raw observation points — North Carolina")
seeds <- get_voronoi_seeds(
boundary = nc_boundary,
sample_points = points_sf,
method = "kmeans",
n = 40
)
tess_voronoi <- build_tessellation(
points_sf, boundary = nc_boundary,
method = "voronoi", clip = TRUE, quiet = TRUE
)
tess_hex <- build_tessellation(
points_sf, boundary = nc_boundary,
method = "hex", approx_n_cells = 50, clip = TRUE, quiet = TRUE
)
tess_square <- build_tessellation(
points_sf, boundary = nc_boundary,
method = "square", approx_n_cells = 50, clip = TRUE, quiet = TRUE
)
tess_tri <- tryCatch(
build_tessellation(
points_sf, boundary = nc_boundary,
method = "triangles", clip = TRUE, quiet = TRUE
),
error = function(e) {
message("Delaunay skipped: ", conditionMessage(e))
NULL
}
)
cat(sprintf(
"Voronoi: %d | Hex: %d | Square: %d | Triangles: %s\n",
nrow(tess_voronoi$cells),
nrow(tess_hex$cells),
nrow(tess_square$cells),
if (!is.null(tess_tri)) nrow(tess_tri$cells) else "skipped"
))
## Voronoi: 300 | Hex: 49 | Square: 45 | Triangles: 10
For each tessellation we assign observation points to cells, compute
the mean response y per cell, and render a filled
choropleth with a clean look.
#' Assign points → cells, compute mean, and produce a clean choropleth
make_choropleth <- function(tess, boundary, points, fill_var = "y",
palette = "viridis", title = NULL,
legend_title = "Mean Response (y)") {
cells <- tess$cells
# Identify the id column
id_col <- if ("cell_id" %in% names(cells)) "cell_id" else "poly_id"
if (!id_col %in% names(cells)) {
cells$cell_id <- seq_len(nrow(cells))
id_col <- "cell_id"
}
# Assign points to cells and compute cell-level mean
assigned <- assign_features_to_polygons(points, cells, polygon_id_col = id_col)
cell_summary <- assigned |>
st_drop_geometry() |>
group_by(.data[[id_col]]) |>
summarise(
fill_value = mean(.data[[fill_var]], na.rm = TRUE),
n_obs = n(),
.groups = "drop"
)
cells <- left_join(cells, cell_summary, by = id_col)
# Build the choropleth via plot_tessellation_map
plot_tessellation_map(
tessellation_sf = cells,
boundary = boundary,
fill_col = "fill_value",
palette = palette,
tile_alpha = 0.9,
outline_col = "white",
outline_size = 0.3,
boundary_col = "grey20",
boundary_size = 0.8,
legend_title = legend_title,
title = title,
subtitle = sprintf("%d cells | %d observations", nrow(cells), nrow(points))
)
}
make_choropleth(tess_voronoi, nc_boundary, points_sf,
title = "Voronoi Tessellation — Mean Response")
Voronoi choropleth — mean response per cell
make_choropleth(tess_hex, nc_boundary, points_sf,
title = "Hexagonal Grid — Mean Response")
Hex grid choropleth — mean response per cell
make_choropleth(tess_square, nc_boundary, points_sf,
title = "Square Grid — Mean Response")
Square grid choropleth — mean response per cell
make_choropleth(tess_tri, nc_boundary, points_sf,
title = "Delaunay Triangulation — Mean Response")
Delaunay choropleth — mean response per cell
if (requireNamespace("patchwork", quietly = TRUE)) {
library(patchwork)
p1 <- make_choropleth(tess_voronoi, nc_boundary, points_sf,
title = "Voronoi")
p2 <- make_choropleth(tess_hex, nc_boundary, points_sf,
title = "Hex Grid")
p3 <- make_choropleth(tess_square, nc_boundary, points_sf,
title = "Square Grid")
(p1 | p2 | p3) +
plot_annotation(
title = "Tessellation Comparison — Cell-Level Mean Response",
subtitle = sprintf("%d observations, North Carolina", n_points),
theme = theme(
plot.title = element_text(size = 16, face = "bold"),
plot.subtitle = element_text(size = 11, color = "grey40")
)
)
} else {
cat("Install 'patchwork' for the side-by-side panel: install.packages('patchwork')")
}
All tessellations at a glance
Fit a geographically weighted regression:
y ~ elevation + pop_density.
response_var <- "y"
predictor_vars <- c("elevation", "pop_density")
gwr_fit <- tryCatch({
fit_gwr_model(
data_sf = points_sf,
response_var = response_var,
predictor_vars = predictor_vars,
adaptive = TRUE,
kernel = "bisquare"
)
}, error = function(e) {
message("GWR skipped: ", conditionMessage(e))
NULL
})
## Adaptive bandwidth (number of nearest neighbours): 193 AICc value: 1805.596
## Adaptive bandwidth (number of nearest neighbours): 127 AICc value: 1806.03
## Adaptive bandwidth (number of nearest neighbours): 234 AICc value: 1806.232
## Adaptive bandwidth (number of nearest neighbours): 167 AICc value: 1805.02
## Adaptive bandwidth (number of nearest neighbours): 152 AICc value: 1805.208
## Adaptive bandwidth (number of nearest neighbours): 177 AICc value: 1805.013
## Adaptive bandwidth (number of nearest neighbours): 183 AICc value: 1805.186
## Adaptive bandwidth (number of nearest neighbours): 173 AICc value: 1805.065
## Adaptive bandwidth (number of nearest neighbours): 179 AICc value: 1805.174
## Adaptive bandwidth (number of nearest neighbours): 175 AICc value: 1805.042
## Adaptive bandwidth (number of nearest neighbours): 177 AICc value: 1805.013
cat(sprintf("Bandwidth: %.1f | R²: %.3f | RMSE: %.3f\n",
gwr_fit$info$bandwidth,
gwr_fit$metrics$r_squared,
gwr_fit$metrics$rmse))
Map the mean absolute residual onto each tessellation type, producing clean filled maps so you can compare how tessellation geometry aggregates model error.
pts_with_gwr <- points_sf
pts_with_gwr$gwr_fitted <- as.numeric(fitted(gwr_fit))
pts_with_gwr$gwr_residual <- as.numeric(residuals(gwr_fit))
pts_with_gwr$abs_error <- abs(pts_with_gwr$gwr_residual)
tess_list <- list(
list(tess = tess_voronoi, label = "Voronoi"),
list(tess = tess_hex, label = "Hex Grid"),
list(tess = tess_square, label = "Square Grid")
)
for (info in tess_list) {
cells <- info$tess$cells
id_col <- if ("cell_id" %in% names(cells)) "cell_id" else "poly_id"
if (!id_col %in% names(cells)) {
cells$cell_id <- seq_len(nrow(cells))
id_col <- "cell_id"
}
asgn <- assign_features_to_polygons(pts_with_gwr, cells, polygon_id_col = id_col)
cell_err <- asgn |>
st_drop_geometry() |>
group_by(.data[[id_col]]) |>
summarise(mean_abs_error = mean(abs_error, na.rm = TRUE), .groups = "drop")
cells <- left_join(cells, cell_err, by = id_col)
p <- plot_tessellation_map(
tessellation_sf = cells,
boundary = nc_boundary,
fill_col = "mean_abs_error",
palette = "magma",
tile_alpha = 0.9,
outline_col = "white",
outline_size = 0.3,
boundary_col = "grey20",
boundary_size = 0.8,
legend_title = "Mean |Residual|",
title = sprintf("GWR Residuals — %s", info$label),
subtitle = sprintf("Abs. residual aggregated to %d cells", nrow(cells))
)
print(p)
cat("\n\n")
}
cv_results <- tryCatch({
cv_gwr(
data_sf = points_sf,
response_var = response_var,
predictor_vars = predictor_vars,
k = 5,
adaptive = TRUE
)
}, error = function(e) {
message("CV skipped: ", conditionMessage(e))
NULL
})
## Adaptive bandwidth (number of nearest neighbours): 155 AICc value: 1453.163
## Adaptive bandwidth (number of nearest neighbours): 104 AICc value: 1455.129
## Adaptive bandwidth (number of nearest neighbours): 188 AICc value: 1452.97
## Adaptive bandwidth (number of nearest neighbours): 207 AICc value: 1452.754
## Adaptive bandwidth (number of nearest neighbours): 220 AICc value: 1452.48
## Adaptive bandwidth (number of nearest neighbours): 227 AICc value: 1452.542
## Adaptive bandwidth (number of nearest neighbours): 214 AICc value: 1452.598
## Adaptive bandwidth (number of nearest neighbours): 222 AICc value: 1452.461
## Adaptive bandwidth (number of nearest neighbours): 225 AICc value: 1452.429
## Adaptive bandwidth (number of nearest neighbours): 225 AICc value: 1452.429
## Adaptive bandwidth (number of nearest neighbours): 157 AICc value: 1463.314
## Adaptive bandwidth (number of nearest neighbours): 105 AICc value: 1462.364
## Adaptive bandwidth (number of nearest neighbours): 71 AICc value: 1465.435
## Adaptive bandwidth (number of nearest neighbours): 124 AICc value: 1462.743
## Adaptive bandwidth (number of nearest neighbours): 91 AICc value: 1463.106
## Adaptive bandwidth (number of nearest neighbours): 111 AICc value: 1462.68
## Adaptive bandwidth (number of nearest neighbours): 98 AICc value: 1462.531
## Adaptive bandwidth (number of nearest neighbours): 106 AICc value: 1462.447
## Adaptive bandwidth (number of nearest neighbours): 101 AICc value: 1462.379
## Adaptive bandwidth (number of nearest neighbours): 104 AICc value: 1462.363
## Adaptive bandwidth (number of nearest neighbours): 107 AICc value: 1462.543
## Adaptive bandwidth (number of nearest neighbours): 105 AICc value: 1462.364
## Adaptive bandwidth (number of nearest neighbours): 106 AICc value: 1462.447
## Adaptive bandwidth (number of nearest neighbours): 105 AICc value: 1462.364
## Adaptive bandwidth (number of nearest neighbours): 105 AICc value: 1462.364
## Adaptive bandwidth (number of nearest neighbours): 104 AICc value: 1462.363
## Adaptive bandwidth (number of nearest neighbours): 155 AICc value: 1446.256
## Adaptive bandwidth (number of nearest neighbours): 104 AICc value: 1450.545
## Adaptive bandwidth (number of nearest neighbours): 188 AICc value: 1444.664
## Adaptive bandwidth (number of nearest neighbours): 207 AICc value: 1444.455
## Adaptive bandwidth (number of nearest neighbours): 220 AICc value: 1444.289
## Adaptive bandwidth (number of nearest neighbours): 227 AICc value: 1444.225
## Adaptive bandwidth (number of nearest neighbours): 232 AICc value: 1444.144
## Adaptive bandwidth (number of nearest neighbours): 235 AICc value: 1444.098
## Adaptive bandwidth (number of nearest neighbours): 237 AICc value: 1444.069
## Adaptive bandwidth (number of nearest neighbours): 238 AICc value: 1444.062
## Adaptive bandwidth (number of nearest neighbours): 239 AICc value: 1444.055
## Adaptive bandwidth (number of nearest neighbours): 239 AICc value: 1444.055
## Adaptive bandwidth (number of nearest neighbours): 154 AICc value: 1413.537
## Adaptive bandwidth (number of nearest neighbours): 103 AICc value: 1416.397
## Adaptive bandwidth (number of nearest neighbours): 186 AICc value: 1413.782
## Adaptive bandwidth (number of nearest neighbours): 134 AICc value: 1412.956
## Adaptive bandwidth (number of nearest neighbours): 122 AICc value: 1414.002
## Adaptive bandwidth (number of nearest neighbours): 142 AICc value: 1413.02
## Adaptive bandwidth (number of nearest neighbours): 129 AICc value: 1413.528
## Adaptive bandwidth (number of nearest neighbours): 136 AICc value: 1413.033
## Adaptive bandwidth (number of nearest neighbours): 131 AICc value: 1413.269
## Adaptive bandwidth (number of nearest neighbours): 134 AICc value: 1412.956
## Adaptive bandwidth (number of nearest neighbours): 156 AICc value: 1458.753
## Adaptive bandwidth (number of nearest neighbours): 104 AICc value: 1460.506
## Adaptive bandwidth (number of nearest neighbours): 188 AICc value: 1459.461
## Adaptive bandwidth (number of nearest neighbours): 135 AICc value: 1459.209
## Adaptive bandwidth (number of nearest neighbours): 167 AICc value: 1458.956
## Adaptive bandwidth (number of nearest neighbours): 147 AICc value: 1458.678
## Adaptive bandwidth (number of nearest neighbours): 143 AICc value: 1458.782
## Adaptive bandwidth (number of nearest neighbours): 151 AICc value: 1458.647
## Adaptive bandwidth (number of nearest neighbours): 152 AICc value: 1458.62
## Adaptive bandwidth (number of nearest neighbours): 154 AICc value: 1458.726
## Adaptive bandwidth (number of nearest neighbours): 152 AICc value: 1458.62
cat(sprintf("CV RMSE: %.3f | CV R²: %.3f | CV MAE: %.3f\n",
cv_results$summary$rmse,
cv_results$summary$r_squared,
cv_results$summary$mae))
| Tessellation | Cells | Notes |
|---|---|---|
| Voronoi | 300 | Adapts to point density via k-means seeds |
| Hex grid | 49 | Uniform hexagons, good for regular sampling |
| Square grid | 45 | Simplest regular grid |
| Delaunay | 10 | One triangle per point triplet, finest resolution |
The choropleth maps show how each tessellation aggregates the response variable spatially across North Carolina. The GWR residual maps let you compare how each tessellation captures model error — hexes and squares give a uniform view, while Voronoi cells highlight where the observation network is dense or sparse.
## R version 4.6.1 (2026-06-24)
## Platform: aarch64-apple-darwin23
## Running under: macOS Tahoe 26.5.2
##
## Matrix products: default
## BLAS: /Library/Frameworks/R.framework/Versions/4.6/Resources/lib/libRblas.0.dylib
## LAPACK: /Library/Frameworks/R.framework/Versions/4.6/Resources/lib/libRlapack.dylib; LAPACK version 3.12.1
##
## locale:
## [1] C/en_US.UTF-8/en_US.UTF-8/C/en_US.UTF-8/en_US.UTF-8
##
## time zone: America/Chicago
## tzcode source: internal
##
## attached base packages:
## [1] stats graphics grDevices utils datasets methods base
##
## other attached packages:
## [1] patchwork_1.3.2 ggplot2_4.0.3 dplyr_1.2.1 sf_1.1-2
## [5] spatialkit_1.0.0
##
## loaded via a namespace (and not attached):
## [1] gtable_0.3.6 xfun_0.59 bslib_0.11.0
## [4] lattice_0.22-9 LearnBayes_2.15.2 vctrs_0.7.3
## [7] tools_4.6.1 generics_0.1.4 sandwich_3.1-2
## [10] spdep_1.4-2 parallel_4.6.1 tibble_3.3.1
## [13] proxy_0.4-29 spacetime_1.3-3 DEoptimR_1.2-0
## [16] xts_0.14.2 pkgconfig_2.0.3 Matrix_1.7-6
## [19] KernSmooth_2.23-26 data.table_1.18.4 RColorBrewer_1.1-3
## [22] S7_0.2.2 lifecycle_1.0.5 compiler_4.6.1
## [25] farver_2.1.2 deldir_2.0-4 FNN_1.1.4.1
## [28] codetools_0.2-20 marginaleffects_0.32.0 htmltools_0.5.9
## [31] class_7.3-23 sass_0.4.10 yaml_2.3.12
## [34] pillar_1.11.1 jquerylib_0.1.4 MASS_7.3-65
## [37] classInt_0.4-11 cachem_1.1.0 wk_0.9.5
## [40] spatialreg_1.4-3 multcomp_1.4-31 boot_1.3-32
## [43] nlme_3.1-169 robustbase_0.99-7 tidyselect_1.2.1
## [46] digest_0.6.39 mvtnorm_1.4-2 splines_4.6.1
## [49] labeling_0.4.3 GWmodel_2.4-1 fastmap_1.2.0
## [52] grid_4.6.1 cli_3.6.6 logger_0.4.2
## [55] magrittr_2.0.5 survival_3.8-6 TH.data_1.1-5
## [58] e1071_1.7-17 withr_3.0.3 backports_1.5.1
## [61] scales_1.4.0 sp_2.2-3 spData_2.3.5
## [64] rmarkdown_2.31 otel_0.2.0 zoo_1.8-15
## [67] coda_0.19-4.1 evaluate_1.0.5 knitr_1.51
## [70] viridisLite_0.4.3 s2_1.1.11 rlang_1.3.0
## [73] Rcpp_1.1.1-1.1 glue_1.8.1 DBI_1.3.0
## [76] rstudioapi_0.19.0 jsonlite_2.0.0 R6_2.6.1
## [79] intervals_0.15.5 units_1.0-1