gq is a style management system for cartography. Define your map styles once in a JSON registry, then translate them to any rendering target — tmap, mapgl, leaflet, ggplot2. Change a color in one place and every map updates.
The problem
New Graph produces maps across multiple tools — QGIS for field work, tmap for bookdown reports, MapLibre GL for web maps. Symbology (colors, line weights, classification breaks) is duplicated manually across each tool. Change a color in QGIS → manually update R code → manually update web styles. It doesn’t scale.
The solution
A canonical style registry that serves as the single source of truth:
QGIS Project (.qgs)
↓ gq_qgs_extract()
registry.json
↓ gq_tmap_style() / gq_mapgl_style()
tmap, mapgl, leaflet, ggplot2
Real data: Bittner Creek
gq ships with real spatial data for Bittner Creek near Prince George — a ~56 km² watershed pulled from the BC Freshwater Atlas and bcfishpass via the newgraph database. This includes the watershed boundary, FWA streams, lakes, DRA roads, CN railway, and 95 PSCIS fish passage assessments.
library(gq)
library(sf)
#> Linking to GEOS 3.12.1, GDAL 3.8.4, PROJ 9.4.0; sf_use_s2() is TRUE
data(bittner_wsd, bittner_streams, bittner_lakes,
bittner_roads, bittner_railway, bittner_pscis,
package = "gq")
cat("Watershed:", round(bittner_wsd$area_ha), "ha\n")
#> Watershed: 5600 ha
cat("Streams:", nrow(bittner_streams), "segments\n")
#> Streams: 150 segments
cat("Lakes:", nrow(bittner_lakes), "\n")
#> Lakes: 12
cat("Roads:", nrow(bittner_roads), "\n")
#> Roads: 5576
cat("PSCIS crossings:", nrow(bittner_pscis), "\n")
#> PSCIS crossings: 95Load the style registry
The styles come from a QGIS project extracted with
gq_qgs_extract(). The registry maps layer names to
rendering properties — fill colors, stroke weights, classification
breaks:
reg_path <- system.file("examples", "reg_demo.json", package = "gq")
reg <- gq_registry_read(reg_path)
# What layers did we extract?
names(reg$layers)
#> [1] "conservancy" "crossings_pscis_assessment"
#> [3] "lake" "provincial_park"
#> [5] "railway" "roads_dra"
#> [7] "stream_labels" "streams_all"
# Lake style — fill, stroke, opacity, label settings all captured
reg$layers$lake
#> $type
#> [1] "polygon"
#>
#> $source_layer
#> [1] "whse_basemapping.fwa_lakes_poly"
#>
#> $fill
#> $fill$color
#> [1] "#dcecf4"
#>
#> $fill$opacity
#> [1] 0.7
#>
#>
#> $stroke
#> $stroke$color
#> [1] "#1f78b4"
#>
#> $stroke$width
#> [1] 0.2
#>
#>
#> $label
#> $label$font
#> [1] "Helvetica"
#>
#> $label$size
#> [1] 10
#>
#> $label$style
#> [1] "italic"
#>
#> $label$color
#> [1] "#1f78b4"
#>
#> $label$halo
#> $label$halo$color
#> [1] "#ffffff"
#>
#> $label$halo$width
#> [1] 0.3tmap: static map for reports
gq_tmap_style() converts registry entries directly to
tmap v4 arguments. Here’s a study area map following New Graph
cartographic conventions:
library(tmap)
#> Registered S3 method overwritten by 'stars':
#> method from
#> st_interpolate_aw.stars sf
sf_use_s2(FALSE)
#> Spherical geometry (s2) switched off
# --- Basemap: gray canvas × hillshade blend ---
# A muted raster basemap gives terrain relief without competing labels; we
# control all text ourselves via tm_text(). Esri.WorldGrayCanvas does carry
# faint place labels that appear as you zoom in -- none at zoom 10 here, lake
# names by zoom 12.
# See cartography skill for scale-based basemap selection guidelines.
#
# gq_basemap_tiles() does the padding a projected map needs, and returns NULL
# rather than erroring when a fetch fails -- so a tile hiccup costs the basemap,
# not the whole figure. This chunk hand-rolled both until gq#57.
bbox <- gq_bbox_aspect(bittner_wsd, asp = 7 / 6)
canvas <- gq_basemap_tiles(bbox, provider = "Esri.WorldGrayCanvas",
zoom = 10, pad = 0, crs = NULL)
relief <- gq_basemap_tiles(bbox, provider = "Esri.WorldShadedRelief",
zoom = 10, pad = 0, crs = NULL)
# The whole LAYER is conditional, not just the data: tm_shape() rejects NULL,
# so holding a NULL raster and passing it on would only move the failure into
# the map composition below.
basemap <- if (is.null(canvas) || is.null(relief)) {
NULL
} else {
tm_shape(gq_basemap_blend(canvas, relief, method = "gamma", gamma = 0.5)) +
tm_rgb()
}
# --- Data prep ---
streams_display <- bittner_streams[bittner_streams$stream_order >= 3, ]
roads_hwy <- bittner_roads[bittner_roads$road_type == "RH1", ]
roads_art <- bittner_roads[bittner_roads$road_type %in% c("RA1", "RA2"), ]
# Stream labels: dissolve named streams to single point per name
stream_labels <- bittner_streams[
!is.na(bittner_streams$gnis_name) & bittner_streams$stream_order >= 4, ]
stream_labels <- do.call(rbind, lapply(
split(stream_labels, stream_labels$gnis_name),
function(x) {
combined <- st_union(x)
pt <- st_point_on_surface(combined)
st_sf(gnis_name = x$gnis_name[1], geometry = pt, crs = st_crs(x))
}
))
#> although coordinates are longitude/latitude, st_union assumes that they are
#> planar
#> Warning in st_point_on_surface.sfc(combined): st_point_on_surface may not give
#> correct results for longitude/latitude data
#> although coordinates are longitude/latitude, st_union assumes that they are
#> planar
#> Warning in st_point_on_surface.sfc(combined): st_point_on_surface may not give
#> correct results for longitude/latitude data
#> although coordinates are longitude/latitude, st_union assumes that they are
#> planar
#> Warning in st_point_on_surface.sfc(combined): st_point_on_surface may not give
#> correct results for longitude/latitude data
#> although coordinates are longitude/latitude, st_union assumes that they are
#> planar
#> Warning in st_point_on_surface.sfc(combined): st_point_on_surface may not give
#> correct results for longitude/latitude data
#> although coordinates are longitude/latitude, st_union assumes that they are
#> planar
#> Warning in st_point_on_surface.sfc(combined): st_point_on_surface may not give
#> correct results for longitude/latitude data
#> although coordinates are longitude/latitude, st_union assumes that they are
#> planar
#> Warning in st_point_on_surface.sfc(combined): st_point_on_surface may not give
#> correct results for longitude/latitude data
# Separate Bittner Creek for emphasis
bittner_label <- stream_labels[stream_labels$gnis_name == "Bittner Creek", ]
other_labels <- stream_labels[stream_labels$gnis_name != "Bittner Creek", ]
# PSCIS classification from the registry — same colors as QGIS
pscis_cls <- gq_tmap_classes(reg$layers$crossings_pscis_assessment)
# New Graph logo (bundled, pre-resized to square for tm_logo)
logo_path <- system.file("logo", "nge_icon_200.png", package = "gq")
# --- Map composition ---
m <- basemap +
tm_shape(bittner_wsd) +
tm_polygons(fill = "#a8c8e0", fill_alpha = 0.4, col = "#2c3e50", lwd = 1.8) +
tm_shape(bittner_lakes) +
do.call(tm_polygons, gq_tmap_style(reg$layers$lake)) +
tm_shape(streams_display) +
tm_lines(col = "#a9e0ff", lwd = 1.4) +
tm_shape(other_labels) +
tm_text("gnis_name", size = 0.4, fontface = "italic", col = "#1a5276",
options = opt_tm_text(shadow = TRUE, remove_overlap = TRUE)) +
tm_shape(bittner_label) +
tm_text("gnis_name", size = 0.55, fontface = "bold.italic", col = "#1a3c5e",
options = opt_tm_text(shadow = TRUE)) +
tm_shape(bittner_railway) +
tm_lines(col = "black", lwd = 1.2) +
tm_shape(bittner_railway) +
tm_lines(col = "white", lwd = 0.6, lty = "42") +
tm_shape(roads_hwy) +
tm_lines(col = "#c0392b", lwd = 2.0) +
tm_shape(roads_art) +
tm_lines(col = "#e67e22", lwd = 1.4) +
tm_shape(bittner_pscis) +
tm_dots(
fill = pscis_cls$field,
fill.scale = tm_scale_categorical(
values = pscis_cls$values,
labels = pscis_cls$labels
),
fill.legend = tm_legend(show = FALSE),
# Size from the registry rather than by hand. This was 0.5 until #16, while
# the legend swatch below was 0.8 -- the same layer drawn at two different
# sizes, which is the per-map guessing gq_symbol_size() exists to end.
size = gq_symbol_size(reg$layers$crossings_pscis_assessment$
classification$classes$BARRIER$radius, "tmap"),
col = "white",
lwd = 0.8
) +
# Manual legend for full control — PSCIS crossings + infrastructure
tm_add_legend(
type = "symbols",
labels = pscis_cls$labels,
fill = pscis_cls$values,
col = "white",
# Same registry value as the map above, so the key matches what it describes.
size = gq_symbol_size(reg$layers$crossings_pscis_assessment$
classification$classes$BARRIER$radius, "tmap"),
shape = gq_symbol_shape(
reg$layers$crossings_pscis_assessment$classification$classes$BARRIER$shape,
"tmap"),
title = "PSCIS Crossings"
) +
tm_add_legend(
type = "lines",
labels = c("Highway", "Arterial", "Railway"),
col = c("#c0392b", "#e67e22", "black"),
lwd = c(2, 1.4, 1.2)
) +
tm_logo(logo_path, position = c("right", "top"), height = 3) +
tm_layout(
frame = TRUE,
inner.margins = c(0, 0, 0, 0),
outer.margins = c(0.002, 0.002, 0.002, 0.002),
legend.position = c("left", "top"),
legend.frame = TRUE,
legend.bg.color = "white",
legend.bg.alpha = 0.85,
legend.text.size = 0.55,
legend.title.size = 0.65
)
m
PSCIS crossings around the Bittner Creek watershed, classified by barrier status from the gq registry.
Every color on that map traces back to the registry. The PSCIS
crossing colors (red = barrier, green = passable, orange = potential,
purple = unknown) match the QGIS project exactly because they come from
the same registry.json.
How the style translation works
The registry stores canonical properties.
gq_tmap_style() translates them to tmap’s parameter
names:
# Registry → tmap for a polygon layer
gq_tmap_style(reg$layers$lake)
#> $fill
#> [1] "#dcecf4"
#>
#> $fill_alpha
#> [1] 0.7
#>
#> $col
#> [1] "#1f78b4"
#>
#> $lwd
#> [1] 0.2
# Registry → tmap for a line layer
gq_tmap_style(reg$layers$railway)
#> $col
#> [1] "#000000"
#>
#> $lwd
#> [1] 0.4For classified layers, gq_tmap_classes() extracts the
field name, color vector, and labels — ready for
tm_scale_categorical():
gq_tmap_classes(reg$layers$crossings_pscis_assessment)
#> $field
#> [1] "barrier_result_code"
#>
#> $values
#> BARRIER PASSABLE POTENTIAL UNKNOWN
#> "#ca3c3c" "#33a02c" "#ff7f00" "#bf2ac4"
#>
#> $labels
#> [1] "Barrier" "Passable" "Potential" "Unknown"
#>
#> $widths
#> NULL
#>
#> $dashes
#> NULLMapLibre GL: interactive web map
The same registry produces MapLibre GL paint properties for web maps
via gq_mapgl_style():
library(mapgl)
# PSCIS match expression from registry — same classification as tmap
pscis_expr <- gq_mapgl_classes(reg$layers$crossings_pscis_assessment)
# Layer styles from registry
lake_style <- gq_mapgl_style(reg$layers$lake)
railway_style <- gq_mapgl_style(reg$layers$railway)
maplibre(
bounds = as.numeric(st_bbox(bittner_wsd))
) |>
add_fill_layer(
id = "watershed",
source = bittner_wsd,
fill_color = "#a8c8e0",
fill_opacity = 0.4
) |>
add_fill_layer(
id = "lakes",
source = bittner_lakes,
fill_color = lake_style$paint[["fill-color"]],
fill_opacity = lake_style$paint[["fill-opacity"]]
) |>
add_line_layer(
id = "streams",
source = streams_display,
line_color = "#a9e0ff",
line_width = 1
) |>
add_line_layer(
id = "railway",
source = bittner_railway,
line_color = railway_style$paint[["line-color"]],
line_width = 1.5
) |>
add_line_layer(
id = "roads-hwy",
source = roads_hwy,
line_color = "#c0392b",
line_width = 2
) |>
add_circle_layer(
id = "pscis",
source = bittner_pscis,
circle_color = pscis_expr,
circle_radius = 5,
circle_stroke_color = "white",
circle_stroke_width = 1
)The same registry styles translated to MapLibre GL paint properties.
The PSCIS crossings use a MapLibre match expression
built from the registry:
str(pscis_expr)
#> List of 11
#> $ : chr "match"
#> $ :List of 2
#> ..$ : chr "get"
#> ..$ : chr "barrier_result_code"
#> $ : chr "BARRIER"
#> $ : chr "#ca3c3c"
#> $ : chr "PASSABLE"
#> $ : chr "#33a02c"
#> $ : chr "POTENTIAL"
#> $ : chr "#ff7f00"
#> $ : chr "UNKNOWN"
#> $ : chr "#bf2ac4"
#> $ : chr "#888888"Same classification, same colors — one source of truth for both static and interactive maps.
Extract from QGIS
If you have an existing QGIS project, gq_qgs_extract()
parses the .qgs XML and builds a registry — no PyQGIS needed:
qgs_path <- system.file("examples", "mini_project.qgs", package = "gq")
extracted <- gq_qgs_extract(qgs_path)
names(extracted$layers)
#> [1] "lakes" "streams" "crossings" "roads"Write it out as your registry:
jsonlite::write_json(extracted, "registry.json", pretty = TRUE, auto_unbox = TRUE)One source of truth
The workflow:
- Design styles in QGIS (the best visual tool for cartography)
- Extract with
gq_qgs_extract()→registry.json - In R:
gq_tmap_style()for static report maps,gq_mapgl_style()for web - Change a color in the registry → every map updates
No more copy-pasting hex codes across files.
