Visualisation
Interactive maps in R with mapgl
Use real Greater Sydney SA2 polygons, ABS population data, and NSW school locations to build interactive maps for a DATA1X01 Project 2 Quarto report, from your first map to advanced controls.
I discovered mapgl while working with geospatial data at AstraZeneca, where I was building an in-house solution for sales territory and workforce planning for a new product and team launch. Being able to put data on a map, explore it interactively, and explain what the geography meant made it much easier to connect an analysis to a practical decision.
This tutorial brings that approach into DATA1X01 Project 2. You will learn how to turn a table or spatial file into an interactive map, embed it in your Quarto report, and explain the pattern it shows. You do not need previous experience with geographic information systems.
The maps below are actual outputs of the R code, rather than screenshots. Drag to pan, use the + / − buttons to zoom, and try the popups and other controls. Mouse-wheel zoom is disabled in most examples so you can scroll through the lesson comfortably.
R runs when Quarto renders this page. The published maps run JavaScript in your browser. To change the R code and recompute an example, download the source and run it in RStudio. A static website does not provide a live R session. These examples use MapLibre and OpenFreeMap without an API key; the basemap still needs an internet connection.
Our real Sydney case study
We will use Greater Sydney, as defined by the ABS Greater Capital City Statistical Area (1GSYD). This includes the Central Coast and Blue Mountains; it is broader than the City of Sydney council area. The polygon maps use 373 real Statistical Areas Level 2 (SA2s). An SA2 is a statistical area, not necessarily a suburb or a school catchment.
| Dataset | What we use | Source and reference period |
|---|---|---|
| SA2 boundaries | Official codes, names, polygons, and area in km² | ABS ASGS Edition 3, 2021 geography |
| Resident population | Annual population estimates, 2021–2025 | ABS Regional population, 2024–25; population at 30 June each year |
| Government education locations | Coordinates, school type, and latest reported FTE enrolments | NSW Department of Education master dataset, extracted 2 October 2026 |
| Population grid | Modelled population density in 1 km cells | ABS Australian population grid 2025, cropped to Greater Sydney |
The school snapshot includes environmental education centres and other government education sites as well as primary and secondary schools. We retain the provider’s types. It covers government education, rather than all schools. The enrolment field is latest reported FTE, not a count measured on the extraction date; the source does not supply an enrolment-year column. Population estimates for 2025 are preliminary. These periods and populations cannot be treated as interchangeable.
The prepared CSV, GeoJSON, GeoPackage, and GeoTIFF files are bundled with the source. The data guide records attribution, original URLs, transformations, exclusions, and checksums. None of the maps uses simulated observations.
You can work through the core sections first, then choose an extension that suits your data. An effective project might need only one well-designed map and a supporting chart. This is an optional teaching resource: follow the assessment instructions for the required data, methods, and submission format.
- Set up R and Quarto
Use R 4.1 or later, RStudio, and the Quarto CLI.
install.packages(c(
"mapgl", "sf", "dplyr", "readr", "htmltools", "htmlwidgets",
"terra", "viridisLite", "knitr", "rmarkdown", "jsonlite"
))Now load the packages in the report. Unlike installation, this step must run whenever you render it.
library(mapgl)
library(sf)Linking to GEOS 3.12.1, GDAL 3.8.4, PROJ 9.4.0; sf_use_s2() is TRUE
library(dplyr)
Attaching package: 'dplyr'
The following objects are masked from 'package:stats':
filter, lag
The following objects are masked from 'package:base':
intersect, setdiff, setequal, union
library(readr)
# This tutorial uses the mapgl 0.5.2 API.
stopifnot(packageVersion("mapgl") >= "0.5.2")mapgl creates the maps; sf stores vector geometries and performs spatial operations; dplyr prepares tables; readr imports CSV files. Later, terra handles raster data. Package names and R functions are case-sensitive.
The mapgl documentation describes two interfaces: maplibre() and mapboxgl(). We will use maplibre() throughout. Mapbox requires an access token and its own service terms; it is an optional alternative, rather than a requirement for this lesson.
- Make your first map
# Start with one actual record from the bundled NSW snapshot.
first_school <- read_csv("data/sydney-public-schools.csv",
show_col_types = FALSE) |>
filter(name == "Glebe Public School")
stopifnot(nrow(first_school) == 1)
first_location <- c(first_school$longitude, first_school$latitude)
first_map <- maplibre(
style = openfreemap_style("positron"),
center = first_location,
zoom = 12,
projection = "mercator",
height = 420,
scrollZoom = FALSE
) |>
add_markers(
first_location,
color = "#0d706f",
popup = "Glebe Public School · NSW Department of Education"
) |>
add_navigation_control(position = "top-right") |>
add_fullscreen_control(position = "top-right") |>
add_scale_control(position = "bottom-left", unit = "metric")
first_mapTry it: click the marker, zoom in, then use the fullscreen control.
The final line displays the object. If you finish a chunk with first_map <- ..., R stores the map but does not automatically display it.
| Argument | What it changes |
|---|---|
style |
The basemap: here, OpenFreeMap’s restrained Positron style. |
center |
The starting location, in longitude, latitude order. |
zoom |
The initial level of detail. Larger values zoom in. |
projection |
The display projection. We use Mercator for local examples. |
height |
The map’s height in pixels. Set it explicitly in a report. |
scrollZoom |
Whether scrolling the mouse wheel zooms the map. |
A marker is convenient for one or a few locations. For a dataset of points, use a circle layer, which supports styling, filtering, and clustering. See the maplibre() reference and available OpenFreeMap styles.
- Put geospatial data into the right form
You need either coordinates, geometries, or an identifier that can be joined to boundaries. A place name on its own is not a geometry.
| Your starting data | Preparation in R | Typical map |
|---|---|---|
| CSV with longitude and latitude | read_csv(), then st_as_sf() |
Circles, clusters, or a heatmap |
| GeoJSON or GeoPackage | st_read() |
Points, lines, or filled polygons |
| Shapefile | Keep .shp, .shx, .dbf, and .prj together; use st_read() |
Depends on the geometry |
| Table of statistics by area | Join to an appropriate boundary file using an area code | Choropleth |
| GeoTIFF or another raster | terra::rast() |
Raster overlay |
| Addresses or place names | Obtain verified coordinates or join to appropriate boundaries first | Depends on the question |
Geocoding is a separate step, with possible ambiguity, service limits, and costs. “Sydney” could mean a city, an administrative area, or a suburb. Document how you resolved it. A map cannot supply geographic precision that your source data does not contain.
Convert a CSV into spatial points
The bundled CSV contains 1,033 real government education locations in Greater Sydney. It is a spatial subset of the NSW master dataset, with only the fields needed for this lesson. A public school location does not identify where its students live. All relative paths in this lesson assume that you extracted the complete source bundle and opened its folder as your project.
schools_raw <- read_csv("data/sydney-public-schools.csv",
col_types = cols(school_code = col_character()))
schools_raw |>
select(name, school_type, longitude, latitude, enrolment_fte) |>
head(4) |>
knitr::kable(caption = "A few real records from the NSW school snapshot.")| name | school_type | longitude | latitude | enrolment_fte |
|---|---|---|---|---|
| Abbotsford Public School | Primary School | 151.1312 | -33.85273 | 368 |
| Annandale Public School | Primary School | 151.1696 | -33.88578 | 363 |
| Annandale North Public School | Primary School | 151.1718 | -33.87790 | 359 |
| Appin Public School | Primary School | 150.7877 | -34.19842 | 337 |
Validate coordinates before converting them. This retains an explicit record of excluded rows rather than silently losing them.
coordinate_ok <- with(
schools_raw,
is.finite(longitude) & is.finite(latitude) &
longitude >= -180 & longitude <= 180 &
latitude >= -90 & latitude <= 90
)
rejected_schools <- schools_raw[!coordinate_ok, ]
schools_clean <- schools_raw[coordinate_ok, ]
stopifnot(nrow(schools_clean) > 0, !anyDuplicated(schools_clean$school_code))
schools <- st_as_sf(
schools_clean,
coords = c("longitude", "latitude"),
crs = 4326,
remove = FALSE
)
data.frame(
rows_imported = nrow(schools_raw),
rows_excluded = nrow(rejected_schools),
rows_mapped = nrow(schools)
) |>
knitr::kable()| rows_imported | rows_excluded | rows_mapped |
|---|---|---|
| 1033 | 0 | 1033 |
sf adds a geometry column to the table. coords identifies the coordinate columns, and crs = 4326 declares that these are WGS84 longitude and latitude in degrees. Use that declaration only when it matches the source metadata. remove = FALSE keeps the coordinate columns for inspection.
Range checks catch obvious errors, but cannot prove that a valid coordinate is the correct one. Check the map’s extent against your study area, look for swapped columns, and investigate suspicious points. The NSW dictionary describes latitude and longitude but does not specify their geodetic datum; this teaching example treats them as WGS84 web coordinates. Do not use that assumption for survey accuracy or legal boundary decisions.
Understand coordinate reference systems
A coordinate reference system (CRS) describes what your coordinate numbers mean. Geographic coordinates use angular units; projected coordinates often use metres. Inspect an imported file’s CRS and geometry type:
st_crs(schools)$input[1] "EPSG:4326"
unique(st_geometry_type(schools))[1] POINT
18 Levels: GEOMETRY POINT LINESTRING POLYGON MULTIPOINT ... TRIANGLE
st_bbox(schools) xmin ymin xmax ymax
150.23258 -34.32048 151.58244 -33.13547
If a spatial file has a known projected CRS, convert its coordinates with st_transform(x, 4326) before passing it to a web map. Assigning a CRS and transforming coordinates are different operations. st_set_crs() changes the declared meaning of the existing numbers; it does not recalculate them. If the CRS is missing, find the original metadata before assigning one.
For the following import patterns, replace the paths with your own existing files. These are intentionally unexecuted templates.
areas <- st_read("data/my-boundaries.geojson", quiet = TRUE)
stopifnot(!is.na(st_crs(areas)))
areas <- st_transform(areas, 4326)
# A GeoPackage can contain several layers.
st_layers("data/my-geography.gpkg")
roads <- st_read("data/my-geography.gpkg", layer = "roads", quiet = TRUE)
# For projected CSV coordinates, use the CRS stated by the data provider.
projected_points <- st_as_sf(my_table, coords = c("easting", "northing"),
crs = verified_source_crs)
web_points <- st_transform(projected_points, 4326)The sf import reference covers supported file formats; the st_transform() reference explains transformations. For distance, buffers, or area calculations, choose an appropriate analytical CRS or understand sf’s geographic calculation methods. Do not interpret degrees as metres.
- Add points, tooltips, and popups
A map contains sources and layers. A source holds the data. A layer tells the browser how to draw those data. Here, providing an sf object directly to source lets mapgl register the source for us.
# Escape text before placing it inside popup HTML.
schools <- schools |>
mutate(popup_html = paste0(
"<strong>", htmltools::htmlEscape(name), "</strong><br>",
htmltools::htmlEscape(school_type), "<br>",
"Latest reported FTE enrolment: ",
if_else(is.na(enrolment_fte), "Not reported", as.character(enrolment_fte))
))
points_map <- maplibre(
style = openfreemap_style("positron"),
bounds = schools,
fitBoundsOptions = list(padding = 40),
projection = "mercator",
height = 440,
scrollZoom = FALSE
) |>
add_circle_layer(
id = "school-points",
source = schools,
circle_color = "#0d706f",
circle_radius = 7,
circle_opacity = 0.85,
circle_stroke_color = "#ffffff",
circle_stroke_width = 1.5,
tooltip = "name",
popup = "popup_html"
) |>
add_navigation_control()
points_mapTry it: hover over a school for its name, then click for its school type and enrolment figure. Source: NSW Department of Education, snapshot 2 October 2026.
bounds = schools fits the starting view to the data. id is a unique layer identifier, not a column name. tooltip and popup identify columns used for hover and click content. Popup columns can contain HTML, so escape text imported from other sources before adding your own markup.
fitBoundsOptions = list(padding = 40) leaves room around the fitted data so circles at the edges are fully visible. It is a MapLibre option passed through by maplibre().
Read the sources and layers overview and add_circle_layer() reference for more options.
A quick map for exploration
When you mainly want to inspect a spatial dataset, maplibre_view() chooses a layer and colour scale for you:
maplibre_view(
filter(schools, !is.na(enrolment_fte)),
column = "enrolment_fte",
palette = viridisLite::viridis,
style = openfreemap_style("positron"),
projection = "mercator",
height = 420,
scrollZoom = FALSE
) |>
add_navigation_control()This quick view excludes the 29 records without reported enrolments; it does not replace them with zero. This is useful during exploration. For your final report, control the scale, legend title, units, and missing-value treatment deliberately. See the maplibre_view() reference.
- Map categories and quantities
Styling expressions connect an attribute to an appearance. A constant gives every feature the same style; an expression calculates a style from a feature or map property.
| Helper | Use it for | Example |
|---|---|---|
get_column() |
A prepared attribute | A radius calculated in R |
match_expr() |
Named categories | School group colours |
interpolate() |
Smooth numeric variation | Population density colours |
step_expr() |
Fixed thresholds | Density bands |
Category colours and proportional circles
We group primary and infants schools together, retain secondary schools separately, and put the provider’s remaining categories in an explicitly labelled group. The original school type remains in the popup. For proportional circles, area should represent enrolment, so radius is proportional to the square root of FTE.
school_groups <- c("Primary / infants", "Secondary", "Other government sites")
school_colours <- c("#0072B2", "#D55E00", "#009E73")
schools <- schools |>
mutate(school_group = case_when(
school_type %in% c("Primary School", "Infants School") ~ school_groups[1],
school_type == "Secondary School" ~ school_groups[2],
TRUE ~ school_groups[3]
))
# Missing enrolment is not zero. Keep those locations in the basic map,
# but account for their exclusion from this quantitative encoding.
schools_known <- schools |> filter(!is.na(enrolment_fte))
stopifnot(all(is.finite(schools_known$enrolment_fte)),
all(schools_known$enrolment_fte >= 0))
schools_known <- schools_known |>
mutate(radius_px = 6 * sqrt(enrolment_fte / 500))
data.frame(all_locations = nrow(schools),
missing_enrolment = sum(is.na(schools$enrolment_fte)),
locations_with_enrolment = nrow(schools_known)) |>
knitr::kable()| all_locations | missing_enrolment | locations_with_enrolment |
|---|---|---|
| 1033 | 29 | 1004 |
styled_map <- maplibre(
style = openfreemap_style("positron"), bounds = schools,
fitBoundsOptions = list(padding = 40), projection = "mercator",
height = 500, scrollZoom = FALSE
) |>
add_circle_layer(
id = "styled-schools", source = schools_known,
circle_color = match_expr("school_group", values = school_groups,
stops = school_colours, default = "#999999"),
circle_radius = get_column("radius_px"), circle_opacity = 0.8,
circle_stroke_color = "white", circle_stroke_width = 1,
tooltip = "name", popup = "popup_html"
) |>
add_categorical_legend(
legend_title = "School group · click to filter",
values = school_groups, colors = school_colours,
patch_shape = "circle", position = "top-left",
layer_id = "styled-schools", interactive = TRUE,
filter_column = "school_group"
) |>
add_categorical_legend(
legend_title = "Latest reported FTE · circle area",
values = c("500", "1,000", "2,000"), colors = rep("#526478", 3),
patch_shape = "circle", sizes = 12 * sqrt(c(500, 1000, 2000) / 500),
position = "bottom-left", add = TRUE
) |>
add_navigation_control()
styled_mapTry it: click a group in the top legend, then inspect a popup. The map includes 1,004 locations with reported FTE; 29 records with missing FTE are excluded from this view. Source: NSW Department of Education.
A location with 2,000 FTE has twice the radius and four times the area of a location with 500 FTE. Legend sizes are diameters. A true zero has zero radius here; if you must show zeros, add a separate symbol with a clear key. FTE describes enrolment, not building capacity, local child population, or students’ home locations.
The second legend uses add = TRUE to supplement the first. Clicking a legend changes browser visibility; it does not change the R data or recompute your analysis. See match_expr() and map legends.
- Combine real SA2 polygons, lines, and points
Use fill layers for areas, line layers for outlines or paths, and circle layers for points. We will draw actual SA2 polygons, derive line geometries from their boundaries, and overlay the school locations. These lines represent boundaries; they do not imply travel routes.
# Small display copy: simplified by 25 metres in a projected CRS.
sa2 <- st_read("data/greater-sydney-sa2-display.geojson", quiet = TRUE) |>
st_transform(4326)
stopifnot(!anyDuplicated(sa2$sa2_code),
!any(st_is_empty(sa2)), all(st_is_valid(sa2)))
sa2_lines <- st_boundary(sa2)Registering a source once lets several layers reuse it. Add fills first, then lines and points so the points remain visible.
layered_map <- maplibre(
style = openfreemap_style("positron"), bounds = sa2,
fitBoundsOptions = list(padding = 40), projection = "mercator",
height = 480, scrollZoom = FALSE
) |>
add_source(id = "sa2-data", data = sa2) |>
add_fill_layer(
id = "sa2-fill", source = "sa2-data",
fill_color = "#56B4E9", fill_opacity = 0.18, popup = "sa2_name"
) |>
add_line_layer(
id = "sa2-outlines", source = sa2_lines,
line_color = "#526478",
line_width = interpolate(property = "zoom", values = c(7, 13),
stops = c(0.4, 1.5))
) |>
add_circle_layer(
id = "school-overlay", source = schools,
circle_color = "#0072B2", circle_radius = 4,
circle_stroke_color = "white", circle_stroke_width = 0.6,
popup = "popup_html", tooltip = "name"
) |>
add_layers_control(
layers = list("SA2 polygons" = "sa2-fill",
"SA2 boundary lines" = "sa2-outlines",
"Government education locations" = "school-overlay"),
position = "top-left", collapsible = FALSE
) |>
add_navigation_control()
layered_mapTry it: switch the school points and boundary lines off independently. Zoom in to see the SA2 detail. Sources: ABS ASGS 2021 and NSW Department of Education.
source = "sa2-data" names a source ID. A layer control uses layer IDs. Here the layers are independent so you can inspect fills, outlines, and points separately. To switch fill and outline together, group their IDs in one vector. See add_source() and add_layers_control().
- Join population statistics to SA2 boundaries
Population is supplied as a table, while geometry is supplied separately. Join them on the nine-character SA2 code, checking uniqueness and unmatched keys in both directions. Keep identifiers as character values; area names can change or be spelled differently.
population <- read_csv("data/sydney-sa2-population.csv",
col_types = cols(sa2_code = col_character()))
stopifnot(!anyNA(sa2$sa2_code), !anyNA(population$sa2_code),
!anyDuplicated(sa2$sa2_code), !anyDuplicated(population$sa2_code))
boundaries_without_data <- anti_join(st_drop_geometry(sa2), population,
by = "sa2_code")
data_without_boundaries <- anti_join(population, st_drop_geometry(sa2),
by = "sa2_code")
stopifnot(nrow(boundaries_without_data) == 0,
nrow(data_without_boundaries) == 0)
sa2_data <- left_join(sa2, select(population, -sa2_name), by = "sa2_code")
stopifnot(nrow(sa2_data) == nrow(sa2), all(sa2_data$area_km2 > 0),
all(is.finite(sa2_data$population_2025)),
all(sa2_data$population_2025 >= 0))
sa2_data <- sa2_data |>
mutate(
density_2021 = population_2021 / area_km2,
density_2025 = population_2025 / area_km2,
popup_html = paste0(
"<strong>", htmltools::htmlEscape(sa2_name), "</strong><br>",
"SA2: ", sa2_code, "<br>Population, June 2025: ",
format(population_2025, big.mark = ",", trim = TRUE),
"<br>Area: ", sprintf("%.2f km²", area_km2),
"<br>Density: ", sprintf("%.0f residents/km²", density_2025)
)
)
sa2_data |>
st_drop_geometry() |> arrange(desc(density_2025)) |>
select(sa2_name, population_2025, area_km2, density_2025) |> head(6) |>
knitr::kable(digits = c(NA, 0, 4, 0),
caption = "The six highest-density Sydney SA2s in June 2025.")| sa2_name | population_2025 | area_km2 | density_2025 |
|---|---|---|---|
| Sydney (South) - Haymarket | 24758 | 1.0810 | 22903 |
| Chippendale | 9505 | 0.4642 | 20476 |
| Ultimo | 10534 | 0.5588 | 18851 |
| Zetland | 14991 | 0.8047 | 18629 |
| Wolli Creek | 12585 | 0.6795 | 18521 |
| Waterloo | 18631 | 1.1339 | 16431 |
The boundary vintage is ASGS 2021, matching the geography used for these population estimates. Using a newer boundary file just because it is newer can break comparability. The bundled snapshot contains population values for all 373 polygons; unmatched keys and missing measurements are still separate problems that you must check in your own data.
Why map density rather than population count?
A large SA2 can contain many people spread over a large area. Population density is population / area_km2, measured in residents per square kilometre. It answers a question about geographic concentration. A count answers a question about how many residents an area contains. Neither is a measure of school access or capacity.
We use the official ABS area_albers_sqkm value, renamed area_km2. We do not recompute area from the simplified display polygons. The denominator includes the area represented by the ABS boundary definition; density does not mean that population is evenly spread inside the polygon.
- Build a choropleth of population density
A choropleth shades areas according to a statistic. A sequential colour scale suits a non-negative density. A diverging scale can suit change around zero. Explain the units and the limits of your colour scale.
A continuous colour scale
density_limits <- c(0, 5000, 25000)
density_colours <- c("#f7fbff", "#6baed6", "#08306b")
missing_colour <- "#bdbdbd"
stopifnot(max(sa2_data$density_2025) <= max(density_limits))
density_map <- maplibre(
style = openfreemap_style("positron"), bounds = sa2_data,
fitBoundsOptions = list(padding = 40), projection = "mercator",
height = 480, scrollZoom = FALSE
) |>
add_source(id = "density-data", data = sa2_data) |>
add_fill_layer(
id = "density-fill", source = "density-data",
fill_color = interpolate("density_2025", values = density_limits,
stops = density_colours, na_color = missing_colour),
fill_opacity = 0.85, popup = "popup_html", tooltip = "sa2_name",
hover_options = list(fill_opacity = 1)
) |>
add_line_layer(
id = "density-outline", source = "density-data",
line_color = "#526478", line_width = 0.5
) |>
add_continuous_legend(
legend_title = "June 2025 · residents per km²",
values = density_limits, colors = density_colours,
position = "bottom-left"
) |>
add_navigation_control()
density_mapTry it: zoom towards central Sydney and click an SA2. Its popup reports the real population, official area, and calculated density. Source: ABS Regional population 2024–25 and ASGS 2021.
interpolate() blends between stops and holds endpoint colours beyond the specified limits. The 0–25,000 scale covers this snapshot’s densities and will also be used for comparisons. The 5,000 stop is a colour anchor, not a class boundary. If your data exceed the upper limit, extend the scale or disclose that values saturate. The missing-value colour is specified for future adaptation; this snapshot has no missing density values, so it has no missing-data legend key. See interpolate().
Fixed bands and an accurate legend
band_colours <- c("#eff3ff", "#bdd7e7", "#6baed6", "#2171b5", "#08306b")
band_map <- maplibre(
style = openfreemap_style("positron"), bounds = sa2_data,
fitBoundsOptions = list(padding = 40), projection = "mercator",
height = 480, scrollZoom = FALSE
) |>
add_fill_layer(
id = "density-bands", source = sa2_data,
fill_color = step_expr("density_2025", base = band_colours[1],
values = c(500, 2000, 5000, 8000), stops = band_colours[-1],
na_color = missing_colour),
fill_opacity = 0.85, fill_outline_color = "#526478",
popup = "popup_html"
) |>
add_categorical_legend(
legend_title = "June 2025 · residents per km²",
values = c("0 to <500", "500 to <2,000", "2,000 to <5,000",
"5,000 to <8,000", "8,000 or more"),
colors = band_colours, position = "top-left"
) |>
add_navigation_control()
band_mapAt exactly 500 the second colour begins; at 2,000 the third begins. Fixed thresholds discard within-band differences. Automatic helpers such as step_quantile() or step_jenks() can aid exploration, but separately recalculating breaks for each year makes identical colours represent different values. See step_expr() and classification helpers.
In the bundled June 2025 data, Sydney (South) - Haymarket has 24,758 residents across 1.0810 km², approximately 22,903 residents/km². Chippendale and Ultimo follow at approximately 20,476 and 18,851 residents/km². These values are calculated from the table and official area field, rather than read off a colour.
Discuss the pattern relevant to your question. Population estimates are not exact headcounts, and the 2025 estimates are preliminary. Area-level differences do not establish causation or describe every person within an area. Large, sparsely populated polygons can dominate visual attention.
- Use a spatial join when there is no area code
The school table has coordinates but no SA2 code. A spatial join attaches the code using location. Use the full-resolution analytical boundaries for this operation, not the simplified display copy.
sa2_full <- st_read("data/greater-sydney-sa2.gpkg", layer = "sa2", quiet = TRUE)
# A local projected CRS makes the boundary predicate explicit.
# EPSG:28356 is GDA94 / MGA zone 56; it is not a universal choice.
schools_local <- st_transform(schools, 28356)
sa2_local <- st_transform(sa2_full, 28356)
memberships <- st_within(schools_local, sa2_local)
membership_count <- lengths(memberships)
stopifnot(all(membership_count == 1L))
schools_with_sa2 <- st_join(
schools_local, select(sa2_local, sa2_code), join = st_within, left = TRUE
)
school_counts <- schools_with_sa2 |>
st_drop_geometry() |> count(sa2_code, name = "n_locations")
sa2_with_counts <- sa2 |>
left_join(school_counts, by = "sa2_code") |>
mutate(n_locations = coalesce(n_locations, 0L))
stopifnot(sum(sa2_with_counts$n_locations) == nrow(schools))
sa2_with_counts |> st_drop_geometry() |> arrange(desc(n_locations)) |>
select(sa2_name, n_locations) |> head(8) |>
knitr::kable(caption = "Government education locations assigned by point location.")| sa2_name | n_locations |
|---|---|
| Kurrajong Heights - Ebenezer | 11 |
| Dural - Kenthurst - Wisemans Ferry | 9 |
| Oyster Bay - Como - Jannali | 9 |
| Lalor Park - Kings Langley | 8 |
| Bidwill - Hebersham - Emerton | 8 |
| Kingswood - Werrington | 8 |
| Gosford - Springfield | 7 |
| Lethbridge Park - Tregear | 7 |
Zero means no locations from this government dataset were assigned to the SA2. It does not mean no schools of any kind, no school-age residents, or no access to education. School catchments and student residence can cross SA2 boundaries.
Points on boundaries, outside the study area, or inside overlapping polygons need an explicit rule. st_within() excludes boundary points in this projected example; a different predicate can match more than one polygon. Investigate exceptions before joining instead of dropping duplicate assignments arbitrarily. See st_join().
- Explore point concentration with a heatmap
A heatmap smooths nearby points across screen pixels. Its appearance depends on zoom, radius, and weighting. We give each government education location the same weight, including those without an enrolment figure.
heat_map <- maplibre(
style = openfreemap_style("positron"), bounds = schools,
fitBoundsOptions = list(padding = 40), projection = "mercator",
height = 440, scrollZoom = FALSE
) |>
add_heatmap_layer(
id = "school-heat", source = schools, heatmap_weight = 1,
heatmap_radius = interpolate(property = "zoom", values = c(7, 14),
stops = c(12, 32)),
heatmap_intensity = 1,
heatmap_color = interpolate(property = "heatmap-density",
values = c(0, 0.25, 0.5, 0.75, 1),
stops = c("rgba(255,255,255,0)", "#c6dbef", "#6baed6",
"#2171b5", "#08306b")),
heatmap_opacity = 0.8
) |>
add_continuous_legend(
legend_title = "Relative concentration of locations",
values = c("Lower", "Higher"), colors = c("#c6dbef", "#08306b"),
position = "bottom-left"
) |>
add_navigation_control()
heat_mapTry it: zoom in and out. The legend is qualitative; it does not report schools per km² or enrolments. Source: NSW Department of Education, 2 October 2026 snapshot.
This is a visual concentration map, not a population-adjusted rate or a test of a statistically significant hotspot. For a defined density, calculate a statistic on documented areas or use an appropriate density estimator. See add_heatmap_layer().
- Cluster crowded school points
Clustering groups nearby points at the current zoom and displays their feature counts. It reduces overplotting; these groups are visual, rather than fixed statistical clusters.
cluster_map <- maplibre(
style = openfreemap_style("positron"), bounds = schools,
fitBoundsOptions = list(padding = 40), projection = "mercator",
height = 470, scrollZoom = FALSE
) |>
add_circle_layer(
id = "schools", source = schools, circle_color = "#D55E00",
circle_radius = 5, popup = "popup_html",
cluster_options = cluster_options(
max_zoom = 12, cluster_radius = 45, count_stops = c(0, 20, 100),
color_stops = c("#56B4E9", "#0072B2", "#172f46"),
radius_stops = c(18, 25, 34), text_color = "white"
)
) |>
set_layout_property(layer_id = "schools-cluster-count",
name = "text-font", value = list("Noto Sans Regular")) |>
add_categorical_legend(
legend_title = "Locations in visual cluster",
values = c("2–19", "20–99", "100 or more", "Individual location"),
colors = c("#56B4E9", "#0072B2", "#172f46", "#D55E00"),
patch_shape = "circle", position = "top-left"
) |>
add_navigation_control()
cluster_mapTry it: click a numbered cluster to zoom towards its locations, then click an individual point. The label counts locations, not enrolled students. Source: NSW Department of Education.
Supply the sf points directly to add_circle_layer() with cluster_options(). The shortcut creates a separate schools-cluster-count symbol layer. We use set_layout_property() to select Noto Sans Regular, available from the OpenFreeMap glyph service. An unavailable font can leave labels blank while circles still render.
- Add a population year slider without a server
A slider filters features in the browser, so it works on a static website. The filter property must be numeric. We build one polygon record per SA2 and year from the real 2021–2025 population series; the slider shows only one year’s records at a time.
population_long <- read_csv("data/sydney-sa2-population-long.csv",
col_types = cols(sa2_code = col_character(), year = col_integer()))
stopifnot(!anyDuplicated(paste(population_long$sa2_code, population_long$year)),
is.numeric(population_long$year),
nrow(population_long) == nrow(sa2) * 5)
sa2_time <- left_join(sa2, population_long, by = "sa2_code") |>
mutate(density = population / area_km2,
year_popup = paste0(htmltools::htmlEscape(sa2_name), "<br>June ", year,
"<br>Population: ", population,
"<br>Density: ", sprintf("%.0f residents/km²", density)))
stopifnot(nrow(sa2_time) == nrow(population_long))
time_map <- maplibre(
style = openfreemap_style("positron"), bounds = sa2,
fitBoundsOptions = list(padding = 40), projection = "mercator",
height = 480, scrollZoom = FALSE
) |>
add_fill_layer(
id = "population-by-year", source = sa2_time,
fill_color = interpolate("density", values = density_limits,
stops = density_colours),
fill_opacity = 0.85, fill_outline_color = "#526478", popup = "year_popup"
) |>
add_slider_control(
layers = "population-by-year", property = "year", values = 2021:2025,
labels = as.character(2021:2025), mode = "sequential",
initial_value = 2021, play_button = TRUE,
title = "Population at 30 June · selected year",
width = 230, position = "top-left"
) |>
add_continuous_legend(
legend_title = "All years · residents per km²",
values = density_limits, colors = density_colours, position = "bottom-left"
) |>
add_navigation_control()
time_mapTry it: move the slider to 2025 or press play. The colours use a fixed scale and the geography is fixed to ASGS 2021. Source: ABS Regional population 2024–25, annual series.
These are annual population snapshots, so sequential filtering suits the question. Cumulative mode would overlap several estimates of the same area and should not be used to add resident populations across years. The browser reveals existing records; it does not estimate missing years or fit a trend model.
For dates, use as_time_property(date_column, unit = "date") to produce a numeric property: GeoJSON serializes R dates as strings. For large datasets, avoid repeating complex geometries unnecessarily; the slider also supports changing paint expressions on a single geometry source. See add_slider_control().
- Compare 2021 and 2025 using a shared scale
A swipe comparison helps inspect changes. Both maps must use comparable statistics, units, geography, and colour limits. Here density uses the same official area denominator and the same 0–25,000 scale.
make_year_map <- function(data, year) {
density_column <- paste0("density_", year)
stopifnot(density_column %in% names(data))
maplibre(
style = openfreemap_style("positron"), bounds = data,
fitBoundsOptions = list(padding = 40), projection = "mercator",
scrollZoom = FALSE
) |>
add_fill_layer(
id = "comparison-density", source = data,
fill_color = interpolate(density_column, values = density_limits,
stops = density_colours, na_color = missing_colour),
fill_opacity = 0.85, fill_outline_color = "#526478"
)
}comparison_map <- compare(
make_year_map(sa2_data, 2021), make_year_map(sa2_data, 2025),
mode = "swipe", height = 480
) |>
add_legend(
legend_title = "Left: 2021 · Right: 2025 · residents/km²",
values = density_limits, colors = density_colours,
type = "continuous", target = "compare", position = "bottom-left"
)
comparison_mapTry it: drag the central handle. Left shows June 2021; right shows June 2025. Source: ABS Regional population 2024–25.
Use mode = "sync" for synchronized views. Quantify changes in a table or chart as well: colour differences do not establish why population changed. For example, compute population_2025 - population_2021; for percentage change, check for a zero baseline before dividing. See compare().
- Explore 3D extrusion
Extrusion assigns display heights to polygons. Perspective and occlusion make precise comparisons difficult, so retain a 2D map or table. This example focuses on central Sydney while drawing the real SA2 dataset.
sa2_3d <- sa2_data |> mutate(height_m = density_2025 / 10)
extrusion_map <- maplibre(
style = openfreemap_style("positron"), center = c(151.20, -33.89),
zoom = 11.8, projection = "mercator", pitch = 50, bearing = -20,
height = 480, scrollZoom = FALSE
) |>
add_fill_extrusion_layer(
id = "extruded-sa2", source = sa2_3d,
fill_extrusion_color = interpolate("density_2025", values = density_limits,
stops = density_colours),
fill_extrusion_height = get_column("height_m"),
fill_extrusion_opacity = 0.85, popup = "popup_html"
) |>
add_continuous_legend(
legend_title = "Residents/km² · height = density ÷ 10 m",
values = density_limits, colors = density_colours, position = "bottom-left"
) |>
add_navigation_control()
extrusion_mapThe heights encode population density; they are not measured building heights or terrain. A density of 10,000 residents/km² produces a display height of 1,000 metres. Source: ABS.
fill_extrusion_height expects metres, so the display conversion is explicit. projection = "mercator" avoids globe-related extrusion artefacts described in the layer overview. See add_fill_extrusion_layer().
- Display the real ABS population raster
Vector data describe distinct features. Raster data describe values in grid cells. The bundled GeoTIFF is the ABS 2025 population grid cropped and masked to Greater Sydney, retaining its native 1 km cells in EPSG:3577. Its values are modelled residents per km², not observations of individuals at cell centres.
population_grid <- terra::rast("data/sydney-population-grid-2025.tif")
terra::res(population_grid)[1] 1000 1000
terra::crs(population_grid, proj = TRUE)[1] "+proj=aea +lat_0=0 +lon_0=132 +lat_1=-18 +lat_2=-36 +x_0=0 +y_0=0 +ellps=GRS80 +units=m +no_defs"
terra::global(population_grid, c("min", "max"), na.rm = TRUE) min max
population_density_2025 0 24015.78
# Classify on the original analytical grid before making a display image.
# 1 = zero, 2 = >0 to <500, 3 = 500 to <2000, ... 6 = >=8000.
grid_classes <- 1 + (population_grid > 0) + (population_grid >= 500) +
(population_grid >= 2000) + (population_grid >= 5000) +
(population_grid >= 8000)
grid_colours <- c("#f7f7f7", "#eff3ff", "#bdd7e7", "#6baed6", "#2171b5", "#08306b")
grid_labels <- c("Zero", ">0 to <500", "500 to <2,000", "2,000 to <5,000",
"5,000 to <8,000", "8,000 or more")
rgb <- t(grDevices::col2rgb(grid_colours))
terra::coltab(grid_classes) <- data.frame(
value = 1:6, red = rgb[, 1], green = rgb[, 2], blue = rgb[, 3], alpha = 255
)raster_map <- maplibre(
style = openfreemap_style("positron"), bounds = sa2,
fitBoundsOptions = list(padding = 40), projection = "mercator",
height = 480, scrollZoom = FALSE
) |>
add_image_source(id = "population-grid-image", data = grid_classes) |>
add_raster_layer(
id = "population-grid", source = "population-grid-image",
raster_opacity = 0.85, raster_resampling = "nearest"
) |>
add_categorical_legend(
legend_title = "Modelled residents/km² · June 2025",
values = grid_labels, colors = grid_colours, position = "top-left"
) |>
add_navigation_control()
raster_mapTry it: zoom in to inspect the grid cells. The classes retain exact threshold meanings; unmodelled or masked cells are transparent. Source: ABS Australian population grid 2025.
The colour table makes this a categorical display raster. Mapgl uses nearest-neighbour reprojection for this path; raster_resampling = "nearest" also prevents blended class colours when zooming. Zero is distinct from missing data. The retained analytical GeoTIFF still contains its original numeric density values.
Mapgl turns the raster into an image; this example does not provide cell-value popups. A display image is not a substitute for the original grid in analysis. Cropping by a region and masking edge cells is approximate at the 1 km grid scale, so do not expect the masked grid to reproduce the exact SA2 population total. See add_image_source() and add_raster_layer().
Use terra:: explicitly: terra and mapgl share function names including interpolate() and compare(). If you attach both packages, qualify the intended function with mapgl:: or terra::. For a large raster, crop or reduce a display copy, or consider a tiled source.
- Put the map in your Quarto report
Interactive htmlwidgets belong in an HTML report. A normal mapgl map returned from an R chunk becomes an interactive widget when Quarto renders the document. No iframe, screenshot, or Shiny server is needed for that workflow. See Quarto’s htmlwidgets guide.
Use this YAML at the top of an independent report:
---
title: "My DATA1X01 Project 2 report"
format:
html:
embed-resources: true
toc: true
code-copy: true
execute:
echo: true
message: false
error: false
---Then put your setup, data preparation, and map construction in ordinary executable R chunks. Finish the map chunk with the map object. For example, a report can use this pattern:
```{r}
#| label: fig-schools
#| fig-cap: "Greater Sydney government education locations. Source: NSW Department of Education, 2 October 2026 snapshot."
my_map <- maplibre(
style = openfreemap_style("positron"), bounds = schools,
projection = "mercator", height = 480
) |>
add_circle_layer(
id = "school-points", source = schools,
circle_color = "#0072B2", circle_radius = 6, popup = "name"
)
my_map
```
@fig-schools shows the education locations included in this analysis.Do not set eval: false, include: false, or output: false on a chunk whose map should appear. Avoid placing maps inside collapsed tabs during initial troubleshooting. A fig-* label and fig-cap provide a caption and cross-reference. Follow your course’s instructions about showing code and submitting supporting files.
Render and verify the report
Run these commands in a terminal, from your project folder:
quarto preview index.qmd
quarto render index.qmd --to htmlStart from a fresh R session before the final render, and check the resulting HTML in a browser. A report must not rely on objects that happen to exist in your Console. Test at least one popup, one zoom control, and any filter or slider you added.
embed-resources: true puts the document’s local JavaScript and CSS dependencies into the HTML. It does not download the complete remote basemap or guarantee offline maps. Send the rendered HTML and any files required by the assessment instructions. For a website, also publish linked downloads and any supporting assets. See Quarto’s HTML documentation.
PDF and Word need a static alternative
PDF and Word cannot preserve these interactive maps. Mapgl’s print_map() can produce a static figure during rendering, using chromote and an installed Chrome/Chromium browser. The following is an optional export pattern; it is not executed in this HTML tutorial.
# Run once in the Console if needed: install.packages("chromote")
if (knitr::is_html_output()) {
density_map
} else {
print_map(density_map, width = 1000, height = 600)
}
# You can also save files outside a report:
save_map(density_map, "density-map.png", width = 1000, height = 600)
htmlwidgets::saveWidget(density_map, "density-map.html", selfcontained = TRUE)The helper script scripts/export-example.R provides a complete HTML export example. If you use static output, inspect it after export: check the extent, legend, missing-value key, and attribution. See print_map() and save_map().
- Adapt the workflow to Project 2
Start with the analytical question, then choose a geometry and an encoding that help answer it. For example, point locations can support a question about distribution, while area rates can support a comparison between regions. The appearance of the map should follow from that question.
- Identify the geographic unit. Is each row a location, an event, a route, or an area? Check the data period and coverage.
- Prepare and validate the data. Read files reproducibly, verify the CRS, inspect invalid or empty geometries, and account for excluded rows.
- Join only what is needed. Check unique identifiers and unmatched keys. Keep only columns that are appropriate to publish.
- Choose the encoding. Use category colours for categories, a sequential scale for a rate, or proportional circle areas for suitable counts. Explain the units.
- Write the finding. Describe a relevant pattern, quantify it, and discuss the source’s limitations. Pair the map with a chart or table when precise comparison matters.
- Render and check the result. Make sure the HTML opens and the map works in the same format the reader or marker will receive.
Performance and responsible publication
Every attribute retained in a displayed sf object may be serialized into the map’s GeoJSON. Hiding a column from a popup does not remove it from the published HTML. Select only approved fields before mapping, and do not publish confidential data or precise personal locations.
For a slow map, first reduce unnecessary columns, crop to the study region, and inspect file size. Simplify a display copy of large geometries using a defensible tolerance; retain the originals for analysis. A clustering control reduces visual crowding but still sends the underlying points to the browser. Large projects may need vector tiles or PMTiles rather than an embedded GeoJSON dataset.
Keep basemap attribution visible. Provide a useful caption and a table or chart so that colour perception, a small screen, or an unavailable basemap does not prevent a reader from understanding your result. Avoid adding many maps when one answers the question clearly.
- Troubleshoot common problems
| Symptom | What to check |
|---|---|
| The basemap appears, but no data do | Check st_bbox(), longitude/latitude order, CRS, geometry type, and whether a filter excludes everything. |
| Points appear in the wrong country | Check swapped coordinates and the source CRS. Projected eastings and northings are not longitude and latitude. |
| Nothing appears in the report | Finish the chunk with the map object, set a non-zero height, and render to HTML. Check chunk output options. |
| Grey or unexpected colours | Inspect missing values, column types, exact category spelling, and expression defaults. |
| A join adds many extra rows | Check duplicate keys and whether a spatial predicate matches multiple areas. |
| A join loses areas | Inspect unmatched keys in both directions, identifier types, leading zeros, and boundary vintage. |
| A slider shows no features | Make the property numeric, check the layer ID, and check its range and selected value. |
| The layer control changes nothing | Supply layer IDs, rather than source IDs; group related fill and outline layers. |
could not find function |
Load the package and check its version. These examples use mapgl 0.5.2 or later. |
sf or terra will not install |
Use an appropriate binary where available. Linux source installs need geospatial system libraries; see each package’s official installation guidance. |
| The whole map stays blank | Check the browser console, WebGL availability, internet access, basemap service, and website security policy. Try another current browser. |
| The published page loses its maps | Publish the rendered HTML with its dependencies, or use embed-resources: true. Source .qmd alone is not a rendered page. |
| The map works online but fails offline | Its basemap is remote. Embedded dependencies do not make remote tiles available offline. |
| The map fails in PDF or Word | Use a tested static image or the print_map() export pattern. |
If the basemap service is temporarily unavailable, you can construct a map using style = basemap_style("#f5f5f2") and your local data layers. This provides a plain background for debugging or a deliberately basemap-free design. It lacks place labels and other basemap context, and the mapping library still needs to be available.
Documentation and reproducibility
This lesson uses original examples informed by Kyle Walker’s mapgl documentation. It targets mapgl 0.5.2 and explicitly selects its basemap to avoid relying on a default that may change between versions. Refer to the current documentation when extending the examples.
- mapgl home and getting started
- Sources and layers
- Map design
- Complete function reference
- mapgl release notes
sfdocumentationterradocumentation- Quarto and htmlwidgets
- OpenFreeMap and basemap attribution
- ABS population methodology
- NSW school data and dictionary
To record your own environment, run sessionInfo() and retain the output with your project. The supplied R/prepare-sydney-data.R rebuilds the teaching files from bundled official-source snapshots. scripts/fetch-sydney-data.R documents how to download those sources again; a later school snapshot may differ. data/README.md documents the fields, periods, licences, and transformations.
Try one extension at a time: change the school colour palette and update its legend; compare population counts with density; find which schools fall in each SA2; change the heatmap radius and describe the visual effect; or quantify the SA2 with the largest population change between 2021 and 2025. Explain why cumulative filtering would be inappropriate for repeated annual population snapshots.
Then choose one map appropriate to your own data and write a paragraph that explains a specific finding, its magnitude, and its limitations.
Downloads
- Download
Executable Quarto tutorial and Sydney data