freestiler creates PMTiles vector tilesets from R. Give it an sf object, a file on disk, or a DuckDB SQL query, and it writes a single .pmtiles file you can serve from anywhere. The tiling engine is written in Rust and runs in-process, so there's nothing else to install.
This is an R-only fork of walkerke/freestiler. It tracks upstream's R package and API, with one deliberate difference: native builds always statically compile both Rust backends into the package:
duckdb-rs, bundled) — powers freestile_query() and the engine = "duckdb" file reader, enabling out-of-R-memory, file-to-file and SQL-to-tiles workflows..parquet tiling via freestile_file().Upstream ships these off by default on Windows; here they are always on, including on Windows (built with the Rtools45 GNU/UCRT toolchain). Because DuckDB is statically linked, the result is a single self-contained DLL with no external runtime dependencies — once installed, library(freestiler) gives you the full feature set with no PATH or runtime setup. Python packaging is intentionally not part of this fork.
This fork is Windows-focused and always compiles the Rust GeoParquet and DuckDB backends in. Install it from GitHub with pak:
# install.packages("pak")
pak::pak("jimbrig/freestiler")
From a local clone of this repo:
pak::local_install()
Once an r-universe binary has built, you can install it without a Rust toolchain:
install.packages(
"freestiler",
repos = c("https://jimbrig.r-universe.dev", "https://cloud.r-project.org")
)
The upstream package (Rust DuckDB off by default on Windows) is on CRAN and walkerke.r-universe.dev.
library(freestiler)
freestiler:::.has_rust_duckdb()
freestiler:::.has_rust_geoparquet()
install.R and build.R are convenience scripts for source builds. They locate
Rtools via pkgbuild, isolate the build from any personal ~/.R/Makevars, and
verify both Rust backends are compiled in:
source("install.R") — install into your normal librarysource("build.R") — install into an isolated build/library/ for testingtools/feature-smoke.R runs the same feature checks used in CI.
The main function is freestile(). Let's tile the North Carolina counties dataset that ships with sf:
library(sf)
library(freestiler)
nc <- st_read(system.file("shape/nc.shp", package = "sf"))
freestile(nc, "nc_counties.pmtiles", layer_name = "counties")
That's useful for checking your installation, but the same API handles much bigger data. Here we tile all 242,000 US block groups from tigris:
library(tigris)
options(tigris_use_cache = TRUE)
bgs <- block_groups(cb = TRUE)
freestile(
bgs,
"us_bgs.pmtiles",
layer_name = "bgs",
min_zoom = 4,
max_zoom = 12
)
The quickest way to view a tileset is view_tiles(), which starts a local server and opens an interactive map:
view_tiles("us_bgs.pmtiles")
For more control, use serve_tiles() to start a local server and build your map with mapgl:
library(mapgl)
serve_tiles("us_bgs.pmtiles")
maplibre(hash = TRUE) |>
add_pmtiles_source(
id = "bgs-src",
url = "http://localhost:8080/us_bgs.pmtiles",
promote_id = "GEOID"
) |>
add_fill_layer(
id = "bgs-fill",
source = "bgs-src",
source_layer = "bgs",
fill_color = "navy",
fill_opacity = 0.5,
hover_options = list(
fill_color = "#ffffcc",
fill_opacity = 0.9
)
)
The built-in server handles CORS and range requests automatically. For tilesets larger than ~1 GB, use an external server like npx http-server /path --cors -c-1 for better performance. See the Mapping with mapgl article for a full walkthrough.
If your data lives in DuckDB, freestile_query() lets you filter, join, and transform with SQL before tiling:
freestile_query(
query = "SELECT * FROM read_parquet('blocks.parquet') WHERE state = 'NC'",
output = "nc_blocks.pmtiles",
layer_name = "blocks"
)
For very large point datasets, the streaming pipeline avoids loading the full result into memory. On a recent run, freestile_query() streamed 146 million US job points from DuckDB into a 2.3 GB PMTiles archive in about 12 minutes:
freestile_query(
query = "SELECT naics, state, ST_Point(lon, lat) AS geometry FROM jobs_dots",
output = "us_jobs_dots.pmtiles",
db_path = db_path,
layer_name = "jobs",
tile_format = "mvt",
min_zoom = 4,
max_zoom = 14,
base_zoom = 14,
drop_rate = 2.5,
source_crs = "EPSG:4326",
streaming = "always",
overwrite = TRUE
)
You can tile spatial files without loading them into R first:
# GeoParquet
freestile_file("census_blocks.parquet", "blocks.pmtiles")
# GeoPackage, Shapefile, or other formats via DuckDB
freestile_file("counties.gpkg", "counties.pmtiles", engine = "duckdb")
For GeoParquet, the direct file path is powered by the Rust geoparquet
backend, which this fork always compiles in. freestile_file() reads
WKB-based GeoParquet directly without materializing the data in the R session
first.
For dense point datasets, freestile_h3() aggregates points into H3 hexagons at zoom-appropriate resolutions: low zooms show coarse hexes summarizing whole regions, intermediate zooms show progressively finer hexes, and base_zoom and above reveal the underlying points. Aggregation rules are user-defined SQL (COUNT(*), SUM(pop), AVG(value), ...).
freestile_h3(
pts,
"wind.pmtiles",
agg = c(n = "COUNT(*)", total_mw = "SUM(capacity_mw)"),
min_zoom = 2, max_zoom = 12, base_zoom = 10
)
view_h3_tiles("wind.pmtiles", agg_column = "n")
Pass fade = TRUE to cross-fade between resolutions instead of swapping cleanly. Requires DuckDB and its H3 community extension; see the Hexagonal binning with H3 article.
pts <- st_centroid(nc)
freestile(
list(
counties = freestile_layer(nc, min_zoom = 0, max_zoom = 10),
centroids = freestile_layer(pts, min_zoom = 6, max_zoom = 14)
),
"nc_layers.pmtiles"
)
freestiler defaults to Mapbox Vector Tiles (MVT), the widely-supported protobuf format that works with both MapLibre GL JS and Mapbox GL JS. The experimental MapLibre Tiles (MLT) format is also available via tile_format = "mlt" and can produce smaller files for polygon and line data.