diff --git a/DESCRIPTION b/DESCRIPTION index 9f326bab..2b2bb20f 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -42,4 +42,4 @@ Suggests: leaflet, withr Config/testthat/edition: 3 -Config/roxygen2/version: 8.0.0 +Config/roxygen2/version: 8.1.0 diff --git a/NEWS.md b/NEWS.md index 73fa2e88..929bf132 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,6 +1,20 @@ # mapgl (development version) -* Update Mapbox GL JS to v3.31.0-rc.1, which brings a much smaller memory footprint for large GeoJSON sources. +* Donut count labels use the same overlap and collision-placement rules as their icons, preventing collision-hidden numbers inside visible donuts for native and precomputed clusters. Ordinary circle-cluster labels are unchanged. + +* Interactive categorical legends preserve hexagon and custom SVG patch shapes when toggling categories or resetting filters. + +* Clusters can be drawn as donut charts showing each cluster's category mix with `cluster_options(donut_column = )`, for live GeoJSON clustering and pre-clustered vector tiles. + +* `add_layer()` gains a `metadata` argument. + +* Abbreviated cluster count labels now switch to millions and billions (e.g. "1.7M" or "1.5B" instead of "1735k" or "1500M"). + +* Clustered layers now render in `compare()` views, and cluster circle paint options (`circle_opacity`, strokes, blur) now reach maps updated through a Shiny proxy. + +* Update Mapbox GL JS to v3.31.0, which brings a much smaller memory footprint for large GeoJSON sources. + +* Raster PMTiles sources on Mapbox maps now use Mapbox GL JS's native PMTiles support. The custom `pmtile-source` plugin, which stopped loading on Mapbox GL JS v3.29+, has been removed. * Popups and tooltips support conditional logic evaluated per feature at render time, with new builders `if_else_expr()`, `case_expr()`, `coalesce_expr()`, `has_column()`, `is_blank()`, and `html_escape_expr()` that compose with `concat()`, `get_column()`, and `number_format()`. Useful for remote sources like PMTiles where popup columns can't be precomputed. See `?conditional_expressions`. diff --git a/R/layers.R b/R/layers.R index 3ab343c3..823613a9 100644 --- a/R/layers.R +++ b/R/layers.R @@ -18,6 +18,7 @@ #' @param hover_options A named list of options for highlighting features in the layer on hover. #' @param before_id The name of the layer that this layer appears "before", allowing you to insert layers below other layers in your basemap (e.g. labels). #' @param filter An optional filter expression to subset features in the layer. +#' @param metadata An optional named list stored as the layer's `metadata` style property. It is not rendered, but is available to JavaScript through `map.getLayer(id).metadata`. #' #' @return The modified map object with the new layer added. #' @export @@ -71,7 +72,8 @@ add_layer <- function( before_id = NULL, filter = NULL, tooltip_style = NULL, - popup_style = NULL + popup_style = NULL, + metadata = NULL ) { if (length(paint) == 0) { paint <- NULL @@ -128,6 +130,10 @@ add_layer <- function( )) ) + if (!is.null(metadata)) { + map$x$layers[[length(map$x$layers)]]$metadata <- metadata + } + map <- mapgl_record_layer_order(map, id) if (inherits(map, "mapboxgl_proxy") || inherits(map, "maplibre_proxy")) { @@ -165,6 +171,10 @@ add_layer <- function( layer$maxzoom <- max_zoom } + if (!is.null(metadata)) { + layer$metadata <- metadata + } + if ( inherits(map, "mapboxgl_compare_proxy") || inherits(map, "maplibre_compare_proxy") @@ -804,19 +814,72 @@ add_fill_extrusion_layer <- function( #' Prepare cluster options for circle layers #' #' This function creates a list of options for clustering circle layers. +#' Clusters are drawn as circles colored by point count, or, when +#' `donut_column` is set, as donut charts showing the mix of categories +#' inside each cluster. #' #' @param max_zoom The maximum zoom level at which to cluster points. -#' @param cluster_radius The radius of each cluster when clustering points. -#' @param color_stops A vector of colors for the circle color step expression. -#' @param radius_stops A vector of radii for the circle radius step expression. +#' @param cluster_radius The radius of each cluster when clustering points, in pixels. Defaults to 50 for circle clusters and to 1.5 times the largest of `radius_stops` (60 by default) for donut clusters, which keeps neighboring donuts from piling on top of each other. +#' @param color_stops A vector of colors for the circle color step expression. Ignored for donut clusters. +#' @param radius_stops A vector of radii for the circle radius step expression. Also sizes donut clusters. #' @param count_stops A vector of point counts for both color and radius step expressions. -#' @param circle_blur Amount to blur the circle. -#' @param circle_opacity The opacity of the circle. -#' @param circle_stroke_color The color of the circle's stroke. +#' @param circle_blur Amount to blur the circle. Ignored for donut clusters. +#' @param circle_opacity The opacity of the circle. For donut clusters, the opacity of the whole donut. +#' @param circle_stroke_color The color of the circle's stroke. For donut clusters, the color of the donut's outer edge (default `"white"`). #' @param circle_stroke_opacity The opacity of the circle's stroke. -#' @param circle_stroke_width The width of the circle's stroke. +#' @param circle_stroke_width The width of the circle's stroke. For donut clusters, the width of the donut's outer edge in pixels (default `1`). #' @param text_color The color to use for labels on the cluster circles. -#' @param count_format The formatting of the text labels on the cluster circles to represent the counts. `"abbreviated"` (the default) will use shortened notation, e.g. "11k". `"grouped"` will show comma-separated numbers, e.g. "11,000". `"raw"` shows the raw value. +#' @param count_format The formatting of the text labels on the cluster circles to represent the counts. `"abbreviated"` (the default) will use shortened notation, e.g. "11k", "1.7M", or "1.5B". `"grouped"` will show comma-separated numbers, e.g. "11,000". `"raw"` shows the raw value. +#' @param donut_column The name of a categorical column. When set, clusters are drawn as donut charts showing the share of each category within the cluster. +#' @param donut_values,donut_colors The categories to show and their colors, one color per entry of `donut_values`. Pass a list to group several values under one color, e.g. `list(c("Oil", "Gas"), "Dry Hole")`. When `NULL` (the default), both are taken from the layer's `circle_color` if it is a [match_expr()] on `donut_column`; its `default` color becomes an "other" slice for unlisted values. Required for [add_symbol_layer()]. +#' @param donut_weight An optional numeric column to sum instead of counting points, e.g. population. The cluster label then shows the weighted total. Missing weights count as zero. +#' @param donut_width The thickness of the donut ring as a fraction of its radius, between 0 and 1. +#' @param donut_fill The color of the donut's center, behind the count label. Use `NA` for a transparent center. +#' @param donut_resolution The step, in percent, to which category shares are rounded (an integer from 1 to 10). Categories whose share rounds to zero are not drawn. +#' +#' @details +#' **Donut clusters.** Setting `donut_column` computes per-category totals +#' for every cluster and draws each cluster as a donut chart in the category +#' colors. Colors are usually taken from the unclustered layer's +#' `circle_color` so that clusters and points match: +#' +#' ``` +#' add_circle_layer( +#' id = "people", +#' source = dots, +#' circle_color = match_expr( +#' "race", +#' values = c("White", "Black", "Hispanic", "Asian"), +#' stops = c("#1b9e77", "#d95f02", "#7570b3", "#e7298a") +#' ), +#' cluster_options = cluster_options(donut_column = "race") +#' ) +#' ``` +#' +#' Build a matching legend by passing the same values and colors to +#' [add_categorical_legend()]. Colors taken from `match_expr()` have already +#' had any alpha channel removed; pass `donut_colors` to keep transparency. +#' The ring shares are among the categories drawn: without an "other" +#' slice, points with unlisted or missing categories are left out of the +#' ring (but still count toward `point_count`, and toward the weighted +#' label total when `donut_weight` is set). +#' +#' Computing category totals makes clustering about two to three times +#' slower than plain clustering with ten categories. Each distinct mix of +#' rounded shares is drawn as its own small image and kept for the life of +#' the map, so a long session panning across many zoom levels accumulates +#' images (tens of MB over a wide sweep of a large dataset). A coarser +#' `donut_resolution` produces fewer distinct images. +#' +#' **Pre-clustered vector tiles.** For tiles that are already clustered +#' (e.g. by freestiler), donut clusters read these properties from each +#' cluster feature: `point_count`, one `":"` total per +#' category (a count, or the sum of `donut_weight`), `":_other"` +#' for the other slice when there is one, and `":_total"`, the +#' weight summed over all points, when `donut_weight` is set. Numeric +#' categories must be whole numbers and are written without exponents, e.g. +#' `"code:100000"`. Missing properties count as zero. `_other` and `_total` +#' are reserved and can't be used as category values. #' #' @return A list of cluster options. #' @export @@ -833,9 +896,16 @@ add_fill_extrusion_layer <- function( #' circle_stroke_color = "#ffffff", #' circle_stroke_width = 2 #' ) +#' +#' # Donut clusters with explicit categories and colors +#' cluster_options( +#' donut_column = "type", +#' donut_values = c("Oil", "Gas", "Dry Hole"), +#' donut_colors = c("#1B5E20", "#fc8d59", "#cd5c5c") +#' ) cluster_options <- function( max_zoom = 14, - cluster_radius = 50, + cluster_radius = NULL, color_stops = c("#51bbd6", "#f1f075", "#f28cb1"), radius_stops = c(20, 30, 40), count_stops = c(0, 100, 750), @@ -845,10 +915,20 @@ cluster_options <- function( circle_stroke_opacity = NULL, circle_stroke_width = NULL, text_color = "black", - count_format = c("abbreviated", "grouped", "raw") + count_format = c("abbreviated", "grouped", "raw"), + donut_column = NULL, + donut_values = NULL, + donut_colors = NULL, + donut_weight = NULL, + donut_width = 0.35, + donut_fill = "white", + donut_resolution = 2 ) { count_format <- match.arg(count_format) - list( + if (is.null(cluster_radius)) { + cluster_radius <- if (is.null(donut_column)) 50 else 1.5 * max(radius_stops) + } + options <- list( max_zoom = max_zoom, cluster_radius = cluster_radius, color_stops = color_stops, @@ -862,19 +942,29 @@ cluster_options <- function( text_color = text_color, count_format = count_format ) + + if (!is.null(donut_column)) { + options$donut <- .validate_donut_options( + column = donut_column, + values = donut_values, + colors = donut_colors, + weight = donut_weight, + width = donut_width, + fill = donut_fill, + resolution = donut_resolution, + stroke_color = circle_stroke_color, + stroke_opacity = circle_stroke_opacity, + stroke_width = circle_stroke_width + ) + } else if (!is.null(donut_values) || !is.null(donut_colors) || !is.null(donut_weight)) { + rlang::abort( + "`donut_values`, `donut_colors`, and `donut_weight` require `donut_column`." + ) + } + + options } -# Build a Mapbox/MapLibre expression that abbreviates a numeric count -# property the same way native `point_count_abbreviated` does, with a -# millions extension tacked on: -# <1000: "42" -# 1000-9999: "1.2k" -# 10000-999999: "12k" -# >=1000000: "1.2M" -# Used by the precomputed cluster path because GL's `number-format` -# expression supports only locale/currency/min-/max-fraction-digits — -# the Intl.NumberFormat `notation = "compact"` option is silently -# ignored, so we can't rely on `number_format()` for this. # Warn when a Mapbox GL JS map is rendering a pre-clustered vector # tile source via cluster_options(). Mapbox GL JS v3.21.0's native # TileProvider PMTiles path currently renders features from multiple @@ -906,51 +996,805 @@ cluster_options <- function( ) } -# Resolve the count-label expression for a given format + backend. -# is_native=TRUE uses the native `point_count_abbreviated` property -# when available; precomputed tiles get the case-expression fallback. -.cluster_count_label <- function(count_format, is_native) { +# Resolve the count-label expression for a cluster count format. +# `value` overrides the counted quantity (e.g. a weighted donut total). +.cluster_count_label <- function(count_format, value = NULL) { + value <- value %||% list("get", "point_count") switch( count_format, - abbreviated = if (is_native) { - get_column("point_count_abbreviated") - } else { - .cluster_count_label_expr("point_count") - }, - grouped = number_format(column = "point_count"), - raw = list("to-string", list("get", "point_count")) + abbreviated = .cluster_count_label_expr(value), + grouped = number_format(column = value), + raw = list("to-string", value) ) } +# Build a Mapbox/MapLibre expression that abbreviates a count the same +# way Supercluster's native `point_count_abbreviated` does, extended to +# millions and billions (the native property stops at "k"): +# <1000: "42" +# 1000-9999: "1.2k" +# 10000-999499: "12k" +# 999500-9949999: "1.7M" +# 9950000-999499999: "17M" +# 999500000-9949999999: "1.5B" +# >=9950000000: "17B" +# Below 1000, non-integer values (weighted donut totals) keep one decimal. +# Rounding matches Supercluster (nearest, halves up). Unit cutoffs prevent +# labels from rounding up to "1000k" or "1000M". GL's `number-format` +# ignores Intl's `notation = "compact"`, so this can't use number_format(). +# `column` is a property name or an expression. .cluster_count_label_expr <- function(column = "point_count") { - col <- list("get", column) + col <- if (is.character(column) && length(column) == 1) { + list("get", column) + } else { + column + } + scaled <- function(divisor, decimals) { + if (decimals) { + list("/", list("round", list("/", col, divisor / 10)), 10) + } else { + list("round", list("/", col, divisor)) + } + } list( "case", - list(">=", col, 1000000), - list( - "concat", - list( - "to-string", - list("/", list("floor", list("/", col, 100000)), 10) - ), - "M" - ), + list(">=", col, 9950000000), + list("concat", list("to-string", scaled(1e9, FALSE)), "B"), + list(">=", col, 999500000), + list("concat", list("to-string", scaled(1e9, TRUE)), "B"), + list(">=", col, 9950000), + list("concat", list("to-string", scaled(1e6, FALSE)), "M"), + list(">=", col, 999500), + list("concat", list("to-string", scaled(1e6, TRUE)), "M"), list(">=", col, 10000), - list( - "concat", - list("to-string", list("floor", list("/", col, 1000))), - "k" - ), + list("concat", list("to-string", scaled(1000, FALSE)), "k"), list(">=", col, 1000), + list("concat", list("to-string", scaled(1000, TRUE)), "k"), + # One decimal for weighted totals; whole counts are unchanged + list("to-string", list("/", list("round", list("*", col, 10)), 10)) + ) +} + +# ---- Donut clusters --------------------------------------------------------- +# +# Donut clusters aggregate per-category totals on each cluster (GL +# `clusterProperties` for native clusters, or precomputed properties on +# pre-clustered tiles) and draw each cluster as a symbol whose icon-image +# is built from the rounded category shares, e.g. +# "mapgl-donut|||40,30,0,30|". The +# `styleimagemissing` handler in lib/mapgl-cluster-donut/cluster-donut.js +# draws that image on demand, reading colors from the layer's metadata. + +# Validate the donut arguments of cluster_options() early so errors +# surface where they were written. Returns the stored donut options. +.validate_donut_options <- function( + column, + values, + colors, + weight, + width, + fill, + resolution, + stroke_color, + stroke_opacity, + stroke_width +) { + is_name <- function(x) { + is.character(x) && length(x) == 1 && !is.na(x) && nzchar(x) + } + if (!is_name(column)) { + rlang::abort("`donut_column` must be a single column name.") + } + if (!is.null(weight) && !is_name(weight)) { + rlang::abort("`donut_weight` must be a single column name.") + } + if (identical(weight, column)) { + rlang::abort("`donut_weight` must be a different column from `donut_column`.") + } + if (xor(is.null(values), is.null(colors))) { + rlang::abort("Supply both `donut_values` and `donut_colors`, or neither.") + } + if (!is.null(values)) { + if (length(colors) != length(values)) { + rlang::abort("`donut_colors` must be the same length as `donut_values`.") + } + .donut_normalize_values( + .donut_expand_groups(values, colors)$values, + "donut_values" + ) + .mapgl_donut_colors(colors, "donut_colors") + } + if ( + !is.numeric(width) || + length(width) != 1 || + is.na(width) || + width <= 0 || + width >= 1 + ) { + rlang::abort("`donut_width` must be a number between 0 and 1.") + } + if ( + !is.numeric(resolution) || + length(resolution) != 1 || + is.na(resolution) || + resolution != round(resolution) || + resolution < 1 || + resolution > 10 + ) { + rlang::abort("`donut_resolution` must be a whole number from 1 to 10.") + } + if (length(fill) != 1 || (!is.na(fill) && !is.character(fill))) { + rlang::abort("`donut_fill` must be a single color or `NA`.") + } + if (!is.na(fill)) { + .mapgl_donut_colors(fill, "donut_fill") + } + + # The outer edge is drawn on a canvas, so these can't be expressions + scalar_args <- list( + circle_stroke_color = stroke_color, + circle_stroke_opacity = stroke_opacity, + circle_stroke_width = stroke_width + ) + for (arg in names(scalar_args)) { + value <- scalar_args[[arg]] + if (!is.null(value) && (is.list(value) || length(value) != 1)) { + rlang::abort(paste0( + "`", + arg, + "` must be a single value for donut clusters; expressions aren't supported." + )) + } + } + if (!is.null(stroke_color)) { + .mapgl_donut_colors(stroke_color, "circle_stroke_color") + } + if ( + !is.null(stroke_opacity) && + (!is.numeric(stroke_opacity) || stroke_opacity < 0 || stroke_opacity > 1) + ) { + rlang::abort("`circle_stroke_opacity` must be a number between 0 and 1.") + } + if ( + !is.null(stroke_width) && (!is.numeric(stroke_width) || stroke_width < 0) + ) { + rlang::abort("`circle_stroke_width` must be a non-negative number.") + } + + list( + column = column, + values = values, + colors = colors, + weight = weight, + width = width, + fill = fill, + resolution = resolution + ) +} + +# Normalize category values into match labels (character or numeric) and +# the key strings used in `:` property names. Accepts an +# atomic vector, a factor, or a list of scalars (from match_expr()). +.donut_normalize_values <- function(values, arg) { + if (is.factor(values)) { + values <- as.character(values) + } + if (is.list(values)) { + types <- vapply( + values, + function(value) { + if (is.character(value) || is.factor(value)) { + "character" + } else if (is.numeric(value)) { + "numeric" + } else { + "other" + } + }, + character(1) + ) + if (any(types == "other") || length(unique(types)) > 1) { + rlang::abort(paste0( + "`", + arg, + "` must be all character or all numeric category values." + )) + } + values <- if (types[[1]] == "character") { + unlist(lapply(values, as.character)) + } else { + unlist(values) + } + } + if (length(values) == 0) { + rlang::abort(paste0("`", arg, "` must contain at least one category.")) + } + if (anyNA(values)) { + rlang::abort(paste0("`", arg, "` can't contain missing values.")) + } + + if (is.character(values)) { + if (any(values %in% c("_other", "_total"))) { + rlang::abort( + "`_other` and `_total` are reserved and can't be used as category values." + ) + } + keys <- values + } else if (is.numeric(values)) { + if ( + any(!is.finite(values)) || + any(values != round(values)) || + any(abs(values) > 9007199254740991) + ) { + rlang::abort(paste0( + "Numeric `", + arg, + "` must be whole numbers between -(2^53 - 1) and 2^53 - 1." + )) + } + values <- as.numeric(values) + keys <- sprintf("%.0f", values) + } else { + rlang::abort(paste0( + "`", + arg, + "` must be character, factor, or numeric category values." + )) + } + + if (anyDuplicated(keys)) { + rlang::abort(paste0("`", arg, "` can't contain duplicate categories.")) + } + + list(labels = values, keys = keys) +} + +# Read categories, colors, and the default color from a match_expr() on +# `column`. Grouped labels (`values = list(c("A", "B"), "C")`) expand into +# one category per value sharing the pair's color. Returns NULL when +# `expr` isn't a simple color match on `column`. +.donut_from_match <- function(expr, column) { + if (!is.list(expr) || length(expr) < 4 || !identical(expr[[1]], "match")) { + return(NULL) + } + input <- expr[[2]] + if ( + !is.list(input) || + length(input) != 2 || + !identical(input[[1]], "get") || + !identical(input[[2]], column) + ) { + return(NULL) + } + + n <- length(expr) + has_default <- n %% 2 == 1 + last_label <- if (has_default) n - 2 else n - 1 + + values <- list() + colors <- character() + for (i in seq(3, last_label, by = 2)) { + label <- expr[[i]] + color <- expr[[i + 1]] + if (!is.character(color) || length(color) != 1) { + return(NULL) + } + if (is.list(label)) { + label <- unlist(label) + } + for (value in as.list(label)) { + values <- c(values, list(value)) + colors <- c(colors, color) + } + } + + default <- if (has_default) expr[[n]] else NULL + if (!is.null(default) && (!is.character(default) || length(default) != 1)) { + return(NULL) + } + + list(values = values, colors = colors, default = default) +} + +# Expand grouped categories (`list(c("A", "B"), "C")`) into one value per +# category, each keeping its group's color, as match_expr() labels do. +.donut_expand_groups <- function(values, colors) { + if (!is.list(values)) { + return(list(values = values, colors = colors)) + } + sizes <- lengths(values) + list( + values = unlist(lapply(values, as.list), recursive = FALSE), + colors = rep(colors, times = sizes) + ) +} + +# Normalize colors for canvas drawing: 6-digit hex plus a separate alpha +# in [0, 1]. Accepts R color names, #RGB/#RGBA/#RRGGBB/#RRGGBBAA, and CSS +# rgb()/rgba(). Unlike .mapgl_col2hex(), alpha is kept. +.mapgl_donut_colors <- function(colors, arg) { + if (!is.character(colors) || length(colors) == 0 || anyNA(colors)) { + rlang::abort(paste0( + "`", + arg, + "` must be a character vector of colors without missing values." + )) + } + rgba <- vapply( + colors, + function(color) { + parsed <- mapgl_parse_rgb_css_color(color) + if (!is.null(parsed)) { + return(c(parsed[1:3], parsed[[4]] * 255)) + } + color <- trimws(color) + if (grepl("^#[0-9A-Fa-f]{3,4}$", color)) { + digits <- strsplit(substring(color, 2), "")[[1]] + color <- paste0("#", paste(rep(digits, each = 2), collapse = "")) + } + out <- tryCatch( + as.numeric(grDevices::col2rgb(color, alpha = TRUE)), + error = function(e) NULL + ) + if (is.null(out)) { + rlang::abort(paste0( + "Invalid color `", + color, + "` in `", + arg, + "`. Use an R color name, hex value, or CSS rgb()/rgba() string." + )) + } + out + }, + numeric(4), + USE.NAMES = FALSE + ) + rgba <- matrix(rgba, nrow = 4) + list( + hex = sprintf( + "#%02X%02X%02X", + as.integer(round(rgba[1, ])), + as.integer(round(rgba[2, ])), + as.integer(round(rgba[3, ])) + ), + alpha = round(rgba[4, ] / 255, 3) + ) +} + +# Check the donut columns against sf data before serializing, so type +# mismatches fail in R instead of silently producing empty rings. +.check_donut_columns <- function(source, column, weight, categories) { + if (inherits(source, "sfc") || !column %in% names(source)) { + rlang::abort(paste0( + "`donut_column` \"", + column, + "\" isn't a column in `source`." + )) + } + x <- source[[column]] + if (is.character(categories$labels)) { + if (!is.character(x) && !is.factor(x)) { + rlang::abort(paste0( + "`donut_column` \"", + column, + "\" must be a character or factor column to match character categories." + )) + } + } else if (!is.numeric(x)) { + rlang::abort(paste0( + "`donut_column` \"", + column, + "\" must be a numeric column to match numeric categories." + )) + } + + if (!is.null(weight)) { + if (!weight %in% names(source)) { + rlang::abort(paste0( + "`donut_weight` \"", + weight, + "\" isn't a column in `source`." + )) + } + w <- source[[weight]] + if (!is.numeric(w)) { + rlang::abort(paste0("`donut_weight` \"", weight, "\" must be numeric.")) + } + w <- w[!is.na(w)] + if (any(!is.finite(w)) || any(w < 0)) { + rlang::abort(paste0( + "`donut_weight` \"", + weight, + "\" must contain finite, non-negative values." + )) + } + } + + invisible(TRUE) +} + +# Build everything a donut cluster layer needs: the clusterProperties for +# native clusters, the icon-image expression, the weighted label value, +# and the canvas spec stored in layer metadata. +.cluster_donut_spec <- function( + cluster_options, + circle_color, + source, + layer_id +) { + donut <- cluster_options$donut + column <- donut$column + + if ( + inherits(source, c("sf", "sfc")) && + (inherits(source, "sfc") || !column %in% names(source)) + ) { + rlang::abort(paste0( + "`donut_column` \"", + column, + "\" isn't a column in `source`." + )) + } + + if (!is.null(donut$values)) { + grouped <- .donut_expand_groups(donut$values, donut$colors) + categories <- .donut_normalize_values(grouped$values, "donut_values") + colors <- grouped$colors + other_color <- NULL + } else { + parsed <- .donut_from_match(circle_color, column) + if (is.null(parsed)) { + rlang::abort(c( + "Donut clusters need category colors.", + i = paste0( + "Pass `donut_values` and `donut_colors` to `cluster_options()`, ", + "or use a `match_expr()` on \"", + column, + "\" as the layer's `circle_color`." + ) + )) + } + categories <- .donut_normalize_values(parsed$values, "circle_color") + colors <- parsed$colors + other_color <- parsed$default + } + + if (inherits(source, c("sf", "sfc"))) { + .check_donut_columns(source, column, donut$weight, categories) + } + + slice_colors <- .mapgl_donut_colors(c(colors, other_color), "donut_colors") + fill <- if (is.na(donut$fill)) { + NULL + } else { + .mapgl_donut_colors(donut$fill, "donut_fill") + } + stroke <- .mapgl_donut_colors( + cluster_options$circle_stroke_color %||% "white", + "circle_stroke_color" + ) + + # Data-only canvas spec; its hash names the images, so any change to + # colors or ring styling produces new images rather than stale ones. + spec <- list( + colors = as.list(slice_colors$hex), + alphas = as.list(slice_colors$alpha), + width = donut$width, + fill = fill$hex, + fill_alpha = fill$alpha, + stroke = stroke$hex, + stroke_alpha = stroke$alpha * (cluster_options$circle_stroke_opacity %||% 1), + stroke_width = cluster_options$circle_stroke_width %||% 1 + ) + fingerprint <- substr(rlang::hash(spec), 1, 10) + spec$fp <- fingerprint + + labels <- categories$labels + keys <- paste0(column, ":", categories$keys) + other_key <- paste0(column, ":_other") + slice_keys <- if (is.null(other_color)) keys else c(keys, other_key) + + weight_expr <- if (is.null(donut$weight)) { + 1 + } else { + list("coalesce", list("get", donut$weight), 0) + } + category <- list("get", column) + + cluster_properties <- lapply(seq_along(labels), function(i) { + list("+", list("case", list("==", category, labels[[i]]), weight_expr, 0)) + }) + names(cluster_properties) <- keys + if (!is.null(other_color)) { + # Bare label array: `literal` isn't allowed in match label position + cluster_properties[[other_key]] <- list( + "+", + list("match", category, as.list(labels), 0, weight_expr) + ) + } + + label_value <- NULL + if (!is.null(donut$weight)) { + total_key <- paste0(column, ":_total") + cluster_properties[[total_key]] <- list("+", weight_expr) + label_value <- list("coalesce", list("get", total_key), 0) + } + + slice_values <- lapply(slice_keys, function(key) { + list("coalesce", list("get", key), 0) + }) + total <- if (length(slice_values) == 1) { + slice_values[[1]] + } else { + c(list("+"), slice_values) + } + # The total is bound once with `let` and read back per slice with `var` + total_var <- list("var", "mapgl_total") + units <- lapply(slice_values, function(value) { list( - "concat", + "case", + list(">", total_var, 0), list( - "to-string", - list("/", list("floor", list("/", col, 100)), 10) + "round", + list( + "/", + list("*", 100, value), + list("*", total_var, donut$resolution) + ) ), - "k" - ), - list("to-string", col) + 0 + ) + }) + + # Images are drawn at their display radius; drawing everything at the + # largest radius and scaling with icon-size saves few images (each + # cluster's mix is usually unique) but costs more bytes per image + radius <- step_expr( + column = "point_count", + base = cluster_options$radius_stops[1], + stops = cluster_options$radius_stops[-1], + values = cluster_options$count_stops[-1] + ) + + unit_parts <- list() + for (i in seq_along(units)) { + if (i > 1) { + unit_parts <- c(unit_parts, list(",")) + } + unit_parts <- c(unit_parts, list(list("to-string", units[[i]]))) + } + icon_image <- list( + "let", + "mapgl_total", + total, + c( + list( + "concat", + paste0("mapgl-donut|", fingerprint, "|"), + list("to-string", radius), + "|" + ), + unit_parts, + list(paste0("|", layer_id)) + ) + ) + + list( + spec = spec, + cluster_properties = cluster_properties, + icon_image = icon_image, + label_value = label_value + ) +} + +# Add the three layers behind the `cluster_options` shortcut of +# add_circle_layer() and add_symbol_layer(): `-clusters` (circles or +# donuts), `-cluster-count`, and the unclustered `` layer. Paint +# and layout are complete before each add_layer() call so proxy messages +# carry every property. +.add_cluster_layers <- function( + map, + id, + source, + source_layer, + cluster_options, + point_type, + paint, + layout, + visibility, + circle_color = NULL, + popup = NULL, + tooltip = NULL, + tooltip_style = NULL, + popup_style = NULL, + hover_options = NULL, + slot = NULL, + min_zoom = NULL, + max_zoom = NULL, + before_id = NULL +) { + clusters_id <- paste0(id, "-clusters") + is_donut <- !is.null(cluster_options$donut) + donut <- NULL + + # Dispatch on source shape. sf/sfc takes precedence so existing + # calls that incidentally pass `source_layer` alongside sf data + # (silently ignored today) don't regress. + if (inherits(source, c("sf", "sfc"))) { + # Native live clustering: inject a clustered GeoJSON source. + if (is_donut) { + donut <- .cluster_donut_spec( + cluster_options, + circle_color, + source, + clusters_id + ) + map <- add_source( + map, + id = id, + data = source, + cluster = TRUE, + clusterMaxZoom = cluster_options$max_zoom, + clusterRadius = cluster_options$cluster_radius, + clusterProperties = donut$cluster_properties + ) + } else { + map <- add_source( + map, + id = id, + data = source, + cluster = TRUE, + clusterMaxZoom = cluster_options$max_zoom, + clusterRadius = cluster_options$cluster_radius + ) + } + cluster_source <- id + cluster_source_layer <- NULL + } else if ( + is.character(source) && + length(source) == 1 && + !is.null(source_layer) + ) { + # Pre-clustered vector tiles (e.g. PMTiles from the freestiler + # package, or tippecanoe-clustered tiles). Use the source as-is. + if (is_donut) { + donut <- .cluster_donut_spec( + cluster_options, + circle_color, + NULL, + clusters_id + ) + } + cluster_source <- source + cluster_source_layer <- source_layer + .warn_mapbox_pmtiles_cluster(map) + } else { + rlang::abort(c( + "`cluster_options` requires one of the following shapes:", + i = "`source` = an sf/sfc object (live clustering is applied automatically), or", + i = "`source` = an existing source id (string) + `source_layer` = the source-layer name, for pre-clustered vector tiles.", + i = "To cluster against a pre-registered clustered GeoJSON source referenced by id, build the three cluster layers manually with `add_layer()`." + )) + } + + count_label_expr <- .cluster_count_label( + cluster_options$count_format %||% "abbreviated", + value = donut$label_value + ) + + if (is_donut) { + cluster_paint <- list() + if (!is.null(cluster_options$circle_opacity)) { + cluster_paint[["icon-opacity"]] <- cluster_options$circle_opacity + } + cluster_layout <- list( + "icon-image" = donut$icon_image, + "icon-allow-overlap" = TRUE, + "icon-ignore-placement" = TRUE + ) + if (!is.null(visibility)) { + cluster_layout$visibility <- visibility + } + map <- add_layer( + map, + id = clusters_id, + type = "symbol", + source = cluster_source, + source_layer = cluster_source_layer, + filter = c("has", "point_count"), + paint = cluster_paint, + layout = cluster_layout, + slot = slot, + min_zoom = min_zoom, + max_zoom = max_zoom, + before_id = before_id, + metadata = list("mapgl:donut" = donut$spec) + ) + } else { + cluster_paint <- list( + "circle-color" = step_expr( + column = "point_count", + base = cluster_options$color_stops[1], + stops = cluster_options$color_stops[-1], + values = cluster_options$count_stops[-1] + ), + "circle-radius" = step_expr( + column = "point_count", + base = cluster_options$radius_stops[1], + stops = cluster_options$radius_stops[-1], + values = cluster_options$count_stops[-1] + ) + ) + optional_paint <- list( + "circle-blur" = cluster_options$circle_blur, + "circle-opacity" = cluster_options$circle_opacity, + "circle-stroke-color" = cluster_options$circle_stroke_color, + "circle-stroke-opacity" = cluster_options$circle_stroke_opacity, + "circle-stroke-width" = cluster_options$circle_stroke_width + ) + for (prop in names(optional_paint)) { + if (!is.null(optional_paint[[prop]])) { + cluster_paint[[prop]] <- optional_paint[[prop]] + } + } + map <- add_layer( + map, + id = clusters_id, + type = "circle", + source = cluster_source, + source_layer = cluster_source_layer, + filter = c("has", "point_count"), + paint = cluster_paint, + layout = list(visibility = visibility), + slot = slot, + min_zoom = min_zoom, + max_zoom = max_zoom, + before_id = before_id + ) + } + + # Donut icons always render and do not reserve collision space. Their + # separate count layer must use the same rules: otherwise a basemap label + # can suppress just the number, leaving a visible but empty donut. + # Keep ordinary circle-cluster label placement unchanged. + map <- add_symbol_layer( + map, + id = paste0(id, "-cluster-count"), + source = cluster_source, + source_layer = cluster_source_layer, + filter = c("has", "point_count"), + text_field = count_label_expr, + text_size = 12, + text_color = cluster_options$text_color, + text_allow_overlap = if (is_donut) TRUE else NULL, + text_ignore_placement = if (is_donut) TRUE else NULL, + visibility = visibility, + slot = slot, + min_zoom = min_zoom, + max_zoom = max_zoom, + before_id = before_id + ) + + # Add unclustered points + add_layer( + map, + id = id, + type = point_type, + source = cluster_source, + source_layer = cluster_source_layer, + filter = list("!", c("has", "point_count")), + paint = paint, + layout = layout, + popup = popup, + tooltip = tooltip, + tooltip_style = tooltip_style, + popup_style = popup_style, + hover_options = hover_options, + slot = slot, + min_zoom = min_zoom, + max_zoom = max_zoom, + before_id = before_id ) } @@ -983,7 +1827,7 @@ cluster_options <- function( #' @param hover_options A named list of options for highlighting features in the layer on hover. #' @param before_id The name of the layer that this layer appears "before", allowing you to insert layers below other layers in your basemap (e.g. labels). #' @param filter An optional filter expression to subset features in the layer. -#' @param cluster_options A list of options for clustering circles, created by the `cluster_options()` function. Two input shapes are supported: pass an `sf`/`sfc` object as `source` for native live clustering (a GeoJSON source is injected automatically), or pass the id of an already-registered vector source (e.g. from `add_pmtiles_source()`) along with `source_layer` to use pre-clustered vector tiles such as those produced by the freestiler package. In the latter case the cluster-count label is abbreviated client-side via [number_format()]. +#' @param cluster_options A list of options for clustering circles, created by the `cluster_options()` function. Two input shapes are supported: pass an `sf`/`sfc` object as `source` for native live clustering (a GeoJSON source is injected automatically), or pass the id of an already-registered vector source (e.g. from `add_pmtiles_source()`) along with `source_layer` to use pre-clustered vector tiles such as those produced by the freestiler package. In the latter case the cluster-count label is abbreviated client-side from `point_count`. Set `donut_column` in `cluster_options()` to draw clusters as donut charts of category shares; see [cluster_options()] for the properties pre-clustered tiles need. #' #' **Updating a clustered layer in Shiny:** the shortcut creates three layers (`"id"`, `"id-clusters"`, `"id-cluster-count"`) on top of one source. For reactive data updates the recommended pattern is [set_source()], which replaces the source's data and lets Mapbox/MapLibre re-cluster automatically without tearing down the layers: `mapboxgl_proxy("map") |> set_source(layer_id = "circles", source = filtered())`. If you need to remove a clustered layer entirely (e.g. before switching backends), pass the full trio to [clear_layer()]: `clear_layer(proxy, c("circles", "circles-clusters", "circles-cluster-count"))`. #' @@ -1119,112 +1963,17 @@ add_circle_layer <- function( if (!is.null(visibility)) layout[["visibility"]] <- visibility if (!is.null(cluster_options)) { - # Dispatch on source shape. sf/sfc takes precedence so existing - # calls that incidentally pass `source_layer` alongside sf data - # (silently ignored today) don't regress. - if (inherits(source, c("sf", "sfc"))) { - # Native live clustering: inject a clustered GeoJSON source. - map <- add_source( - map, - id = id, - data = source, - cluster = TRUE, - clusterMaxZoom = cluster_options$max_zoom, - clusterRadius = cluster_options$cluster_radius - ) - cluster_source <- id - cluster_source_layer <- NULL - is_native_cluster <- TRUE - } else if ( - is.character(source) && - length(source) == 1 && - !is.null(source_layer) - ) { - # Pre-clustered vector tiles (e.g. PMTiles from the freestiler - # package, or tippecanoe-clustered tiles). Use the source as-is. - cluster_source <- source - cluster_source_layer <- source_layer - is_native_cluster <- FALSE - .warn_mapbox_pmtiles_cluster(map) - } else { - rlang::abort(c( - "`cluster_options` requires one of the following shapes:", - i = "`source` = an sf/sfc object (live clustering is applied automatically), or", - i = "`source` = an existing source id (string) + `source_layer` = the source-layer name, for pre-clustered vector tiles.", - i = "To cluster against a pre-registered clustered GeoJSON source referenced by id, build the three cluster layers manually with `add_layer()`." - )) - } - - count_label_expr <- .cluster_count_label( - cluster_options$count_format %||% "abbreviated", - is_native_cluster - ) - - # Add clustered circles layer - map <- add_layer( - map, - id = paste0(id, "-clusters"), - type = "circle", - source = cluster_source, - source_layer = cluster_source_layer, - filter = c("has", "point_count"), - paint = list( - "circle-color" = step_expr( - column = "point_count", - base = cluster_options$color_stops[1], - stops = cluster_options$color_stops[-1], - values = cluster_options$count_stops[-1] - ), - "circle-radius" = step_expr( - column = "point_count", - base = cluster_options$radius_stops[1], - stops = cluster_options$radius_stops[-1], - values = cluster_options$count_stops[-1] - ) - ), - layout = list(visibility = visibility) - ) - - # Add optional paint properties if they are not NULL - optional_paint <- list( - "circle-blur" = cluster_options$circle_blur, - "circle-opacity" = cluster_options$circle_opacity, - "circle-stroke-color" = cluster_options$circle_stroke_color, - "circle-stroke-opacity" = cluster_options$circle_stroke_opacity, - "circle-stroke-width" = cluster_options$circle_stroke_width - ) - - for (prop in names(optional_paint)) { - if (!is.null(optional_paint[[prop]])) { - map$x$layers[[length(map$x$layers)]]$paint[[ - prop - ]] <- optional_paint[[prop]] - } - } - - # Add cluster count labels - map <- add_symbol_layer( - map, - id = paste0(id, "-cluster-count"), - source = cluster_source, - source_layer = cluster_source_layer, - filter = c("has", "point_count"), - text_field = count_label_expr, - text_size = 12, - text_color = cluster_options$text_color, - visibility = visibility - ) - - # Add unclustered points - map <- add_layer( + map <- .add_cluster_layers( map, id = id, - type = "circle", - source = cluster_source, - source_layer = cluster_source_layer, - filter = list("!", c("has", "point_count")), + source = source, + source_layer = source_layer, + cluster_options = cluster_options, + point_type = "circle", paint = paint, layout = layout, + visibility = visibility, + circle_color = circle_color, popup = popup, tooltip = tooltip, tooltip_style = tooltip_style, @@ -1471,7 +2220,7 @@ add_raster_layer <- function( #' @param hover_options A named list of options for highlighting features in the layer on hover. Not all elements of SVG icons can be styled. #' @param before_id The name of the layer that this layer appears "before", allowing you to insert layers below other layers in your basemap (e.g. labels). #' @param filter An optional filter expression to subset features in the layer. -#' @param cluster_options A list of options for clustering symbols, created by the `cluster_options()` function. Two input shapes are supported: pass an `sf`/`sfc` object as `source` for native live clustering (a GeoJSON source is injected automatically), or pass the id of an already-registered vector source (e.g. from `add_pmtiles_source()`) along with `source_layer` to use pre-clustered vector tiles such as those produced by the freestiler package. In the latter case the cluster-count label is abbreviated client-side via [number_format()]. +#' @param cluster_options A list of options for clustering symbols, created by the `cluster_options()` function. Two input shapes are supported: pass an `sf`/`sfc` object as `source` for native live clustering (a GeoJSON source is injected automatically), or pass the id of an already-registered vector source (e.g. from `add_pmtiles_source()`) along with `source_layer` to use pre-clustered vector tiles such as those produced by the freestiler package. In the latter case the cluster-count label is abbreviated client-side from `point_count`. Set `donut_column` in `cluster_options()` to draw clusters as donut charts of category shares; see [cluster_options()] for the properties pre-clustered tiles need. #' #' **Updating a clustered layer in Shiny:** the shortcut creates three layers (`"id"`, `"id-clusters"`, `"id-cluster-count"`) on top of one source. For reactive data updates the recommended pattern is [set_source()], which replaces the source's data and lets Mapbox/MapLibre re-cluster automatically without tearing down the layers: `mapboxgl_proxy("map") |> set_source(layer_id = "pts", source = filtered())`. If you need to remove a clustered layer entirely (e.g. before switching backends), pass the full trio to [clear_layer()]: `clear_layer(proxy, c("pts", "pts-clusters", "pts-cluster-count"))`. #' @@ -1714,107 +2463,16 @@ add_symbol_layer <- function( if (!is.null(visibility)) layout[["visibility"]] <- visibility if (!is.null(cluster_options)) { - # Dispatch on source shape. See add_circle_layer() for notes. - if (inherits(source, c("sf", "sfc"))) { - map <- add_source( - map, - id = id, - data = source, - cluster = TRUE, - clusterMaxZoom = cluster_options$max_zoom, - clusterRadius = cluster_options$cluster_radius - ) - cluster_source <- id - cluster_source_layer <- NULL - is_native_cluster <- TRUE - } else if ( - is.character(source) && - length(source) == 1 && - !is.null(source_layer) - ) { - cluster_source <- source - cluster_source_layer <- source_layer - is_native_cluster <- FALSE - .warn_mapbox_pmtiles_cluster(map) - } else { - rlang::abort(c( - "`cluster_options` requires one of the following shapes:", - i = "`source` = an sf/sfc object (live clustering is applied automatically), or", - i = "`source` = an existing source id (string) + `source_layer` = the source-layer name, for pre-clustered vector tiles.", - i = "To cluster against a pre-registered clustered GeoJSON source referenced by id, build the three cluster layers manually with `add_layer()`." - )) - } - - count_label_expr <- .cluster_count_label( - cluster_options$count_format %||% "abbreviated", - is_native_cluster - ) - - # Add clustered symbols layer - map <- add_layer( - map, - id = paste0(id, "-clusters"), - type = "circle", - source = cluster_source, - source_layer = cluster_source_layer, - filter = c("has", "point_count"), - paint = list( - "circle-color" = step_expr( - column = "point_count", - base = cluster_options$color_stops[1], - stops = cluster_options$color_stops[-1], - values = cluster_options$count_stops[-1] - ), - "circle-radius" = step_expr( - column = "point_count", - base = cluster_options$radius_stops[1], - stops = cluster_options$radius_stops[-1], - values = cluster_options$count_stops[-1] - ) - ), - layout = list(visibility = visibility) - ) - - # Add optional paint properties if they are not NULL - optional_paint <- list( - "circle-blur" = cluster_options$circle_blur, - "circle-opacity" = cluster_options$circle_opacity, - "circle-stroke-color" = cluster_options$circle_stroke_color, - "circle-stroke-opacity" = cluster_options$circle_stroke_opacity, - "circle-stroke-width" = cluster_options$circle_stroke_width - ) - - for (prop in names(optional_paint)) { - if (!is.null(optional_paint[[prop]])) { - map$x$layers[[length(map$x$layers)]]$paint[[ - prop - ]] <- optional_paint[[prop]] - } - } - - # Add cluster count labels - map <- add_symbol_layer( - map, - id = paste0(id, "-cluster-count"), - source = cluster_source, - source_layer = cluster_source_layer, - filter = c("has", "point_count"), - text_field = count_label_expr, - text_size = 12, - text_color = cluster_options$text_color, - visibility = visibility - ) - - # Add unclustered symbols - map <- add_layer( + map <- .add_cluster_layers( map, id = id, - type = "symbol", - source = cluster_source, - source_layer = cluster_source_layer, - filter = list("!", c("has", "point_count")), + source = source, + source_layer = source_layer, + cluster_options = cluster_options, + point_type = "symbol", paint = paint, layout = layout, + visibility = visibility, popup = popup, tooltip = tooltip, tooltip_style = tooltip_style, diff --git a/R/sources.R b/R/sources.R index 3c2c6d3a..6be9fe46 100644 --- a/R/sources.R +++ b/R/sources.R @@ -742,7 +742,7 @@ add_video_source <- function(map, id, urls, coordinates) { #' @param map A map object created by the `mapboxgl` or `maplibre` function. #' @param id A unique ID for the source. #' @param url A URL pointing to the PMTiles archive. -#' @param source_type The source type for MapLibre maps. Either "vector" (default) or "raster". +#' @param source_type The source type. Either "vector" (default) or "raster". #' @param maxzoom Only used when source_type is "raster". The maximum zoom level for the PMTiles source. Defaults to 22. #' @param tilesize Only used when source_type is "raster". The size of the tiles in the PMTiles source. Defaults to 256. #' @param promote_id An optional property name to use as the feature ID. This is required for hover effects on vector sources. @@ -796,16 +796,17 @@ add_pmtiles_source <- function( inherits(map, "mapboxgl_compare_proxy") if (is_mapbox) { - # Mapbox GL JS v3.21.0+ has native PMTiles support for vector tiles + # Mapbox GL JS detects .pmtiles URLs natively via its TileProvider API + # (vector since v3.21.0, raster since v3.23.0) if (source_type == "raster") { - # Raster PMTiles still require the custom source type source <- list( id = id, - type = "pmtile-source", - url = url + type = "raster", + url = url, + tileSize = tilesize, + maxzoom = maxzoom ) } else { - # Vector PMTiles use native TileProvider API (auto-detects .pmtiles URLs) source <- list( id = id, type = "vector", diff --git a/inst/htmlwidgets/lib/legend-interactivity/legend-interactivity.js b/inst/htmlwidgets/lib/legend-interactivity/legend-interactivity.js index feba4840..422e46e5 100644 --- a/inst/htmlwidgets/lib/legend-interactivity/legend-interactivity.js +++ b/inst/htmlwidgets/lib/legend-interactivity/legend-interactivity.js @@ -454,9 +454,6 @@ function initCategoricalLegend(map, mapId, legendElement, filterColumn, config) interactiveState.filterColumn = filterColumn; }); - // Store original colors for each item - var originalColors = {}; - // Add click handlers to each item items.forEach(function (item, idx) { var displayValue = item.getAttribute("data-value") || String(displayValues[idx]); @@ -464,19 +461,6 @@ function initCategoricalLegend(map, mapId, legendElement, filterColumn, config) item.setAttribute("data-index", idx); item.setAttribute("data-enabled", "true"); - // Store original color - try multiple selectors for different patch types - var colorSpan = item.querySelector(".legend-color") || - item.querySelector(".legend-shape-svg") || - item.querySelector(".legend-shape-custom"); - if (colorSpan) { - originalColors[idx] = colorSpan.style.backgroundColor || - colorSpan.getAttribute("fill") || - config.colors[idx]; - } else { - // Fallback to config colors - originalColors[idx] = config.colors[idx]; - } - item.addEventListener("click", function (e) { e.preventDefault(); e.stopPropagation(); @@ -486,18 +470,13 @@ function initCategoricalLegend(map, mapId, legendElement, filterColumn, config) item.setAttribute("data-enabled", String(newState)); + // Disabled opacity/grayscale is handled by the shared CSS. Leave + // patch paints untouched: an SVG background colors its rectangular + // viewport, not its polygon/path, and corrupts hex/custom patches. if (newState) { enabledIndices.add(idx); - // Restore original color - if (colorSpan) { - colorSpan.style.backgroundColor = originalColors[idx]; - } } else { enabledIndices.delete(idx); - // Grey out - if (colorSpan) { - colorSpan.style.backgroundColor = "#cccccc"; - } } // Apply filter to all associated layers @@ -550,14 +529,8 @@ function initCategoricalLegend(map, mapId, legendElement, filterColumn, config) // Add reset button addResetButton(legendElement, function () { - items.forEach(function (item, i) { + items.forEach(function (item) { item.setAttribute("data-enabled", "true"); - var colorSpan = item.querySelector(".legend-color") || - item.querySelector(".legend-shape-svg") || - item.querySelector(".legend-shape-custom"); - if (colorSpan && originalColors[i] !== undefined) { - colorSpan.style.backgroundColor = originalColors[i]; - } }); enabledIndices.clear(); for (var i = 0; i < numCategories; i++) { diff --git a/inst/htmlwidgets/lib/mapbox-pmtiles/pmtiles-source-optimized-v2.js b/inst/htmlwidgets/lib/mapbox-pmtiles/pmtiles-source-optimized-v2.js deleted file mode 100644 index 48f9b3bf..00000000 --- a/inst/htmlwidgets/lib/mapbox-pmtiles/pmtiles-source-optimized-v2.js +++ /dev/null @@ -1,1108 +0,0 @@ -/** - * mapbox-pmtiles v1.1.0 - Optimized Version with Lifecycle Management - * Original source: https://github.com/am2222/mapbox-pmtiles by Majid Hojati - * License: MIT - * - * This is an optimized version of the mapbox-pmtiles library that provides - * better performance for large datasets through: - * - Configurable resource management - * - Instance-scoped worker pools and caches - * - Proper lifecycle management and cleanup - * - Reference counting for shared resources - * - Enhanced error handling and timeouts - * - Memory optimization with size-based LRU caches - * - * Last updated: 2025 - */ - -// Note: This version assumes mapboxgl and pmtiles are already loaded as globals -(function (global) { - "use strict"; - - // Helper functions - var __pow = Math.pow; - var __async = (__this, __arguments, generator) => { - return new Promise((resolve, reject) => { - var fulfilled = (value) => { - try { - step(generator.next(value)); - } catch (e) { - reject(e); - } - }; - var rejected = (value) => { - try { - step(generator.throw(value)); - } catch (e) { - reject(e); - } - }; - var step = (x2) => - x2.done - ? resolve(x2.value) - : Promise.resolve(x2.value).then(fulfilled, rejected); - step((generator = generator.apply(__this, __arguments)).next()); - }); - }; - - // Check dependencies - if (typeof mapboxgl === "undefined") { - console.error("mapbox-pmtiles: Mapbox GL JS is not loaded"); - return; - } - if (typeof pmtiles === "undefined") { - console.error("mapbox-pmtiles: PMTiles library is not loaded"); - return; - } - - const VectorTileSourceImpl = mapboxgl.Style.getSourceType("vector"); - const SOURCE_TYPE = "pmtile-source"; - - // Global shared resources with reference counting - const GLOBAL_SHARED_RESOURCES = { - // Protocol cache - expensive to duplicate, shared with reference counting - protocolCache: new Map(), // url -> { protocol, instance, refCount } - - // Metadata cache - small and shareable - metadataCache: new Map(), // cacheKey -> data - - // Pre-calculated world sizes for common zoom levels (static, no cleanup needed) - worldSizeCache: new Array(25).fill(null).map((_, z) => Math.pow(2, z)), - - // Global cleanup registry - activeManagers: new Set(), - - // Debug/development features - debug: false, - performanceMetrics: new Map(), - }; - - /** - * Default configuration options - */ - const DEFAULT_OPTIONS = { - workerPoolSize: 4, - tileCacheSize: 1000, - tileCacheMaxMemoryMB: 100, - metadataCacheSize: 100, - enableSharedProtocols: true, - requestTimeoutMs: 30000, - enableDebugLogging: false, - enablePerformanceMetrics: false, - }; - - /** - * LRU Cache implementation with size-based eviction - */ - class LRUCache { - constructor(maxSize, maxMemoryBytes = Infinity) { - this.maxSize = maxSize; - this.maxMemoryBytes = maxMemoryBytes; - this.cache = new Map(); - this.currentMemoryBytes = 0; - } - - get(key) { - if (this.cache.has(key)) { - // Move to end (most recently used) - const value = this.cache.get(key); - this.cache.delete(key); - this.cache.set(key, value); - return value.data; - } - return undefined; - } - - set(key, data, estimatedSize = 0) { - // Remove if exists - if (this.cache.has(key)) { - const existing = this.cache.get(key); - this.currentMemoryBytes -= existing.size; - this.cache.delete(key); - } - - // Evict old entries if necessary - while ( - this.cache.size >= this.maxSize || - this.currentMemoryBytes + estimatedSize > this.maxMemoryBytes - ) { - const firstKey = this.cache.keys().next().value; - if (!firstKey) break; - - const firstValue = this.cache.get(firstKey); - this.currentMemoryBytes -= firstValue.size; - this.cache.delete(firstKey); - } - - // Add new entry - this.cache.set(key, { data, size: estimatedSize }); - this.currentMemoryBytes += estimatedSize; - } - - has(key) { - return this.cache.has(key); - } - - delete(key) { - if (this.cache.has(key)) { - const value = this.cache.get(key); - this.currentMemoryBytes -= value.size; - this.cache.delete(key); - return true; - } - return false; - } - - clear() { - this.cache.clear(); - this.currentMemoryBytes = 0; - } - - get size() { - return this.cache.size; - } - - getMemoryUsage() { - return { - entries: this.cache.size, - memoryBytes: this.currentMemoryBytes, - memoryMB: this.currentMemoryBytes / (1024 * 1024), - }; - } - } - - /** - * Resource Manager - handles per-instance resources and lifecycle - */ - class PMTilesResourceManager { - constructor(options = {}) { - this.config = { ...DEFAULT_OPTIONS, ...options }; - this.destroyed = false; - this.paused = false; - this.dispatcher = null; - - // Instance-scoped resources - this.workerPool = []; - this.workerPoolIndex = 0; - this.tileCache = new LRUCache( - this.config.tileCacheSize, - this.config.tileCacheMaxMemoryMB * 1024 * 1024, - ); - this.pendingRequests = new Map(); - this.activeRequests = new Set(); - - // Performance tracking - this.metrics = { - tilesLoaded: 0, - cacheHits: 0, - cacheMisses: 0, - memoryPeakMB: 0, - averageLoadTimeMs: 0, - }; - - // Register for global cleanup - GLOBAL_SHARED_RESOURCES.activeManagers.add(this); - - if (this.config.enableDebugLogging) { - console.log("[PMTiles] Resource manager created", this.config); - } - } - - /** - * Initialize worker pool - */ - initializeWorkerPool(dispatcher) { - if (this.destroyed) return; - - // Store dispatcher reference - if (dispatcher) { - this.dispatcher = dispatcher; - } - - if (this.workerPool.length === 0 && this.dispatcher) { - for (let i = 0; i < this.config.workerPoolSize; i++) { - try { - this.workerPool.push(this.dispatcher.getActor()); - } catch (error) { - console.warn("[PMTiles] Failed to create worker:", error); - } - } - - if (this.config.enableDebugLogging) { - console.log( - `[PMTiles] Initialized worker pool with ${this.workerPool.length} workers`, - ); - } - } - } - - /** - * Get next worker from pool (round-robin) - */ - getWorkerFromPool() { - if (this.destroyed) { - return null; - } - - // Try to initialize workers if not done yet - if (this.workerPool.length === 0 && this.dispatcher) { - this.initializeWorkerPool(this.dispatcher); - } - - if (this.workerPool.length === 0) { - if (this.config.enableDebugLogging) { - console.warn( - "[PMTiles] Worker pool is empty, dispatcher available:", - !!this.dispatcher, - ); - } - return null; - } - - const worker = this.workerPool[this.workerPoolIndex]; - this.workerPoolIndex = - (this.workerPoolIndex + 1) % this.workerPool.length; - return worker; - } - - /** - * Get or create protocol instance with reference counting - */ - getProtocol(url) { - if (this.destroyed) return null; - - if (!this.config.enableSharedProtocols) { - // Create instance-specific protocol - const protocol = new pmtiles.Protocol(); - const instance = new pmtiles.PMTiles(url); - protocol.add(instance); - return { protocol, instance }; - } - - // Use shared protocol with reference counting - if (!GLOBAL_SHARED_RESOURCES.protocolCache.has(url)) { - const protocol = new pmtiles.Protocol(); - const instance = new pmtiles.PMTiles(url); - protocol.add(instance); - GLOBAL_SHARED_RESOURCES.protocolCache.set(url, { - protocol, - instance, - refCount: 0, - }); - } - - const cached = GLOBAL_SHARED_RESOURCES.protocolCache.get(url); - cached.refCount++; - return cached; - } - - /** - * Release protocol reference - */ - releaseProtocol(url) { - if (!this.config.enableSharedProtocols) return; - - const cached = GLOBAL_SHARED_RESOURCES.protocolCache.get(url); - if (cached) { - cached.refCount--; - if (cached.refCount <= 0) { - GLOBAL_SHARED_RESOURCES.protocolCache.delete(url); - - if (this.config.enableDebugLogging) { - console.log(`[PMTiles] Released protocol for ${url}`); - } - } - } - } - - /** - * Cache key for tiles - */ - getTileCacheKey(url, z, x, y) { - return `${url}:${z}:${x}:${y}`; - } - - /** - * Add tile to cache with size estimation - */ - addToTileCache(key, data) { - if (this.destroyed) return; - - let estimatedSize = 0; - if (data instanceof ImageBitmap) { - // Rough estimation: width * height * 4 bytes per pixel - estimatedSize = data.width * data.height * 4; - } else if (data && data.byteLength) { - estimatedSize = data.byteLength; - } else { - estimatedSize = 10000; // Default estimate - } - - this.tileCache.set(key, data, estimatedSize); - - // Update peak memory usage - const memoryUsage = this.tileCache.getMemoryUsage(); - this.metrics.memoryPeakMB = Math.max( - this.metrics.memoryPeakMB, - memoryUsage.memoryMB, - ); - } - - /** - * Get cached metadata - */ - getCachedMetadata(cacheKey) { - return GLOBAL_SHARED_RESOURCES.metadataCache.get(cacheKey); - } - - /** - * Set cached metadata - */ - setCachedMetadata(cacheKey, data) { - GLOBAL_SHARED_RESOURCES.metadataCache.set(cacheKey, data); - } - - /** - * Pause all operations - */ - pause() { - this.paused = true; - - // Cancel all pending requests - for (const [key, request] of this.pendingRequests) { - if (request.cancel) { - request.cancel(); - } - } - this.pendingRequests.clear(); - - if (this.config.enableDebugLogging) { - console.log("[PMTiles] Resource manager paused"); - } - } - - /** - * Resume operations - */ - resume() { - this.paused = false; - - if (this.config.enableDebugLogging) { - console.log("[PMTiles] Resource manager resumed"); - } - } - - /** - * Get performance metrics - */ - getMetrics() { - return { - ...this.metrics, - tileCache: this.tileCache.getMemoryUsage(), - workerPoolSize: this.workerPool.length, - pendingRequests: this.pendingRequests.size, - isPaused: this.paused, - isDestroyed: this.destroyed, - }; - } - - /** - * Destroy and cleanup all resources - */ - destroy() { - if (this.destroyed) return; - - this.destroyed = true; - this.paused = true; - - // Cancel all pending requests - for (const [key, request] of this.pendingRequests) { - if (request.cancel) { - request.cancel(); - } - } - this.pendingRequests.clear(); - - // Clear caches - this.tileCache.clear(); - - // Clear worker pool references - this.workerPool.length = 0; - - // Remove from global registry - GLOBAL_SHARED_RESOURCES.activeManagers.delete(this); - - if (this.config.enableDebugLogging) { - console.log("[PMTiles] Resource manager destroyed", this.getMetrics()); - } - } - } - - /** - * Global cleanup function - */ - const cleanup = () => { - for (const manager of GLOBAL_SHARED_RESOURCES.activeManagers) { - manager.destroy(); - } - GLOBAL_SHARED_RESOURCES.protocolCache.clear(); - GLOBAL_SHARED_RESOURCES.metadataCache.clear(); - }; - - // Register global cleanup - if (typeof window !== "undefined") { - window.addEventListener("beforeunload", cleanup); - } - - const extend = (dest, ...sources) => { - for (const src of sources) { - for (const k in src) { - dest[k] = src[k]; - } - } - return dest; - }; - - const mercatorXFromLng = (lng) => { - return (180 + lng) / 360; - }; - - const mercatorYFromLat = (lat) => { - return ( - (180 - - (180 / Math.PI) * - Math.log(Math.tan(Math.PI / 4 + (lat * Math.PI) / 360))) / - 360 - ); - }; - - class TileBounds { - constructor(bounds, minzoom, maxzoom) { - this.bounds = mapboxgl.LngLatBounds.convert(this.validateBounds(bounds)); - this.minzoom = minzoom || 0; - this.maxzoom = maxzoom || 24; - - // Pre-calculate mercator bounds - this._mercatorBounds = { - west: mercatorXFromLng(this.bounds.getWest()), - north: mercatorYFromLat(this.bounds.getNorth()), - east: mercatorXFromLng(this.bounds.getEast()), - south: mercatorYFromLat(this.bounds.getSouth()), - }; - } - - validateBounds(bounds) { - if (!Array.isArray(bounds) || bounds.length !== 4) - return [-180, -90, 180, 90]; - return [ - Math.max(-180, bounds[0]), - Math.max(-90, bounds[1]), - Math.min(180, bounds[2]), - Math.min(90, bounds[3]), - ]; - } - - contains(tileID) { - // Use pre-calculated world size - const worldSize = - GLOBAL_SHARED_RESOURCES.worldSizeCache[tileID.z] || - Math.pow(2, tileID.z); - - // Use pre-calculated mercator bounds - const level = { - minX: Math.floor(this._mercatorBounds.west * worldSize), - minY: Math.floor(this._mercatorBounds.north * worldSize), - maxX: Math.ceil(this._mercatorBounds.east * worldSize), - maxY: Math.ceil(this._mercatorBounds.south * worldSize), - }; - - const hit = - tileID.x >= level.minX && - tileID.x < level.maxX && - tileID.y >= level.minY && - tileID.y < level.maxY; - return hit; - } - } - - class Event { - constructor(type, data = {}) { - extend(this, data); - this.type = type; - } - } - - class ErrorEvent extends Event { - constructor(error, data = {}) { - super("error", extend({ error }, data)); - } - } - - /** - * Enhanced PMTiles Source with lifecycle management - */ - class PmTilesSource extends VectorTileSourceImpl { - constructor(id, options, _dispatcher, _eventedParent) { - super(...[id, options, _dispatcher, _eventedParent]); - - // Extract PMTiles-specific options - const pmtilesOptions = { - workerPoolSize: options.workerPoolSize, - tileCacheSize: options.tileCacheSize, - tileCacheMaxMemoryMB: options.tileCacheMaxMemoryMB, - metadataCacheSize: options.metadataCacheSize, - enableSharedProtocols: options.enableSharedProtocols, - requestTimeoutMs: options.requestTimeoutMs, - enableDebugLogging: options.enableDebugLogging, - enablePerformanceMetrics: options.enablePerformanceMetrics, - }; - - // Initialize resource manager - this.resourceManager = new PMTilesResourceManager(pmtilesOptions); - - // Standard source properties - this.scheme = "xyz"; - this.roundZoom = true; - this.type = "vector"; - this.dispatcher = _dispatcher; - this.reparseOverscaled = true; - this._loaded = false; - this._dataType = "vector"; - this.id = id; - this._implementation = options; - - // Initialize worker pool - this.resourceManager.initializeWorkerPool(_dispatcher); - - if (!this._implementation) { - this.fire( - new ErrorEvent( - new Error(`Missing options for ${this.id} ${SOURCE_TYPE} source`), - ), - ); - return; - } - - const { url } = options; - this.url = url; - this.tileSize = 512; - - // Get protocol instance - this.protocolInfo = this.resourceManager.getProtocol(url); - if (!this.protocolInfo) { - this.fire( - new ErrorEvent(new Error(`Failed to create protocol for ${url}`)), - ); - return; - } - - this._protocol = this.protocolInfo.protocol; - this._instance = this.protocolInfo.instance; - this.tiles = [`pmtiles://${url}/{z}/{x}/{y}`]; - } - - static async getMetadata(url) { - // Check cache first - const cacheKey = `${url}:metadata`; - const cached = GLOBAL_SHARED_RESOURCES.metadataCache.get(cacheKey); - if (cached) { - return cached; - } - - const instance = new pmtiles.PMTiles(url); - const metadata = await instance.getMetadata(); - GLOBAL_SHARED_RESOURCES.metadataCache.set(cacheKey, metadata); - return metadata; - } - - static async getHeader(url) { - // Check cache first - const cacheKey = `${url}:header`; - const cached = GLOBAL_SHARED_RESOURCES.metadataCache.get(cacheKey); - if (cached) { - return cached; - } - - const instance = new pmtiles.PMTiles(url); - const header = await instance.getHeader(); - GLOBAL_SHARED_RESOURCES.metadataCache.set(cacheKey, header); - return header; - } - - getExtent() { - if (!this.header) - return [ - [-180, -90], - [180, 90], - ]; - const { minLon, minLat, maxLon, maxLat } = this.header; - return [minLon, minLat, maxLon, maxLat]; - } - - hasTile(tileID) { - return !this.tileBounds || this.tileBounds.contains(tileID.canonical); - } - - fixTile(tile) { - if (this.resourceManager.destroyed) return; - - if (!tile.destroy) { - tile.destroy = () => {}; - } - if (!tile.abort) { - tile.abort = () => { - tile.aborted = true; - if (tile.request && tile.request.cancel) { - tile.request.cancel(); - } - }; - } - } - - /** - * Pause tile loading - */ - pause() { - this.resourceManager.pause(); - } - - /** - * Resume tile loading - */ - resume() { - this.resourceManager.resume(); - } - - /** - * Get performance metrics - */ - getMetrics() { - return this.resourceManager.getMetrics(); - } - - /** - * Destroy source and cleanup resources - */ - destroy() { - if (this.protocolInfo && this.url) { - this.resourceManager.releaseProtocol(this.url); - } - - this.resourceManager.destroy(); - this._loaded = false; - } - - async load(callback) { - if (this.resourceManager.destroyed) { - const error = new Error("Source has been destroyed"); - this.fire(new ErrorEvent(error)); - if (callback) callback(error); - return; - } - - this._loaded = false; - this.fire(new Event("dataloading", { dataType: "source" })); - - // Check metadata cache first - const headerKey = `${this.url}:header`; - const metadataKey = `${this.url}:metadata`; - - let header, tileJSON; - - const cachedHeader = this.resourceManager.getCachedMetadata(headerKey); - const cachedMetadata = - this.resourceManager.getCachedMetadata(metadataKey); - - if (cachedHeader && cachedMetadata) { - header = cachedHeader; - tileJSON = cachedMetadata; - } else { - try { - // Load and cache - [header, tileJSON] = await Promise.all([ - this._instance.getHeader(), - this._instance.getMetadata(), - ]); - this.resourceManager.setCachedMetadata(headerKey, header); - this.resourceManager.setCachedMetadata(metadataKey, tileJSON); - } catch (error) { - this.fire(new ErrorEvent(error)); - if (callback) callback(error); - return; - } - } - - try { - extend(this, tileJSON); - this.header = header; - const { tileType, minZoom, maxZoom, minLon, minLat, maxLon, maxLat } = - header; - const requiredVariables = [ - minZoom, - maxZoom, - minLon, - minLat, - maxLon, - maxLat, - ]; - - if ( - !requiredVariables.includes(void 0) && - !requiredVariables.includes(null) - ) { - this.tileBounds = new TileBounds( - [minLon, minLat, maxLon, maxLat], - minZoom, - maxZoom, - ); - this.minzoom = minZoom; - this.maxzoom = maxZoom; - } - - if (this.maxzoom == void 0) { - console.warn( - "The maxzoom parameter is not defined in the source json. This can cause memory leak. So make sure to define maxzoom in the layer", - ); - } - - this.minzoom = Number.parseInt(this.minzoom.toString()) || 0; - this.maxzoom = Number.parseInt(this.maxzoom.toString()) || 0; - this._loaded = true; - this.tileType = tileType; - - switch (tileType) { - case pmtiles.TileType.Png: - this.contentType = "image/png"; - break; - case pmtiles.TileType.Jpeg: - this.contentType = "image/jpeg"; - break; - case pmtiles.TileType.Webp: - this.contentType = "image/webp"; - break; - case pmtiles.TileType.Avif: - this.contentType = "image/avif"; - break; - case pmtiles.TileType.Mvt: - this.contentType = "application/vnd.mapbox-vector-tile"; - break; - case pmtiles.TileType.Mlt: - this.contentType = "application/vnd.mapbox-vector-tile"; - break; - } - - if ( - [pmtiles.TileType.Jpeg, pmtiles.TileType.Png].includes(this.tileType) - ) { - this.loadTile = this.loadRasterTile; - this.type = "raster"; - } else if ( - this.tileType === pmtiles.TileType.Mvt || - this.tileType === pmtiles.TileType.Mlt - ) { - this.loadTile = this.loadVectorTile; - this.type = "vector"; - } else { - this.fire(new ErrorEvent(new Error("Unsupported Tile Type"))); - } - - this.fire( - new Event("data", { dataType: "source", sourceDataType: "metadata" }), - ); - this.fire( - new Event("data", { dataType: "source", sourceDataType: "content" }), - ); - } catch (err2) { - this.fire(new ErrorEvent(err2)); - if (callback) callback(err2); - } - } - - loaded() { - return this._loaded && !this.resourceManager.destroyed; - } - - loadVectorTile(tile, callback) { - if (this.resourceManager.destroyed || this.resourceManager.paused) { - return callback(null); - } - - const startTime = Date.now(); - var _a2, _b2, _c; - - const done = (err2, data) => { - var _a3, _b3; - delete tile.request; - - // Update metrics - this.resourceManager.metrics.tilesLoaded++; - const loadTime = Date.now() - startTime; - this.resourceManager.metrics.averageLoadTimeMs = - (this.resourceManager.metrics.averageLoadTimeMs + loadTime) / 2; - - if (tile.aborted) return callback(null); - - // Handle abort errors gracefully - if (err2 && err2.name === "AbortError") { - return callback(null); - } - - if (err2 && err2.status !== 404) { - return callback(err2); - } - - if (data && data.resourceTiming) - tile.resourceTiming = data.resourceTiming; - if ( - ((_a3 = this.map) == null ? void 0 : _a3._refreshExpiredTiles) && - data - ) - tile.setExpiryData(data); - tile.loadVectorData( - data, - (_b3 = this.map) == null ? void 0 : _b3.painter, - ); - callback(null); - - if (tile.reloadCallback) { - this.loadVectorTile(tile, tile.reloadCallback); - tile.reloadCallback = null; - } - }; - - const url = - (_a2 = this.map) == null - ? void 0 - : _a2._requestManager.normalizeTileURL( - tile.tileID.canonical.url(this.tiles, this.scheme), - ); - const request = - (_b2 = this.map) == null - ? void 0 - : _b2._requestManager.transformRequest(url, "Tile"); - - const params = { - request, - data: {}, - uid: tile.uid, - tileID: tile.tileID, - tileZoom: tile.tileZoom, - zoom: tile.tileID.overscaledZ, - tileSize: this.tileSize * tile.tileID.overscaleFactor(), - type: "vector", - source: this.id, - scope: this.scope, - showCollisionBoxes: - (_c = this.map) == null ? void 0 : _c.showCollisionBoxes, - promoteId: this.promoteId, - isSymbolTile: tile.isSymbolTile, - extraShadowCaster: tile.isExtraShadowCaster, - }; - - const afterLoad = (error, data, cacheControl, expires) => { - if (error || !data) { - // Handle abort errors gracefully - if (error && (error.name === "AbortError" || error.code === 20)) { - return done.call(this, null); - } - done.call(this, error); - return; - } - - params.data = { - cacheControl, - expires, - rawData: data, - }; - - if (this.map._refreshExpiredTiles) - tile.setExpiryData({ cacheControl, expires }); - if (tile.actor) - tile.actor.send("loadTile", params, done.bind(this), void 0, true); - }; - - this.fixTile(tile); - if (!tile.actor || tile.state === "expired") { - // Use shared worker pool - tile.actor = this.resourceManager.getWorkerFromPool(); - - // Fallback to dispatcher if worker pool failed - if (!tile.actor && this.dispatcher) { - try { - tile.actor = this.dispatcher.getActor(); - } catch (error) { - console.warn("[PMTiles] Failed to get fallback worker:", error); - return callback(new Error("No workers available")); - } - } - - if (!tile.actor) { - return callback(new Error("No workers available")); - } - - // Create request with timeout - const requestPromise = this._protocol.tile({ ...request }, afterLoad); - - // Add timeout if configured - if (this.resourceManager.config.requestTimeoutMs > 0) { - const timeoutId = setTimeout(() => { - if (tile.request && tile.request.cancel) { - tile.request.cancel(); - } - done.call(this, new Error("Request timeout")); - }, this.resourceManager.config.requestTimeoutMs); - - const originalCancel = requestPromise.cancel; - requestPromise.cancel = () => { - clearTimeout(timeoutId); - if (originalCancel) originalCancel(); - }; - } - - tile.request = requestPromise; - } else if (tile.state === "loading") { - tile.reloadCallback = callback; - } else { - tile.request = this._protocol.tile({ ...tile, url }, afterLoad); - } - } - - loadRasterTileData(tile, data) { - if (this.resourceManager.destroyed) return; - tile.setTexture(data, this.map.painter); - } - - loadRasterTile(tile, callback) { - if (this.resourceManager.destroyed || this.resourceManager.paused) { - return callback(null); - } - - var _a2, _b2; - - // Check tile cache first - const cacheKey = this.resourceManager.getTileCacheKey( - this.url, - tile.tileID.canonical.z, - tile.tileID.canonical.x, - tile.tileID.canonical.y, - ); - - if (this.resourceManager.tileCache.has(cacheKey)) { - this.resourceManager.metrics.cacheHits++; - const cachedData = this.resourceManager.tileCache.get(cacheKey); - this.loadRasterTileData(tile, cachedData); - tile.state = "loaded"; - return callback(null); - } - - this.resourceManager.metrics.cacheMisses++; - - const done = ({ data, cacheControl, expires }) => { - delete tile.request; - if (tile.aborted) return callback(null); - if (data === null || data === void 0) { - const emptyImage = { - width: this.tileSize, - height: this.tileSize, - data: null, - }; - this.loadRasterTileData(tile, emptyImage); - tile.state = "loaded"; - return callback(null); - } - - if (data && data.resourceTiming) - tile.resourceTiming = data.resourceTiming; - if (this.map._refreshExpiredTiles) - tile.setExpiryData({ cacheControl, expires }); - - // Optimized raster tile loading - try direct ArrayBuffer first - const arrayBuffer = data.buffer || data; - window - .createImageBitmap(arrayBuffer) - .then((imageBitmap) => { - // Cache the decoded image - this.resourceManager.addToTileCache(cacheKey, imageBitmap); - - this.loadRasterTileData(tile, imageBitmap); - tile.state = "loaded"; - callback(null); - }) - .catch((error) => { - // Fallback to blob method - const blob = new window.Blob([new Uint8Array(data)], { - type: this.contentType, - }); - window - .createImageBitmap(blob) - .then((imageBitmap) => { - this.resourceManager.addToTileCache(cacheKey, imageBitmap); - this.loadRasterTileData(tile, imageBitmap); - tile.state = "loaded"; - callback(null); - }) - .catch((error) => { - tile.state = "errored"; - return callback( - new Error(`Can't decode image for ${this.id}: ${error}`), - ); - }); - }); - }; - - const url = - (_a2 = this.map) == null - ? void 0 - : _a2._requestManager.normalizeTileURL( - tile.tileID.canonical.url(this.tiles, this.scheme), - ); - const request = - (_b2 = this.map) == null - ? void 0 - : _b2._requestManager.transformRequest(url, "Tile"); - - this.fixTile(tile); - const controller = new AbortController(); - - // Add timeout if configured - let timeoutId; - if (this.resourceManager.config.requestTimeoutMs > 0) { - timeoutId = setTimeout(() => { - controller.abort(); - }, this.resourceManager.config.requestTimeoutMs); - } - - tile.request = { - cancel: () => { - if (timeoutId) clearTimeout(timeoutId); - controller.abort(); - }, - }; - - this._protocol - .tile(request, controller) - .then(done.bind(this)) - .catch((error) => { - if (timeoutId) clearTimeout(timeoutId); - - // Handle abort errors gracefully - if (error.name === "AbortError" || error.code === 20) { - delete tile.request; - return callback(null); - } - tile.state = "errored"; - callback(error); - }); - } - } - - PmTilesSource.SOURCE_TYPE = SOURCE_TYPE; - - // Expose cleanup function - PmTilesSource.cleanup = cleanup; - - // Export to global scope - global.MapboxPmTilesSource = PmTilesSource; - global.PMTILES_SOURCE_TYPE = SOURCE_TYPE; - global.PMTilesResourceManager = PMTilesResourceManager; -})(typeof window !== "undefined" ? window : this); diff --git a/inst/htmlwidgets/lib/mapbox-pmtiles/pmtiles-source.js b/inst/htmlwidgets/lib/mapbox-pmtiles/pmtiles-source.js deleted file mode 100644 index 21307490..00000000 --- a/inst/htmlwidgets/lib/mapbox-pmtiles/pmtiles-source.js +++ /dev/null @@ -1,459 +0,0 @@ -/** - * mapbox-pmtiles v1.0.53 - * Original source: https://github.com/am2222/mapbox-pmtiles - * License: MIT - * - * This is a vendored copy of the mapbox-pmtiles library that provides - * PMTiles support for Mapbox GL JS by implementing a custom source type. - * - * Last updated: 2024 - */ - -// Note: This version assumes mapboxgl and pmtiles are already loaded as globals -(function (global) { - "use strict"; - - // Helper functions - var __pow = Math.pow; - var __async = (__this, __arguments, generator) => { - return new Promise((resolve, reject) => { - var fulfilled = (value) => { - try { - step(generator.next(value)); - } catch (e) { - reject(e); - } - }; - var rejected = (value) => { - try { - step(generator.throw(value)); - } catch (e) { - reject(e); - } - }; - var step = (x2) => - x2.done - ? resolve(x2.value) - : Promise.resolve(x2.value).then(fulfilled, rejected); - step((generator = generator.apply(__this, __arguments)).next()); - }); - }; - - // Check dependencies - if (typeof mapboxgl === "undefined") { - console.error("mapbox-pmtiles: Mapbox GL JS is not loaded"); - return; - } - if (typeof pmtiles === "undefined") { - console.error("mapbox-pmtiles: PMTiles library is not loaded"); - return; - } - - const VectorTileSourceImpl = mapboxgl.Style.getSourceType("vector"); - const SOURCE_TYPE = "pmtile-source"; - - const extend = (dest, ...sources) => { - for (const src of sources) { - for (const k in src) { - dest[k] = src[k]; - } - } - return dest; - }; - - const mercatorXFromLng = (lng) => { - return (180 + lng) / 360; - }; - - const mercatorYFromLat = (lat) => { - return ( - (180 - - (180 / Math.PI) * - Math.log(Math.tan(Math.PI / 4 + (lat * Math.PI) / 360))) / - 360 - ); - }; - - class TileBounds { - constructor(bounds, minzoom, maxzoom) { - this.bounds = mapboxgl.LngLatBounds.convert(this.validateBounds(bounds)); - this.minzoom = minzoom || 0; - this.maxzoom = maxzoom || 24; - } - - validateBounds(bounds) { - if (!Array.isArray(bounds) || bounds.length !== 4) - return [-180, -90, 180, 90]; - return [ - Math.max(-180, bounds[0]), - Math.max(-90, bounds[1]), - Math.min(180, bounds[2]), - Math.min(90, bounds[3]), - ]; - } - - contains(tileID) { - const worldSize = Math.pow(2, tileID.z); - const level = { - minX: Math.floor(mercatorXFromLng(this.bounds.getWest()) * worldSize), - minY: Math.floor(mercatorYFromLat(this.bounds.getNorth()) * worldSize), - maxX: Math.ceil(mercatorXFromLng(this.bounds.getEast()) * worldSize), - maxY: Math.ceil(mercatorYFromLat(this.bounds.getSouth()) * worldSize), - }; - const hit = - tileID.x >= level.minX && - tileID.x < level.maxX && - tileID.y >= level.minY && - tileID.y < level.maxY; - return hit; - } - } - - class Event { - constructor(type, data = {}) { - extend(this, data); - this.type = type; - } - } - - class ErrorEvent extends Event { - constructor(error, data = {}) { - super("error", extend({ error }, data)); - } - } - - class PmTilesSource extends VectorTileSourceImpl { - constructor(id, options, _dispatcher, _eventedParent) { - super(...[id, options, _dispatcher, _eventedParent]); - this.scheme = "xyz"; - this.roundZoom = true; - this.type = "vector"; - this.dispatcher = void 0; - this.reparseOverscaled = true; - this._loaded = false; - this._dataType = "vector"; - this.id = id; - this._dataType = "vector"; - this.dispatcher = _dispatcher; - this._implementation = options; - - if (!this._implementation) { - this.fire( - new ErrorEvent( - new Error(`Missing options for ${this.id} ${SOURCE_TYPE} source`), - ), - ); - } - - const { url } = options; - this.reparseOverscaled = true; - this.scheme = "xyz"; - this.tileSize = 512; - this._loaded = false; - this.type = "vector"; - this._protocol = new pmtiles.Protocol(); - this.tiles = [`pmtiles://${url}/{z}/{x}/{y}`]; - const pmtilesInstance = new pmtiles.PMTiles(url); - this._protocol.add(pmtilesInstance); - this._instance = pmtilesInstance; - } - - static async getMetadata(url) { - const instance = new pmtiles.PMTiles(url); - return instance.getMetadata(); - } - - static async getHeader(url) { - const instance = new pmtiles.PMTiles(url); - return instance.getHeader(); - } - - getExtent() { - if (!this.header) - return [ - [-180, -90], - [180, 90], - ]; - const { minLon, minLat, maxLon, maxLat } = this.header; - return [minLon, minLat, maxLon, maxLat]; - } - - hasTile(tileID) { - return !this.tileBounds || this.tileBounds.contains(tileID.canonical); - } - - fixTile(tile) { - if (!tile.destroy) { - tile.destroy = () => {}; - } - } - - async load(callback) { - this._loaded = false; - this.fire(new Event("dataloading", { dataType: "source" })); - - return Promise.all([ - this._instance.getHeader(), - this._instance.getMetadata(), - ]) - .then(([header, tileJSON]) => { - extend(this, tileJSON); - this.header = header; - const { - specVersion, - clustered, - tileType, - minZoom, - maxZoom, - minLon, - minLat, - maxLon, - maxLat, - centerZoom, - centerLon, - centerLat, - } = header; - const requiredVariables = [ - minZoom, - maxZoom, - minLon, - minLat, - maxLon, - maxLat, - ]; - - if ( - !requiredVariables.includes(void 0) && - !requiredVariables.includes(null) - ) { - this.tileBounds = new TileBounds( - [minLon, minLat, maxLon, maxLat], - minZoom, - maxZoom, - ); - this.minzoom = minZoom; - this.maxzoom = maxZoom; - } - - if (this.maxzoom == void 0) { - console.warn( - "The maxzoom parameter is not defined in the source json. This can cause memory leak. So make sure to define maxzoom in the layer", - ); - } - - this.minzoom = Number.parseInt(this.minzoom.toString()) || 0; - this.maxzoom = Number.parseInt(this.maxzoom.toString()) || 0; - this._loaded = true; - this.tileType = tileType; - - switch (tileType) { - case pmtiles.TileType.Png: - this.contentType = "image/png"; - break; - case pmtiles.TileType.Jpeg: - this.contentType = "image/jpeg"; - break; - case pmtiles.TileType.Webp: - this.contentType = "image/webp"; - break; - case pmtiles.TileType.Avif: - this.contentType = "image/avif"; - break; - case pmtiles.TileType.Mvt: - this.contentType = "application/vnd.mapbox-vector-tile"; - break; - } - - if ( - [pmtiles.TileType.Jpeg, pmtiles.TileType.Png].includes( - this.tileType, - ) - ) { - this.loadTile = this.loadRasterTile; - this.type = "raster"; - } else if (this.tileType === pmtiles.TileType.Mvt) { - this.loadTile = this.loadVectorTile; - this.type = "vector"; - } else { - this.fire(new ErrorEvent(new Error("Unsupported Tile Type"))); - } - - this.fire( - new Event("data", { - dataType: "source", - sourceDataType: "metadata", - }), - ); - this.fire( - new Event("data", { - dataType: "source", - sourceDataType: "content", - }), - ); - }) - .catch((err2) => { - this.fire(new ErrorEvent(err2)); - if (callback) callback(err2); - }); - } - - loaded() { - return this._loaded; - } - - loadVectorTile(tile, callback) { - var _a2, _b2, _c; - const done = (err2, data) => { - var _a3, _b3; - delete tile.request; - if (tile.aborted) return callback(null); - if (err2 && err2.status !== 404) { - return callback(err2); - } - if (data && data.resourceTiming) - tile.resourceTiming = data.resourceTiming; - if ( - ((_a3 = this.map) == null ? void 0 : _a3._refreshExpiredTiles) && - data - ) - tile.setExpiryData(data); - tile.loadVectorData( - data, - (_b3 = this.map) == null ? void 0 : _b3.painter, - ); - callback(null); - if (tile.reloadCallback) { - this.loadVectorTile(tile, tile.reloadCallback); - tile.reloadCallback = null; - } - }; - - const url = - (_a2 = this.map) == null - ? void 0 - : _a2._requestManager.normalizeTileURL( - tile.tileID.canonical.url(this.tiles, this.scheme), - ); - const request = - (_b2 = this.map) == null - ? void 0 - : _b2._requestManager.transformRequest(url, "Tile"); - const params = { - request, - data: {}, - uid: tile.uid, - tileID: tile.tileID, - tileZoom: tile.tileZoom, - zoom: tile.tileID.overscaledZ, - tileSize: this.tileSize * tile.tileID.overscaleFactor(), - type: "vector", - source: this.id, - scope: this.scope, - showCollisionBoxes: - (_c = this.map) == null ? void 0 : _c.showCollisionBoxes, - promoteId: this.promoteId, - isSymbolTile: tile.isSymbolTile, - extraShadowCaster: tile.isExtraShadowCaster, - }; - - const afterLoad = (error, data, cacheControl, expires) => { - if (error || !data) { - done.call(this, error); - return; - } - params.data = { - cacheControl, - expires, - rawData: data, - }; - if (this.map._refreshExpiredTiles) - tile.setExpiryData({ cacheControl, expires }); - if (tile.actor) - tile.actor.send("loadTile", params, done.bind(this), void 0, true); - }; - - this.fixTile(tile); - if (!tile.actor || tile.state === "expired") { - tile.actor = this._tileWorkers[url] = - this._tileWorkers[url] || this.dispatcher.getActor(); - tile.request = this._protocol.tile({ ...request }, afterLoad); - } else if (tile.state === "loading") { - tile.reloadCallback = callback; - } else { - tile.request = this._protocol.tile({ ...tile, url }, afterLoad); - } - } - - loadRasterTileData(tile, data) { - tile.setTexture(data, this.map.painter); - } - - loadRasterTile(tile, callback) { - var _a2, _b2; - const done = ({ data, cacheControl, expires }) => { - delete tile.request; - if (tile.aborted) return callback(null); - if (data === null || data === void 0) { - const emptyImage = { - width: this.tileSize, - height: this.tileSize, - data: null, - }; - this.loadRasterTileData(tile, emptyImage); - tile.state = "loaded"; - return callback(null); - } - if (data && data.resourceTiming) - tile.resourceTiming = data.resourceTiming; - if (this.map._refreshExpiredTiles) - tile.setExpiryData({ cacheControl, expires }); - const blob = new window.Blob([new Uint8Array(data)], { - type: "image/png", - }); - window - .createImageBitmap(blob) - .then((imageBitmap) => { - this.loadRasterTileData(tile, imageBitmap); - tile.state = "loaded"; - callback(null); - }) - .catch((error) => { - tile.state = "errored"; - return callback( - new Error( - `Can't infer data type for ${this.id}, only raster data supported at the moment. ${error}`, - ), - ); - }); - }; - - const url = - (_a2 = this.map) == null - ? void 0 - : _a2._requestManager.normalizeTileURL( - tile.tileID.canonical.url(this.tiles, this.scheme), - ); - const request = - (_b2 = this.map) == null - ? void 0 - : _b2._requestManager.transformRequest(url, "Tile"); - this.fixTile(tile); - const controller = new AbortController(); - tile.request = { cancel: () => controller.abort() }; - this._protocol - .tile(request, controller) - .then(done.bind(this)) - .catch((error) => { - if (error.code === 20) return; - tile.state = "errored"; - callback(error); - }); - } - } - - PmTilesSource.SOURCE_TYPE = SOURCE_TYPE; - - // Export to global scope - global.MapboxPmTilesSource = PmTilesSource; - global.PMTILES_SOURCE_TYPE = SOURCE_TYPE; -})(typeof window !== "undefined" ? window : this); diff --git a/inst/htmlwidgets/lib/mapgl-cluster-donut/cluster-donut.js b/inst/htmlwidgets/lib/mapgl-cluster-donut/cluster-donut.js new file mode 100644 index 00000000..007cd25d --- /dev/null +++ b/inst/htmlwidgets/lib/mapgl-cluster-donut/cluster-donut.js @@ -0,0 +1,211 @@ +/* Donut cluster images, drawn on demand. + * + * Donut cluster layers (cluster_options(donut_column = ...)) are symbol + * layers whose icon-image expression evaluates to a name like + * + * mapgl-donut|||,,...| + * + * where each u is a category's rounded share in resolution units. No + * image by that name exists, so the map fires `styleimagemissing`; this + * handler draws the donut on a canvas and registers it with addImage. + * + * The handler is stateless: slice colors and ring styling come from the + * layer's metadata["mapgl:donut"], so proxy-added layers, style reloads, + * and compare maps need no registry. The fingerprint in the name must + * match the layer's current spec, so a request left over from a replaced + * layer draws nothing instead of caching an image with the wrong colors. + * + * Both Mapbox GL JS and MapLibre GL JS fire styleimagemissing + * synchronously and check for the image right after the listeners run, + * so drawing and addImage must happen before the handler returns. + * + * MapLibre GL JS v6 replaces this event with setMissingStyleImageResolver. + */ +(function () { + "use strict"; + + const PREFIX = "mapgl-donut|"; + + function parseName(name) { + // The layer id is everything after the fourth "|", so ids may contain "|" + const parts = name.split("|"); + if (parts.length < 5) return null; + const radius = Number(parts[2]); + const units = parts[3].split(",").map(Number); + if (!(radius > 0) || units.some((u) => !Number.isFinite(u) || u < 0)) { + return null; + } + return { + fingerprint: parts[1], + radius: radius, + units: units, + layerId: parts.slice(4).join("|"), + }; + } + + function rgba(hex, alpha) { + const value = parseInt(hex.slice(1), 16); + return ( + "rgba(" + + ((value >> 16) & 255) + + "," + + ((value >> 8) & 255) + + "," + + (value & 255) + + "," + + (alpha == null ? 1 : alpha) + + ")" + ); + } + + function drawDonut(parsed, spec, pixelRatio) { + const radius = parsed.radius; + const strokeWidth = spec.stroke_width || 0; + const outer = radius + strokeWidth; + const size = Math.ceil(outer * 2 * pixelRatio); + const canvas = document.createElement("canvas"); + canvas.width = size; + canvas.height = size; + // willReadFrequently keeps the canvas on the CPU; without it, + // getImageData forces a GPU readback that can stall for hundreds of ms + const ctx = canvas.getContext("2d", { willReadFrequently: true }); + ctx.scale(pixelRatio, pixelRatio); + + const center = size / pixelRatio / 2; + const inner = radius * (1 - spec.width); + const total = parsed.units.reduce((a, b) => a + b, 0); + + if (total > 0) { + let start = -Math.PI / 2; + parsed.units.forEach((unit, i) => { + if (!unit || !spec.colors[i]) return; + const end = start + (unit / total) * 2 * Math.PI; + ctx.beginPath(); + ctx.arc(center, center, radius, start, end); + ctx.arc(center, center, inner, end, start, true); + ctx.closePath(); + ctx.fillStyle = rgba(spec.colors[i], spec.alphas[i]); + ctx.fill(); + start = end; + }); + } + + if (spec.fill) { + ctx.beginPath(); + ctx.arc(center, center, inner, 0, 2 * Math.PI); + ctx.fillStyle = rgba(spec.fill, spec.fill_alpha); + ctx.fill(); + } + + if (strokeWidth > 0 && spec.stroke) { + ctx.beginPath(); + ctx.arc(center, center, radius + strokeWidth / 2, 0, 2 * Math.PI); + ctx.lineWidth = strokeWidth; + ctx.strokeStyle = rgba(spec.stroke, spec.stroke_alpha); + ctx.stroke(); + } + + const image = ctx.getImageData(0, 0, size, size); + return { + width: size, + height: size, + data: new Uint8Array(image.data.buffer), + }; + } + + function layerSpec(map, layerId) { + const layer = map.getLayer(layerId); + const metadata = layer && layer.metadata; + return metadata ? metadata["mapgl:donut"] : null; + } + + function attach(map) { + if (!map || map._mapglClusterDonutAttached) return; + map._mapglClusterDonutAttached = true; + + // Memory accounting for diagnostics. `liveBytes` is a running total + // kept O(1) per image; it's reconciled against hasImage() only on + // style reloads (which can drop images) and when memory() is read. + const stats = { + names: new Map(), + liveBytes: 0, + peakBytes: 0, + createdCount: 0, + cumulativeBytes: 0, + }; + map._mapglClusterDonutStats = stats; + const warned = new Set(); + + map.on("style.load", function () { + reconcile(map, stats); + }); + + map.on("styleimagemissing", function (e) { + const name = e && e.id; + if (typeof name !== "string" || name.indexOf(PREFIX) !== 0) return; + if (map.hasImage(name)) return; + + const parsed = parseName(name); + if (!parsed) return; + const spec = layerSpec(map, parsed.layerId); + if (!spec) { + if (!warned.has(parsed.layerId)) { + warned.add(parsed.layerId); + console.warn( + "mapgl: no donut settings found for layer '" + + parsed.layerId + + "'", + ); + } + return; + } + // Stale request from a replaced layer definition + if (spec.fp !== parsed.fingerprint) return; + + try { + const pixelRatio = window.devicePixelRatio || 1; + const image = drawDonut(parsed, spec, pixelRatio); + map.addImage(name, image, { pixelRatio: pixelRatio }); + const bytes = image.data.byteLength; + stats.names.set(name, bytes); + stats.createdCount++; + stats.cumulativeBytes += bytes; + stats.liveBytes += bytes; + if (stats.liveBytes > stats.peakBytes) stats.peakBytes = stats.liveBytes; + } catch (err) { + console.warn("mapgl: failed to draw donut cluster image", err); + } + }); + } + + // Drop names the map no longer holds and recompute the running total + function reconcile(map, stats) { + let bytes = 0; + stats.names.forEach(function (size, name) { + if (map.hasImage(name)) { + bytes += size; + } else { + stats.names.delete(name); + } + }); + stats.liveBytes = bytes; + return bytes; + } + + // Snapshot of image memory for a map: images and bytes currently held, + // peak bytes held, and images and bytes generated over the map's lifetime. + function memory(map) { + const stats = map && map._mapglClusterDonutStats; + if (!stats) return null; + const retainedBytes = reconcile(map, stats); + return { + retainedCount: stats.names.size, + retainedBytes: retainedBytes, + peakBytes: stats.peakBytes, + createdCount: stats.createdCount, + cumulativeBytes: stats.cumulativeBytes, + }; + } + + window._mapglClusterDonut = { attach: attach, memory: memory }; +})(); diff --git a/inst/htmlwidgets/mapboxgl.js b/inst/htmlwidgets/mapboxgl.js index 4b76bce1..a8cb37f6 100644 --- a/inst/htmlwidgets/mapboxgl.js +++ b/inst/htmlwidgets/mapboxgl.js @@ -1909,22 +1909,6 @@ HTMLWidgets.widget({ map = null; } - // Register PMTiles source type if available - if ( - typeof MapboxPmTilesSource !== "undefined" && - typeof pmtiles !== "undefined" - ) { - try { - mapboxgl.Style.setSourceType( - PMTILES_SOURCE_TYPE, - MapboxPmTilesSource, - ); - console.log("PMTiles support enabled for Mapbox GL JS"); - } catch (e) { - console.warn("Failed to register PMTiles source type:", e); - } - } - mapboxgl.accessToken = x.access_token; map = new mapboxgl.Map({ @@ -1942,6 +1926,11 @@ HTMLWidgets.widget({ map.controls = []; map._initialStyleLoaded = false; + // Draw donut cluster images on demand + if (window._mapglClusterDonut) { + window._mapglClusterDonut.attach(map); + } + map.on("style.load", function () { // Store basemap layer IDs before user layers are added if (!map._basemapLayerIds) { @@ -2131,7 +2120,7 @@ HTMLWidgets.widget({ coordinates: source.coordinates, }); } else { - // Handle custom source types (like pmtile-source) + // Handle custom source types const sourceOptions = { type: source.type }; // Copy all properties except id @@ -2188,6 +2177,10 @@ HTMLWidgets.widget({ layerConfig["filter"] = layer.filter; } + if (layer.metadata) { + layerConfig["metadata"] = layer.metadata; + } + if (layer.before_id) { map.addLayer(layerConfig, layer.before_id); } else { @@ -3469,7 +3462,7 @@ if (HTMLWidgets.shinyMode) { }); map.addSource(message.source.id, sourceConfig); } else { - // Handle custom source types (like pmtile-source) + // Handle custom source types const sourceConfig = { type: message.source.type }; // Copy all properties except id diff --git a/inst/htmlwidgets/mapboxgl.yaml b/inst/htmlwidgets/mapboxgl.yaml index 6ffc7cb7..6c68b672 100644 --- a/inst/htmlwidgets/mapboxgl.yaml +++ b/inst/htmlwidgets/mapboxgl.yaml @@ -2,7 +2,7 @@ dependencies: - name: mapbox-gl-js version: "3.31.0" src: - href: "https://api.mapbox.com/mapbox-gl-js/v3.31.0-rc.1" + href: "https://api.mapbox.com/mapbox-gl-js/v3.31.0" script: - "mapbox-gl.js" stylesheet: @@ -66,16 +66,6 @@ dependencies: src: "htmlwidgets/lib/mapbox-gl-globe-minimap" script: - "bundle.js" - - name: pmtiles - version: 4.4.0 - src: "htmlwidgets/lib/pmtiles" - script: - - "pmtiles.js" - - name: mapbox-pmtiles - version: 1.1.0 - src: "htmlwidgets/lib/mapbox-pmtiles" - script: - - "pmtiles-source-optimized-v2.js" - name: turf version: "7.3.0" src: "htmlwidgets/lib/turf" @@ -118,3 +108,8 @@ dependencies: src: "htmlwidgets/lib/mapgl-expressions" script: - "mapgl-expressions.js" + - name: mapgl-cluster-donut + version: "1.0.0" + src: "htmlwidgets/lib/mapgl-cluster-donut" + script: + - "cluster-donut.js" diff --git a/inst/htmlwidgets/mapboxgl_compare.js b/inst/htmlwidgets/mapboxgl_compare.js index 58efbda2..3223c2a9 100644 --- a/inst/htmlwidgets/mapboxgl_compare.js +++ b/inst/htmlwidgets/mapboxgl_compare.js @@ -611,22 +611,6 @@ HTMLWidgets.widget({ return; } - // Register PMTiles source type if available - if ( - typeof MapboxPmTilesSource !== "undefined" && - typeof pmtiles !== "undefined" - ) { - try { - mapboxgl.Style.setSourceType( - PMTILES_SOURCE_TYPE, - MapboxPmTilesSource, - ); - console.log("PMTiles support enabled for Mapbox GL JS Compare"); - } catch (e) { - console.warn("Failed to register PMTiles source type:", e); - } - } - // Set position relative on container to properly contain absolutely positioned maps el.style.position = "relative"; @@ -690,6 +674,13 @@ HTMLWidgets.widget({ beforeMap = compareMaps[0]; afterMap = compareMaps[1]; + // Draw donut cluster images on demand + if (window._mapglClusterDonut) { + compareMaps.forEach(function (compareMap) { + window._mapglClusterDonut.attach(compareMap); + }); + } + // Resolve a side name ("before", "after", or "mapN") to its map. // An out-of-range "mapN" returns undefined so callers no-op rather // than mutating the wrong map; other unknown values fall back to the @@ -1038,7 +1029,7 @@ HTMLWidgets.widget({ }); map.addSource(message.source.id, sourceConfig); } else { - // Handle custom source types (like pmtile-source) + // Handle custom source types const sourceConfig = { type: message.source.type }; // Copy all properties except id @@ -2709,11 +2700,21 @@ HTMLWidgets.widget({ map.addSource(source.id, sourceConfig); } else if (source.type === "geojson") { const geojsonData = source.data; - map.addSource(source.id, { + const sourceOptions = { type: "geojson", data: geojsonData, generateId: true, - }); + }; + + // Pass through extra options (cluster, clusterRadius, + // clusterMaxZoom, clusterProperties, ...) like the main widget + for (const [key, value] of Object.entries(source)) { + if (!["id", "type", "data", "generateId"].includes(key)) { + sourceOptions[key] = value; + } + } + + map.addSource(source.id, sourceOptions); } else if (source.type === "raster") { if (source.url) { map.addSource(source.id, { @@ -2791,6 +2792,14 @@ HTMLWidgets.widget({ layerConfig["maxzoom"] = layer.maxzoom; } + if (layer.filter) { + layerConfig["filter"] = layer.filter; + } + + if (layer.metadata) { + layerConfig["metadata"] = layer.metadata; + } + if (layer.before_id) { map.addLayer(layerConfig, layer.before_id); } else { diff --git a/inst/htmlwidgets/mapboxgl_compare.yaml b/inst/htmlwidgets/mapboxgl_compare.yaml index 128af863..cfeb6793 100644 --- a/inst/htmlwidgets/mapboxgl_compare.yaml +++ b/inst/htmlwidgets/mapboxgl_compare.yaml @@ -4,10 +4,10 @@ dependencies: src: href: "https://api.mapbox.com/mapbox-gl-js/" script: - - "v3.31.0-rc.1/mapbox-gl.js" + - "v3.31.0/mapbox-gl.js" - "plugins/mapbox-gl-compare/v0.4.0/mapbox-gl-compare.js" stylesheet: - - "v3.31.0-rc.1/mapbox-gl.css" + - "v3.31.0/mapbox-gl.css" - "plugins/mapbox-gl-compare/v0.4.0/mapbox-gl-compare.css" - name: mapbox-gl-draw version: "1.5.1" @@ -63,16 +63,6 @@ dependencies: src: "htmlwidgets/lib/mapbox-gl-globe-minimap" script: - "bundle.js" - - name: pmtiles - version: 4.3.0 - src: "htmlwidgets/lib/pmtiles" - script: - - "pmtiles.js" - - name: mapbox-pmtiles - version: 1.1.0 - src: "htmlwidgets/lib/mapbox-pmtiles" - script: - - "pmtiles-source-optimized-v2.js" - name: legend-interactivity version: "1.0.0" src: "htmlwidgets/lib/legend-interactivity" @@ -85,3 +75,8 @@ dependencies: src: "htmlwidgets/lib/mapgl-expressions" script: - "mapgl-expressions.js" + - name: mapgl-cluster-donut + version: "1.0.0" + src: "htmlwidgets/lib/mapgl-cluster-donut" + script: + - "cluster-donut.js" diff --git a/inst/htmlwidgets/maplibregl.js b/inst/htmlwidgets/maplibregl.js index f84df424..efb91e3f 100644 --- a/inst/htmlwidgets/maplibregl.js +++ b/inst/htmlwidgets/maplibregl.js @@ -1964,6 +1964,11 @@ HTMLWidgets.widget({ map.controls = []; map._initialStyleLoaded = false; + // Draw donut cluster images on demand + if (window._mapglClusterDonut) { + window._mapglClusterDonut.attach(map); + } + map.on("style.load", async function () { // Store basemap layer IDs before user layers are added if (!map._basemapLayerIds) { @@ -2219,6 +2224,10 @@ HTMLWidgets.widget({ layerConfig["filter"] = layer.filter; } + if (layer.metadata) { + layerConfig["metadata"] = layer.metadata; + } + if (layer.before_id) { map.addLayer(layerConfig, layer.before_id); } else { diff --git a/inst/htmlwidgets/maplibregl.yaml b/inst/htmlwidgets/maplibregl.yaml index 5dfd3fb3..d3d1e33d 100644 --- a/inst/htmlwidgets/maplibregl.yaml +++ b/inst/htmlwidgets/maplibregl.yaml @@ -122,3 +122,8 @@ dependencies: src: "htmlwidgets/lib/mapgl-expressions" script: - "mapgl-expressions.js" + - name: mapgl-cluster-donut + version: "1.0.0" + src: "htmlwidgets/lib/mapgl-cluster-donut" + script: + - "cluster-donut.js" diff --git a/inst/htmlwidgets/maplibregl_compare.js b/inst/htmlwidgets/maplibregl_compare.js index 03da84bc..776814dc 100644 --- a/inst/htmlwidgets/maplibregl_compare.js +++ b/inst/htmlwidgets/maplibregl_compare.js @@ -697,6 +697,13 @@ HTMLWidgets.widget({ beforeMap = compareMaps[0]; afterMap = compareMaps[1]; + // Draw donut cluster images on demand + if (window._mapglClusterDonut) { + compareMaps.forEach(function (compareMap) { + window._mapglClusterDonut.attach(compareMap); + }); + } + // Resolve a side name ("before", "after", or "mapN") to its map. // An out-of-range "mapN" returns undefined so callers no-op rather // than mutating the wrong map; other unknown values fall back to the @@ -3562,11 +3569,21 @@ HTMLWidgets.widget({ } map.addSource(source.id, sourceConfig); } else if (source.type === "geojson") { - map.addSource(source.id, { + const sourceOptions = { type: "geojson", data: source.data, generateId: source.generateId !== false, - }); + }; + + // Pass through extra options (cluster, clusterRadius, + // clusterMaxZoom, clusterProperties, ...) like the main widget + for (const [key, value] of Object.entries(source)) { + if (!["id", "type", "data", "generateId"].includes(key)) { + sourceOptions[key] = value; + } + } + + map.addSource(source.id, sourceOptions); } else if (source.type === "raster") { if (source.url) { map.addSource(source.id, { @@ -3649,6 +3666,10 @@ HTMLWidgets.widget({ layerConfig["maxzoom"] = layer.maxzoom; } + if (layer.metadata) { + layerConfig["metadata"] = layer.metadata; + } + if (layer.before_id) { map.addLayer(layerConfig, layer.before_id); } else { diff --git a/inst/htmlwidgets/maplibregl_compare.yaml b/inst/htmlwidgets/maplibregl_compare.yaml index 337e6619..c25ffd83 100644 --- a/inst/htmlwidgets/maplibregl_compare.yaml +++ b/inst/htmlwidgets/maplibregl_compare.yaml @@ -95,3 +95,8 @@ dependencies: src: "htmlwidgets/lib/mapgl-expressions" script: - "mapgl-expressions.js" + - name: mapgl-cluster-donut + version: "1.0.0" + src: "htmlwidgets/lib/mapgl-cluster-donut" + script: + - "cluster-donut.js" diff --git a/man/add_circle_layer.Rd b/man/add_circle_layer.Rd index 5b3cc00e..9a386413 100644 --- a/man/add_circle_layer.Rd +++ b/man/add_circle_layer.Rd @@ -91,7 +91,7 @@ add_circle_layer( \item{filter}{An optional filter expression to subset features in the layer.} -\item{cluster_options}{A list of options for clustering circles, created by the \code{cluster_options()} function. Two input shapes are supported: pass an \code{sf}/\code{sfc} object as \code{source} for native live clustering (a GeoJSON source is injected automatically), or pass the id of an already-registered vector source (e.g. from \code{add_pmtiles_source()}) along with \code{source_layer} to use pre-clustered vector tiles such as those produced by the freestiler package. In the latter case the cluster-count label is abbreviated client-side via \code{\link[=number_format]{number_format()}}. +\item{cluster_options}{A list of options for clustering circles, created by the \code{cluster_options()} function. Two input shapes are supported: pass an \code{sf}/\code{sfc} object as \code{source} for native live clustering (a GeoJSON source is injected automatically), or pass the id of an already-registered vector source (e.g. from \code{add_pmtiles_source()}) along with \code{source_layer} to use pre-clustered vector tiles such as those produced by the freestiler package. In the latter case the cluster-count label is abbreviated client-side from \code{point_count}. Set \code{donut_column} in \code{cluster_options()} to draw clusters as donut charts of category shares; see \code{\link[=cluster_options]{cluster_options()}} for the properties pre-clustered tiles need. \strong{Updating a clustered layer in Shiny:} the shortcut creates three layers (\code{"id"}, \code{"id-clusters"}, \code{"id-cluster-count"}) on top of one source. For reactive data updates the recommended pattern is \code{\link[=set_source]{set_source()}}, which replaces the source's data and lets Mapbox/MapLibre re-cluster automatically without tearing down the layers: \code{mapboxgl_proxy("map") |> set_source(layer_id = "circles", source = filtered())}. If you need to remove a clustered layer entirely (e.g. before switching backends), pass the full trio to \code{\link[=clear_layer]{clear_layer()}}: \code{clear_layer(proxy, c("circles", "circles-clusters", "circles-cluster-count"))}.} } diff --git a/man/add_layer.Rd b/man/add_layer.Rd index b6842e36..570890fb 100644 --- a/man/add_layer.Rd +++ b/man/add_layer.Rd @@ -21,7 +21,8 @@ add_layer( before_id = NULL, filter = NULL, tooltip_style = NULL, - popup_style = NULL + popup_style = NULL, + metadata = NULL ) } \arguments{ @@ -56,6 +57,8 @@ add_layer( \item{filter}{An optional filter expression to subset features in the layer.} \item{tooltip_style, popup_style}{Optional appearance for the tooltip/popup: a preset string (\code{"light"} or \code{"dark"}) or a \code{\link[=tooltip_style]{tooltip_style()}} object. When omitted, the native (unstyled) appearance is kept.} + +\item{metadata}{An optional named list stored as the layer's \code{metadata} style property. It is not rendered, but is available to JavaScript through \verb{map.getLayer(id).metadata}.} } \value{ The modified map object with the new layer added. diff --git a/man/add_pmtiles_source.Rd b/man/add_pmtiles_source.Rd index fc6123c8..cf6ddb2b 100644 --- a/man/add_pmtiles_source.Rd +++ b/man/add_pmtiles_source.Rd @@ -22,7 +22,7 @@ add_pmtiles_source( \item{url}{A URL pointing to the PMTiles archive.} -\item{source_type}{The source type for MapLibre maps. Either "vector" (default) or "raster".} +\item{source_type}{The source type. Either "vector" (default) or "raster".} \item{maxzoom}{Only used when source_type is "raster". The maximum zoom level for the PMTiles source. Defaults to 22.} diff --git a/man/add_symbol_layer.Rd b/man/add_symbol_layer.Rd index 7a33aa5d..509d7ab0 100644 --- a/man/add_symbol_layer.Rd +++ b/man/add_symbol_layer.Rd @@ -256,7 +256,7 @@ Requires \code{symbol_placement} to be set to \code{"point"} and \code{symbol-z- \item{filter}{An optional filter expression to subset features in the layer.} -\item{cluster_options}{A list of options for clustering symbols, created by the \code{cluster_options()} function. Two input shapes are supported: pass an \code{sf}/\code{sfc} object as \code{source} for native live clustering (a GeoJSON source is injected automatically), or pass the id of an already-registered vector source (e.g. from \code{add_pmtiles_source()}) along with \code{source_layer} to use pre-clustered vector tiles such as those produced by the freestiler package. In the latter case the cluster-count label is abbreviated client-side via \code{\link[=number_format]{number_format()}}. +\item{cluster_options}{A list of options for clustering symbols, created by the \code{cluster_options()} function. Two input shapes are supported: pass an \code{sf}/\code{sfc} object as \code{source} for native live clustering (a GeoJSON source is injected automatically), or pass the id of an already-registered vector source (e.g. from \code{add_pmtiles_source()}) along with \code{source_layer} to use pre-clustered vector tiles such as those produced by the freestiler package. In the latter case the cluster-count label is abbreviated client-side from \code{point_count}. Set \code{donut_column} in \code{cluster_options()} to draw clusters as donut charts of category shares; see \code{\link[=cluster_options]{cluster_options()}} for the properties pre-clustered tiles need. \strong{Updating a clustered layer in Shiny:} the shortcut creates three layers (\code{"id"}, \code{"id-clusters"}, \code{"id-cluster-count"}) on top of one source. For reactive data updates the recommended pattern is \code{\link[=set_source]{set_source()}}, which replaces the source's data and lets Mapbox/MapLibre re-cluster automatically without tearing down the layers: \code{mapboxgl_proxy("map") |> set_source(layer_id = "pts", source = filtered())}. If you need to remove a clustered layer entirely (e.g. before switching backends), pass the full trio to \code{\link[=clear_layer]{clear_layer()}}: \code{clear_layer(proxy, c("pts", "pts-clusters", "pts-cluster-count"))}.} } diff --git a/man/cluster_options.Rd b/man/cluster_options.Rd index cfefb658..c65392f9 100644 --- a/man/cluster_options.Rd +++ b/man/cluster_options.Rd @@ -6,7 +6,7 @@ \usage{ cluster_options( max_zoom = 14, - cluster_radius = 50, + cluster_radius = NULL, color_stops = c("#51bbd6", "#f1f075", "#f28cb1"), radius_stops = c(20, 30, 40), count_stops = c(0, 100, 750), @@ -16,39 +16,104 @@ cluster_options( circle_stroke_opacity = NULL, circle_stroke_width = NULL, text_color = "black", - count_format = c("abbreviated", "grouped", "raw") + count_format = c("abbreviated", "grouped", "raw"), + donut_column = NULL, + donut_values = NULL, + donut_colors = NULL, + donut_weight = NULL, + donut_width = 0.35, + donut_fill = "white", + donut_resolution = 2 ) } \arguments{ \item{max_zoom}{The maximum zoom level at which to cluster points.} -\item{cluster_radius}{The radius of each cluster when clustering points.} +\item{cluster_radius}{The radius of each cluster when clustering points, in pixels. Defaults to 50 for circle clusters and to 1.5 times the largest of \code{radius_stops} (60 by default) for donut clusters, which keeps neighboring donuts from piling on top of each other.} -\item{color_stops}{A vector of colors for the circle color step expression.} +\item{color_stops}{A vector of colors for the circle color step expression. Ignored for donut clusters.} -\item{radius_stops}{A vector of radii for the circle radius step expression.} +\item{radius_stops}{A vector of radii for the circle radius step expression. Also sizes donut clusters.} \item{count_stops}{A vector of point counts for both color and radius step expressions.} -\item{circle_blur}{Amount to blur the circle.} +\item{circle_blur}{Amount to blur the circle. Ignored for donut clusters.} -\item{circle_opacity}{The opacity of the circle.} +\item{circle_opacity}{The opacity of the circle. For donut clusters, the opacity of the whole donut.} -\item{circle_stroke_color}{The color of the circle's stroke.} +\item{circle_stroke_color}{The color of the circle's stroke. For donut clusters, the color of the donut's outer edge (default \code{"white"}).} \item{circle_stroke_opacity}{The opacity of the circle's stroke.} -\item{circle_stroke_width}{The width of the circle's stroke.} +\item{circle_stroke_width}{The width of the circle's stroke. For donut clusters, the width of the donut's outer edge in pixels (default \code{1}).} \item{text_color}{The color to use for labels on the cluster circles.} -\item{count_format}{The formatting of the text labels on the cluster circles to represent the counts. \code{"abbreviated"} (the default) will use shortened notation, e.g. "11k". \code{"grouped"} will show comma-separated numbers, e.g. "11,000". \code{"raw"} shows the raw value.} +\item{count_format}{The formatting of the text labels on the cluster circles to represent the counts. \code{"abbreviated"} (the default) will use shortened notation, e.g. "11k", "1.7M", or "1.5B". \code{"grouped"} will show comma-separated numbers, e.g. "11,000". \code{"raw"} shows the raw value.} + +\item{donut_column}{The name of a categorical column. When set, clusters are drawn as donut charts showing the share of each category within the cluster.} + +\item{donut_values, donut_colors}{The categories to show and their colors, one color per entry of \code{donut_values}. Pass a list to group several values under one color, e.g. \code{list(c("Oil", "Gas"), "Dry Hole")}. When \code{NULL} (the default), both are taken from the layer's \code{circle_color} if it is a \code{\link[=match_expr]{match_expr()}} on \code{donut_column}; its \code{default} color becomes an "other" slice for unlisted values. Required for \code{\link[=add_symbol_layer]{add_symbol_layer()}}.} + +\item{donut_weight}{An optional numeric column to sum instead of counting points, e.g. population. The cluster label then shows the weighted total. Missing weights count as zero.} + +\item{donut_width}{The thickness of the donut ring as a fraction of its radius, between 0 and 1.} + +\item{donut_fill}{The color of the donut's center, behind the count label. Use \code{NA} for a transparent center.} + +\item{donut_resolution}{The step, in percent, to which category shares are rounded (an integer from 1 to 10). Categories whose share rounds to zero are not drawn.} } \value{ A list of cluster options. } \description{ This function creates a list of options for clustering circle layers. +Clusters are drawn as circles colored by point count, or, when +\code{donut_column} is set, as donut charts showing the mix of categories +inside each cluster. +} +\details{ +\strong{Donut clusters.} Setting \code{donut_column} computes per-category totals +for every cluster and draws each cluster as a donut chart in the category +colors. Colors are usually taken from the unclustered layer's +\code{circle_color} so that clusters and points match: + +\if{html}{\out{
}}\preformatted{add_circle_layer( + id = "people", + source = dots, + circle_color = match_expr( + "race", + values = c("White", "Black", "Hispanic", "Asian"), + stops = c("#1b9e77", "#d95f02", "#7570b3", "#e7298a") + ), + cluster_options = cluster_options(donut_column = "race") +) +}\if{html}{\out{
}} + +Build a matching legend by passing the same values and colors to +\code{\link[=add_categorical_legend]{add_categorical_legend()}}. Colors taken from \code{match_expr()} have already +had any alpha channel removed; pass \code{donut_colors} to keep transparency. +The ring shares are among the categories drawn: without an "other" +slice, points with unlisted or missing categories are left out of the +ring (but still count toward \code{point_count}, and toward the weighted +label total when \code{donut_weight} is set). + +Computing category totals makes clustering about two to three times +slower than plain clustering with ten categories. Each distinct mix of +rounded shares is drawn as its own small image and kept for the life of +the map, so a long session panning across many zoom levels accumulates +images (tens of MB over a wide sweep of a large dataset). A coarser +\code{donut_resolution} produces fewer distinct images. + +\strong{Pre-clustered vector tiles.} For tiles that are already clustered +(e.g. by freestiler), donut clusters read these properties from each +cluster feature: \code{point_count}, one \code{":"} total per +category (a count, or the sum of \code{donut_weight}), \code{":_other"} +for the other slice when there is one, and \code{":_total"}, the +weight summed over all points, when \code{donut_weight} is set. Numeric +categories must be whole numbers and are written without exponents, e.g. +\code{"code:100000"}. Missing properties count as zero. \verb{_other} and \verb{_total} +are reserved and can't be used as category values. } \examples{ cluster_options( @@ -62,4 +127,11 @@ cluster_options( circle_stroke_color = "#ffffff", circle_stroke_width = 2 ) + +# Donut clusters with explicit categories and colors +cluster_options( + donut_column = "type", + donut_values = c("Oil", "Gas", "Dry Hole"), + donut_colors = c("#1B5E20", "#fc8d59", "#cd5c5c") +) } diff --git a/man/maplibre.Rd b/man/maplibre.Rd index 8339e285..cd234d12 100644 --- a/man/maplibre.Rd +++ b/man/maplibre.Rd @@ -5,7 +5,7 @@ \title{Initialize a Maplibre GL Map} \usage{ maplibre( - style = carto_style("voyager"), + style = openfreemap_style("bright"), center = c(0, 0), zoom = 0, bearing = 0, diff --git a/man/maplibre_view.Rd b/man/maplibre_view.Rd index 9d25b949..2ce473af 100644 --- a/man/maplibre_view.Rd +++ b/man/maplibre_view.Rd @@ -10,7 +10,7 @@ maplibre_view( column = NULL, n = NULL, palette = viridisLite::viridis, - style = carto_style("positron"), + style = openfreemap_style("positron"), layer_id = "quickview", legend = TRUE, legend_position = "top-left", diff --git a/tests/testthat/fixtures/legend-patches.cjs b/tests/testthat/fixtures/legend-patches.cjs new file mode 100644 index 00000000..f35be564 --- /dev/null +++ b/tests/testthat/fixtures/legend-patches.cjs @@ -0,0 +1,74 @@ +// Event-level regression: toggle/reset may change enabled state, not patch paint. +// Tiny DOM doubles deliberately expose backgroundColor on SVG, as browsers do. +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const vm = require('node:vm'); +const context = {window: {}, document: {addEventListener() {}}, console}; +vm.createContext(context); +vm.runInContext(fs.readFileSync(process.argv[2], 'utf8'), context); +const shapes = ['square', 'circle', 'line', 'hexagon', 'custom']; +const items = shapes.map((shape, i) => { + const svg = i >= 3; + const patch = { + tagName: svg ? 'svg' : 'SPAN', + style: {backgroundColor: svg ? '' : '#619cff'}, + innerHTML: svg ? '' : '', + getAttribute: () => null + }; + const attributes = {}; + const handlers = {}; + return { + patch, shape, handlers, + getAttribute: name => attributes[name] ?? null, + setAttribute: (name, value) => { attributes[name] = String(value); }, + querySelector: () => patch, + addEventListener: (name, callback) => { handlers[name] = callback; } + }; +}); +const originalPaint = items.map(item => JSON.stringify(item.patch)); +const legend = {querySelectorAll: () => items}; +const base = ['==', ['get', 'modal_tie'], false]; +const calls = {}; +const map = {setFilter: (id, filter) => {calls[id] = JSON.parse(JSON.stringify(filter));}}; +const layerIds = ['r4', 'r5']; +const state = {interactiveFilters: {}, filterStack: {}, filters: {}}; +layerIds.forEach(id => {state.interactiveFilters[id] = {originalFilter: base};}); +context.window._mapglLayerState = {fixture: state}; +let reset; +let resetVisible; +context.addResetButton = (_legend, callback) => {reset = callback;}; +context.updateResetButton = (_legend, visible) => {resetVisible = visible;}; +context.initCategoricalLegend(map, 'fixture', legend, 'group_id', { + _layerIds: layerIds, legendId: 'fixture-legend', values: shapes, + filterValues: [1,2,3,4,5], colors: shapes.map(() => '#619cff') +}); +function click(index) { + items[index].handlers.click({preventDefault() {}, stopPropagation() {}}); +} +function paintUnchanged() { + items.forEach((item, i) => assert.equal(JSON.stringify(item.patch), originalPaint[i], item.shape)); +} +for (let round = 0; round < 3; round++) { + items.forEach((item, i) => { + click(i); + assert.equal(item.getAttribute('data-enabled'), 'false'); + assert.equal(resetVisible, true); + paintUnchanged(); + layerIds.forEach(id => { + calls[id][2][2].sort((a,b) => a-b); + assert.deepEqual(calls[id], + ['all', base, ['match', ['get','group_id'], [1,2,3,4,5].filter(v => v !== i+1), true, false]]); + }); + click(i); + assert.equal(item.getAttribute('data-enabled'), 'true'); + assert.equal(resetVisible, false); + paintUnchanged(); + }); +} +click(0); click(3); click(4); +reset(); +items.forEach(item => assert.equal(item.getAttribute('data-enabled'), 'true')); +paintUnchanged(); +assert.equal(resetVisible, false); +layerIds.forEach(id => assert.deepEqual(calls[id], base)); +console.log('Patch paint preserved through repeated toggles and reset; filters preserved.'); diff --git a/tests/testthat/test-cluster-donut.R b/tests/testthat/test-cluster-donut.R new file mode 100644 index 00000000..3a6264bb --- /dev/null +++ b/tests/testthat/test-cluster-donut.R @@ -0,0 +1,633 @@ +donut_points <- function(n = 20) { + set.seed(42) + sf::st_as_sf( + data.frame( + x = runif(n, -80, -79), + y = runif(n, 35, 36), + race = sample(c("White", "Black", "Hispanic", "Asian"), n, TRUE), + code = sample(c(100000, 2, 3), n, TRUE), + pop = runif(n, 0, 100) + ), + coords = c("x", "y"), + crs = 4326 + ) +} + +race_colors <- function(default = "#cccccc") { + match_expr( + "race", + values = c("White", "Black", "Hispanic"), + stops = c("#1b9e77", "#d95f02", "#7570b3"), + default = default + ) +} + +find_layer <- function(map, id) { + Filter(function(layer) identical(layer$id, id), map$x$layers)[[1]] +} + +to_json <- function(x) as.character(htmlwidgets:::toJSON2(x)) + +test_that("default cluster_options() output is unchanged", { + opts <- cluster_options() + expect_named( + opts, + c( + "max_zoom", + "cluster_radius", + "color_stops", + "radius_stops", + "count_stops", + "circle_blur", + "circle_opacity", + "circle_stroke_color", + "circle_stroke_opacity", + "circle_stroke_width", + "text_color", + "count_format" + ) + ) + m <- maplibre() |> + add_circle_layer("p", donut_points(), cluster_options = opts) + clusters <- find_layer(m, "p-clusters") + expect_equal(clusters$type, "circle") + expect_null(clusters$metadata) + expect_null(m$x$sources[[1]]$clusterProperties) +}) + +test_that("add_layer(metadata = NULL) adds no metadata field", { + m <- maplibre() |> add_layer("f", "fill", source = "s") + expect_false("metadata" %in% names(m$x$layers[[1]])) + m <- maplibre() |> + add_layer("f", "fill", source = "s", metadata = list(note = "x")) + expect_equal(m$x$layers[[1]]$metadata, list(note = "x")) +}) + +test_that("donut settings come from a match_expr() circle_color", { + m <- maplibre() |> + add_circle_layer( + "p", + donut_points(), + circle_color = race_colors(), + cluster_options = cluster_options(donut_column = "race") + ) + props <- m$x$sources[[1]]$clusterProperties + expect_named( + props, + c("race:White", "race:Black", "race:Hispanic", "race:_other") + ) + clusters <- find_layer(m, "p-clusters") + expect_equal(clusters$type, "symbol") + expect_true(clusters$layout[["icon-allow-overlap"]]) + spec <- clusters$metadata[["mapgl:donut"]] + expect_equal( + unlist(spec$colors), + c("#1B9E77", "#D95F02", "#7570B3", "#CCCCCC") + ) + expect_match(spec$fp, "^[0-9a-f]{10}$") + expect_match( + to_json(clusters$layout[["icon-image"]]), + paste0("mapgl-donut|", spec$fp, "|"), + fixed = TRUE + ) + expect_match( + to_json(clusters$layout[["icon-image"]]), + "|p-clusters\"", + fixed = TRUE + ) + # count label and unclustered layers keep their ids + expect_equal( + vapply(m$x$layers, `[[`, "", "id"), + c("p-clusters", "p-cluster-count", "p") + ) +}) + +test_that("the fingerprint changes with the palette and styling", { + build <- function(...) { + m <- maplibre() |> + add_circle_layer( + "p", + donut_points(), + cluster_options = cluster_options( + donut_column = "race", + donut_values = c("White", "Black"), + ... + ) + ) + find_layer(m, "p-clusters")$metadata[["mapgl:donut"]]$fp + } + base <- build(donut_colors = c("red", "blue")) + expect_equal(base, build(donut_colors = c("red", "blue"))) + expect_false(base == build(donut_colors = c("red", "green"))) + expect_false(base == build(donut_colors = c("red", "blue"), donut_width = 0.5)) + expect_false( + base == build(donut_colors = c("red", "blue"), circle_stroke_color = "black") + ) +}) + +test_that("donut counts and icons share placement rules for both source paths", { + # Mapbox's existing native-PMTiles warning is unrelated to placement rules. + local_mocked_bindings( + .warn_mapbox_pmtiles_cluster = function(map) invisible(NULL), + .package = "mapgl" + ) + for (constructor in list(maplibre, mapboxgl)) { + for (precomputed in c(FALSE, TRUE)) { + m <- constructor() |> + add_circle_layer( + "p", + source = if (precomputed) "tiles" else donut_points(), + source_layer = if (precomputed) "points" else NULL, + circle_color = race_colors(), + before_id = "labels", + cluster_options = cluster_options(donut_column = "race") + ) + icon <- find_layer(m, "p-clusters") + count <- find_layer(m, "p-cluster-count") + expect_identical(count$layout[["text-allow-overlap"]], + icon$layout[["icon-allow-overlap"]]) + expect_identical(count$layout[["text-ignore-placement"]], + icon$layout[["icon-ignore-placement"]]) + expect_true(count$layout[["text-allow-overlap"]]) + expect_true(count$layout[["text-ignore-placement"]]) + expect_identical(count$filter, icon$filter) + expect_identical(count$before_id, icon$before_id) + expect_equal(count$layout[["text-size"]], 12) + } + } +}) + +test_that("ordinary cluster count labels retain their collision defaults", { + for (precomputed in c(FALSE, TRUE)) { + m <- maplibre() |> + add_circle_layer( + "p", + source = if (precomputed) "tiles" else donut_points(), + source_layer = if (precomputed) "points" else NULL, + cluster_options = cluster_options() + ) + count <- find_layer(m, "p-cluster-count") + expect_null(count$layout[["text-allow-overlap"]]) + expect_null(count$layout[["text-ignore-placement"]]) + } +}) + +test_that("cluster expressions serialize as GL arrays", { + m <- maplibre() |> + add_circle_layer( + "p", + donut_points(), + cluster_options = cluster_options( + donut_column = "race", + donut_values = "White", + donut_colors = "red" + ) + ) + json <- to_json(m$x$sources[[1]]$clusterProperties) + expect_equal( + json, + '{"race:White":["+",["case",["==",["get","race"],"White"],1,0]]}' + ) + spec <- find_layer(m, "p-clusters")$metadata[["mapgl:donut"]] + expect_match(to_json(spec), '"colors":["#FF0000"]', fixed = TRUE) + + # "other" uses a bare label array, even for a single category + m <- maplibre() |> + add_circle_layer( + "p", + donut_points(), + circle_color = match_expr("race", values = "White", stops = "red"), + cluster_options = cluster_options(donut_column = "race") + ) + expect_equal( + to_json(m$x$sources[[1]]$clusterProperties[["race:_other"]]), + '["+",["match",["get","race"],["White"],0,1]]' + ) +}) + +test_that("weights add a total and weighted label", { + m <- maplibre() |> + add_circle_layer( + "p", + donut_points(), + circle_color = race_colors(default = NULL), + cluster_options = cluster_options( + donut_column = "race", + donut_weight = "pop" + ) + ) + props <- m$x$sources[[1]]$clusterProperties + expect_named( + props, + c("race:White", "race:Black", "race:Hispanic", "race:_total") + ) + expect_equal( + to_json(props[["race:_total"]]), + '["+",["coalesce",["get","pop"],0]]' + ) + label <- to_json(find_layer(m, "p-cluster-count")$layout[["text-field"]]) + expect_match(label, '["get","race:_total"]', fixed = TRUE) +}) + +test_that("grouped match_expr() labels expand into shared-color categories", { + m <- maplibre() |> + add_circle_layer( + "p", + donut_points(), + circle_color = match_expr( + "race", + values = list(c("White", "Black"), "Hispanic"), + stops = c("red", "blue"), + default = NULL + ), + cluster_options = cluster_options(donut_column = "race") + ) + expect_named( + m$x$sources[[1]]$clusterProperties, + c("race:White", "race:Black", "race:Hispanic") + ) + spec <- find_layer(m, "p-clusters")$metadata[["mapgl:donut"]] + expect_equal(unlist(spec$colors), c("#FF0000", "#FF0000", "#0000FF")) +}) + +test_that("explicit donut colors keep alpha without touching col2hex", { + m <- maplibre() |> + add_circle_layer( + "p", + donut_points(), + cluster_options = cluster_options( + donut_column = "race", + donut_values = c("White", "Black"), + donut_colors = c("#FF000080", "rgba(0, 0, 255, 0.25)"), + donut_fill = NA + ) + ) + spec <- find_layer(m, "p-clusters")$metadata[["mapgl:donut"]] + expect_equal(unlist(spec$colors), c("#FF0000", "#0000FF")) + expect_equal(unlist(spec$alphas), c(0.502, 0.25)) + expect_null(spec$fill) + expect_equal(mapgl:::.mapgl_col2hex("#FF000080"), "#FF0000") +}) + +test_that("category types are normalized", { + pts <- donut_points() + pts$race_f <- factor(pts$race) + m <- maplibre() |> + add_circle_layer( + "p", + pts, + cluster_options = cluster_options( + donut_column = "race_f", + donut_values = factor(c("White", "Black")), + donut_colors = c("red", "blue") + ) + ) + expect_named( + m$x$sources[[1]]$clusterProperties, + c("race_f:White", "race_f:Black") + ) + + m <- maplibre() |> + add_circle_layer( + "p", + pts, + cluster_options = cluster_options( + donut_column = "code", + donut_values = c(100000, 2), + donut_colors = c("red", "blue") + ) + ) + props <- m$x$sources[[1]]$clusterProperties + expect_named(props, c("code:100000", "code:2")) + # numeric labels stay numeric in the expression + expect_equal( + to_json(props[["code:100000"]]), + '["+",["case",["==",["get","code"],100000],1,0]]' + ) + big <- mapgl:::.donut_normalize_values(9007199254740991, "x") + expect_equal(big$keys, "9007199254740991") +}) + +test_that("donut arguments are validated", { + expect_error( + cluster_options(donut_values = "a", donut_colors = "red"), + "require `donut_column`" + ) + expect_error( + cluster_options(donut_column = "race", donut_values = "a"), + "both" + ) + expect_error( + cluster_options( + donut_column = "race", + donut_values = c("a", "b"), + donut_colors = "red" + ), + "same length" + ) + expect_error( + cluster_options( + donut_column = "race", + donut_values = c("a", "a"), + donut_colors = c("red", "red") + ), + "duplicate" + ) + expect_error( + cluster_options( + donut_column = "race", + donut_values = c("a", "_other"), + donut_colors = c("red", "blue") + ), + "reserved" + ) + expect_error( + cluster_options( + donut_column = "code", + donut_values = c(1.5, 2), + donut_colors = c("red", "blue") + ), + "whole numbers" + ) + expect_error( + cluster_options( + donut_column = "code", + donut_values = 2^53, + donut_colors = "red" + ), + "whole numbers" + ) + expect_error( + mapgl:::.donut_normalize_values(list("a", 1), "x"), + "all character or all numeric" + ) + expect_error( + cluster_options(donut_column = "race", donut_resolution = 0), + "donut_resolution" + ) + expect_error( + cluster_options(donut_column = "race", donut_width = 1), + "donut_width" + ) + expect_error( + cluster_options( + donut_column = "race", + circle_stroke_color = list("get", "x") + ), + "expressions aren't supported" + ) + expect_error( + cluster_options( + donut_column = "race", + donut_values = "a", + donut_colors = "notacolor" + ), + "Invalid color" + ) + # no colors available for a plain circle_color + expect_error( + maplibre() |> + add_circle_layer( + "p", + donut_points(), + circle_color = "red", + cluster_options = cluster_options(donut_column = "race") + ), + "need category colors" + ) + # column checks against sf data + expect_error( + maplibre() |> + add_circle_layer( + "p", + donut_points(), + circle_color = race_colors(), + cluster_options = cluster_options(donut_column = "missing") + ), + "isn't a column" + ) + pts <- donut_points() + pts$bad <- -1 + expect_error( + maplibre() |> + add_circle_layer( + "p", + pts, + circle_color = race_colors(), + cluster_options = cluster_options( + donut_column = "race", + donut_weight = "bad" + ) + ), + "non-negative" + ) + expect_error( + maplibre() |> + add_circle_layer( + "p", + pts, + cluster_options = cluster_options( + donut_column = "race", + donut_values = c(1, 2), + donut_colors = c("red", "blue") + ) + ), + "numeric column" + ) +}) + +test_that("symbol layers and pre-clustered tiles build donut layers", { + m <- maplibre() |> + add_symbol_layer( + "s", + donut_points(), + text_field = get_column("race"), + cluster_options = cluster_options( + donut_column = "race", + donut_values = c("White", "Black"), + donut_colors = c("red", "blue") + ) + ) + expect_equal(find_layer(m, "s-clusters")$type, "symbol") + expect_equal(find_layer(m, "s")$type, "symbol") + expect_length(m$x$sources[[1]]$clusterProperties, 2) + + m <- maplibre() |> + add_circle_layer( + "t", + "tiles", + source_layer = "pts", + cluster_options = cluster_options( + donut_column = "race", + donut_values = c("White", "Black"), + donut_colors = c("red", "blue") + ) + ) + expect_length(m$x$sources, 0) + clusters <- find_layer(m, "t-clusters") + expect_equal(clusters$source_layer, "pts") + expect_match( + to_json(clusters$layout[["icon-image"]]), + '["coalesce",["get","race:White"],0]', + fixed = TRUE + ) +}) + +test_that("slot, before_id, and zoom range reach all cluster layers", { + m <- mapboxgl() |> + add_circle_layer( + "p", + donut_points(), + circle_color = race_colors(), + slot = "top", + before_id = "labels", + min_zoom = 3, + max_zoom = 12, + cluster_options = cluster_options(donut_column = "race") + ) + for (layer in m$x$layers) { + expect_equal(layer$slot, "top") + expect_equal(layer$before_id, "labels") + expect_equal(layer$minzoom, 3) + expect_equal(layer$maxzoom, 12) + } +}) + +test_that("proxy messages carry cluster properties, paint, and metadata", { + messages <- list() + session <- list( + sendCustomMessage = function(type, message) { + messages[[length(messages) + 1]] <<- message$message + } + ) + proxy <- structure( + list(id = "map", session = session), + class = "maplibre_proxy" + ) + add_circle_layer( + proxy, + "p", + donut_points(), + circle_color = race_colors(), + cluster_options = cluster_options( + donut_column = "race", + circle_opacity = 0.7 + ) + ) + types <- vapply(messages, `[[`, "", "type") + expect_equal(types, c("add_source", "add_layer", "add_layer", "add_layer")) + expect_named( + messages[[1]]$source$clusterProperties, + c("race:White", "race:Black", "race:Hispanic", "race:_other") + ) + clusters <- messages[[2]]$layer + expect_equal(clusters$paint[["icon-opacity"]], 0.7) + expect_false(is.null(clusters$layout[["icon-image"]])) + expect_false(is.null(clusters$metadata[["mapgl:donut"]]$fp)) + expect_true(messages[[3]]$layer$layout[["text-allow-overlap"]]) + expect_true(messages[[3]]$layer$layout[["text-ignore-placement"]]) + + # circle clusters now send their optional paint with the layer + messages <- list() + add_circle_layer( + proxy, + "q", + donut_points(), + cluster_options = cluster_options(circle_stroke_width = 2) + ) + expect_equal(messages[[2]]$layer$paint[["circle-stroke-width"]], 2) +}) + +test_that("donut clusters default to a wider cluster radius", { + expect_equal(cluster_options()$cluster_radius, 50) + expect_equal(cluster_options(donut_column = "race")$cluster_radius, 60) + expect_equal( + cluster_options(donut_column = "race", radius_stops = c(10, 20))$cluster_radius, + 30 + ) + expect_equal( + cluster_options(donut_column = "race", cluster_radius = 45)$cluster_radius, + 45 + ) +}) + +test_that("abbreviated cluster labels switch to millions", { + m <- maplibre() |> + add_circle_layer("p", donut_points(), cluster_options = cluster_options()) + label <- find_layer(m, "p-cluster-count")$layout[["text-field"]] + json <- to_json(label) + expect_false(grepl("point_count_abbreviated", json, fixed = TRUE)) + expect_match(json, '[">=",["get","point_count"],999500]', fixed = TRUE) + expect_match(json, '"M"]', fixed = TRUE) +}) + +test_that("weighted labels keep decimals", { + build <- function(format) { + m <- maplibre() |> + add_circle_layer( + "p", + donut_points(), + circle_color = race_colors(), + cluster_options = cluster_options( + donut_column = "race", + donut_weight = "pop", + count_format = format + ) + ) + to_json(find_layer(m, "p-cluster-count")$layout[["text-field"]]) + } + expect_equal( + build("raw"), + '["to-string",["coalesce",["get","race:_total"],0]]' + ) + expect_false(grepl('["round",["coalesce"', build("grouped"), fixed = TRUE)) + expect_false(grepl('["round",["coalesce"', build("abbreviated"), fixed = TRUE)) +}) + +test_that("explicit grouped donut_values expand their colors", { + m <- maplibre() |> + add_circle_layer( + "p", + donut_points(), + cluster_options = cluster_options( + donut_column = "race", + donut_values = list(c("White", "Black"), "Hispanic"), + donut_colors = c("red", "blue") + ) + ) + expect_named( + m$x$sources[[1]]$clusterProperties, + c("race:White", "race:Black", "race:Hispanic") + ) + spec <- find_layer(m, "p-clusters")$metadata[["mapgl:donut"]] + expect_equal(unlist(spec$colors), c("#FF0000", "#FF0000", "#0000FF")) + expect_error( + cluster_options( + donut_column = "race", + donut_values = list(c("White", "Black"), "Hispanic"), + donut_colors = c("red", "blue", "green") + ), + "same length" + ) +}) + + +test_that("cluster labels round across million and billion boundaries", { + node <- Sys.which("node") + skip_if(node == "", "node is not available") + lib <- system.file("htmlwidgets/lib/mapgl-expressions/mapgl-expressions.js", + package = "mapgl") + script <- tempfile(fileext = ".js") + on.exit(unlink(script), add = TRUE) + counts <- c(42, 1200, 999499, 999500, 999499999, 999500000, + 1470295708, 9949999999, 9950000000) + writeLines(c( + paste0("require(", jsonlite::toJSON(lib, auto_unbox = TRUE), ");"), + paste0("const expr = ", jsonlite::toJSON(.cluster_count_label_expr(), auto_unbox = TRUE), ";"), + paste0("const counts = ", jsonlite::toJSON(counts, digits = NA), ";"), + "console.log(JSON.stringify(counts.map(point_count =>", + " globalThis._mapglEvaluateExpression(expr, {point_count}))));" + ), script) + labels <- jsonlite::fromJSON(system2(node, shQuote(script), stdout = TRUE)) + expect_equal(labels, c("42", "1.2k", "999k", "1M", "999M", "1B", "1.5B", "9.9B", "10B")) +}) diff --git a/tests/testthat/test-legend-patch-toggle.R b/tests/testthat/test-legend-patch-toggle.R new file mode 100644 index 00000000..69a990b3 --- /dev/null +++ b/tests/testthat/test-legend-patch-toggle.R @@ -0,0 +1,11 @@ +test_that("interactive legend toggles preserve SVG and CSS patch paints", { + node <- Sys.which("node") + skip_if(node == "", "node is not available") + js <- system.file("htmlwidgets/lib/legend-interactivity/legend-interactivity.js", + package = "mapgl") + expect_true(file.exists(js)) + output <- system2(node, c(shQuote(test_path("fixtures", "legend-patches.cjs")), + shQuote(js)), stdout = TRUE, stderr = TRUE) + expect_null(attr(output, "status"), info = paste(output, collapse = "\n")) + expect_match(paste(output, collapse = "\n"), "Patch paint preserved") +}) diff --git a/vignettes/layers-overview.Rmd b/vignettes/layers-overview.Rmd index 1df57268..60feea09 100644 --- a/vignettes/layers-overview.Rmd +++ b/vignettes/layers-overview.Rmd @@ -115,6 +115,33 @@ mapboxgl(style = mapbox_style("dark"), ) ``` +With more points, clustering keeps the map readable. Setting `donut_column` in `cluster_options()` draws each cluster as a donut chart of the categories inside it, reusing the colors from `circle_color`: + +```{r} +many_points <- st_sf( + category = sample(categories, 3000, replace = TRUE, + prob = c(0.4, 0.3, 0.2, 0.1)), + geometry = st_sample(st_as_sfc(bbox), 3000) +) + +mapboxgl(style = mapbox_style("dark"), + bounds = many_points) |> + add_circle_layer( + id = "poi-clusters", + source = many_points, + circle_color = match_expr( + "category", + values = c("music", "bar", "theatre", "bicycle"), + stops = c("#1f78b4", "#33a02c", "#e31a1c", "#ff7f00") + ), + circle_radius = 6, + cluster_options = cluster_options( + donut_column = "category", + circle_stroke_color = "#222222" + ) + ) +``` + ### Symbol layers Symbol layers offer a wide range of arguments for customizing icon and label appearance; however not all arguments will work with all icons. The `icon_image` argument will look for a string that represents an icon found in the map style's sprite. [Read more about sprites here](https://docs.mapbox.com/style-spec/reference/sprite/).