diff --git a/NAMESPACE b/NAMESPACE index 88194ff4..697d4400 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -15,6 +15,7 @@ export(addHistory) export(addItemContextmenu) export(addLabelgun) export(addLatLngMoving) +export(addLayerGroupCollision) export(addLeafletsync) export(addLeafletsyncDependency) export(addMapkeyMarkers) diff --git a/NEWS.md b/NEWS.md index 3320c1f8..463fe55e 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,5 +1,6 @@ # leaflet.extras2 (development version) +* Included [LayerGroup.Collision](https://github.com/MazeMap/Leaflet.LayerGroup.Collision) plugin * Included [OSM Buildings](https://osmbuildings.org/documentation/leaflet/) plugin * New Function `addDivicon` adds `DivIcon` markers to Leaflet maps with support for custom HTML and CSS classes. See the example in `./inst/examples/divicons_html_app.R` * Added `addClusterCharts` to enable **pie** and **bar** charts in Marker clusters using `Leaflet.markercluster`, `d3` and `L.DivIcon`, with support for customizable category styling and various aggregation methods like **sum, min, max, mean**, and **median**. diff --git a/R/divicon.R b/R/divicon.R index 0f1daa3e..d785ef68 100644 --- a/R/divicon.R +++ b/R/divicon.R @@ -17,13 +17,11 @@ diviconDependency <- function() { #' for latitude and longitude coordinates. It allows for the application of custom HTML #' content and CSS classes to each marker, providing high flexibility in marker design. #' -#' @param map The Leaflet map object to which the DivIcon markers will be added. #' @inheritParams leaflet::addAwesomeMarkers -#' @param className A single CSS class or a vector of CSS classes to apply to the DivIcon markers. -#' @param html A single HTML string or a vector of HTML strings to display within the DivIcon markers. +#' @param className A single CSS class or a vector of CSS classes. +#' @param html A single HTML string or a vector of HTML strings. #' @param divOptions A list of extra options for Leaflet DivIcon. -#' @param options A list of extra options for the markers. -#' See \code{\link[leaflet]{markerOptions}} for more details. +#' #' @family DivIcon Functions #' @return The modified Leaflet map object. #' @export diff --git a/R/layergroupcollision.R b/R/layergroupcollision.R new file mode 100644 index 00000000..40a9a0ad --- /dev/null +++ b/R/layergroupcollision.R @@ -0,0 +1,87 @@ +layergroupCollisionDependency <- function() { + list( + htmltools::htmlDependency( + "lfx-layergroupcollision", + version = "1.0.0", + src = system.file("htmlwidgets/lfx-layergroupcollision", + package = "leaflet.extras2" + ), + script = c( + "rbush.min.js", + "Leaflet.LayerGroup.Collision.js", + "layergroup-binding.js" + ), + all_files = TRUE + ) + ) +} + +#' Add LayerGroup Collision Plugin +#' +#' @description Integrates the LayerGroup Collision plugin into a Leaflet map, +#' which hides overlapping markers and only displays the first added marker in a +#' collision group. Markers must be static; dynamic changes, dragging, and +#' deletions are not supported. + +#' The function transforms spatial data into GeoJSON format and uses `L.DivIcon`, +#' allowing you to pass HTML content and CSS classes to style the markers. +#' +#' @param group the name of the group. It needs to be single string. +#' @param margin defines the margin between markers, in pixels +#' @return A leaflet map object with the LayerGroup Collision plugin added. +#' @export +#' +#' @inheritParams addDivicon +#' @references \url{https://github.com/MazeMap/Leaflet.LayerGroup.Collision} +#' +#' @name LayerGroupCollision +#' @examples +#' library(leaflet) +#' library(sf) +#' library(leaflet.extras2) +#' +#' df <- sf::st_as_sf(atlStorms2005) +#' df <- suppressWarnings(st_cast(df, "POINT")) +#' df$classes <- sample(x = 1:5, nrow(df), replace = TRUE) +#' +#' leaflet() %>% +#' addProviderTiles("CartoDB.Positron") %>% +#' leaflet::addLayersControl(overlayGroups = c("Labels")) %>% +#' addPolylines(data = sf::st_as_sf(atlStorms2005), label = ~Name) %>% +#' addLayerGroupCollision( +#' data = df, margin = 40, +#' html = ~ paste0( +#' '
', +#' '
', Name, "
", +#' '
MaxWind: ', MaxWind, "
", +#' "
" +#' ), +#' className = ~ paste0("my-label my-label-", classes), +#' group = "Labels" +#' ) +addLayerGroupCollision <- function( + map, group = NULL, + className = NULL, html = NULL, + margin = 5, data = getMapData(map)) { + map$dependencies <- c(map$dependencies, layergroupCollisionDependency()) + + ## Make Geojson and Assign Class & HTML columns ########### + if (!inherits(data, "sf")) { + data <- sf::st_as_sf(data) + } + data$className__ <- evalFormula(className, data) + data$html__ <- evalFormula(html, data) + geojson <- yyjsonr::write_geojson_str(data) + class(geojson) <- c("geojson", "json") + + ## Derive Points and Invoke Method ################## + pts <- derivePoints( + data, NULL, NULL, TRUE, TRUE, + "addLayerGroupCollision" + ) + invokeMethod( + map, NULL, "addLayerGroupCollision", + geojson, group, margin + ) %>% + expandLimits(pts$lat, pts$lng) +} diff --git a/README.md b/README.md index 80044056..561bad76 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ If you need a plugin that is not already implemented create an [issue](https://g - [Hexbin-D3](https://github.com/bluehalo/leaflet-d3#hexbins-api) - [History](https://github.com/cscott530/leaflet-history) - [Labelgun](https://github.com/Geovation/labelgun) +- [LayerGroup.Collision](https://github.com/MazeMap/Leaflet.LayerGroup.Collision) - [Leaflet.Sync](https://github.com/jieter/Leaflet.Sync) - [Mapkey Icons](https://github.com/mapshakers/leaflet-mapkey-icon) - [Moving Markers](https://github.com/ewoken/Leaflet.MovingMarker) diff --git a/_pkgdown.yml b/_pkgdown.yml index 2c74d53d..10fe31f5 100644 --- a/_pkgdown.yml +++ b/_pkgdown.yml @@ -59,6 +59,9 @@ reference: - title: Labelgun contents: - matches("Labelgun") + - title: LayerGroup.Collision + contents: + - matches("LayerGroupCollision") - title: Mapkey Icons contents: - matches("Mapkey") diff --git a/inst/examples/layergroupcollision_app.R b/inst/examples/layergroupcollision_app.R new file mode 100644 index 00000000..2f6d6eeb --- /dev/null +++ b/inst/examples/layergroupcollision_app.R @@ -0,0 +1,53 @@ +library(shiny) +library(leaflet) +library(sf) +library(leaflet.extras2) +options("shiny.autoreload" = TRUE) + +df <- sf::st_as_sf(atlStorms2005) +df <- suppressWarnings(st_cast(df, "POINT")) +df <- df[sample(1:nrow(df), 150, replace = FALSE),] +df$classes = sample(x = 1:5, nrow(df), replace = TRUE) + +## Ordering is important +df <- df[order(df$classes, decreasing = FALSE),] + +ui <- fluidPage( + ## CSS-style ############ + tags$head(tags$style(" + .my-label { + background: white; + border: 1px solid #888; + position: relative; + display: inline-block; + white-space: nowrap; + } + .my-label-1 { font-size: 28px; background-color: red; top: -26px; } + .my-label-2 { font-size: 24px; background-color: orange; top: -25px; } + .my-label-3 { font-size: 22px; background-color: yellow; top: -24px; } + .my-label-4 { font-size: 16px; background-color: green; top: -23px; } + .my-label-5 { font-size: 15px; background-color: lightgreen; top: -22px; } + ")), + leafletOutput("map", height = 800) +) + +## Server ########### +server <- function(input, output, session) { + output$map <- renderLeaflet({ + leaflet() %>% + addProviderTiles("CartoDB.Positron") %>% + leaflet::addLayersControl(overlayGroups = c("Labels")) %>% + addLayerGroupCollision(data = df + , html = ~paste0( + '
', + '
', Name, '
', + '
MaxWind: ', MaxWind, '
', + '
' + ) + , className = ~paste0("my-label my-label-", classes) + , group = "Labels" + ) + + }) +} +shinyApp(ui, server) diff --git a/inst/htmlwidgets/lfx-layergroupcollision/Leaflet.LayerGroup.Collision.js b/inst/htmlwidgets/lfx-layergroupcollision/Leaflet.LayerGroup.Collision.js new file mode 100644 index 00000000..5dbb7d84 --- /dev/null +++ b/inst/htmlwidgets/lfx-layergroupcollision/Leaflet.LayerGroup.Collision.js @@ -0,0 +1,244 @@ + + +var isMSIE8 = !('getComputedStyle' in window && typeof window.getComputedStyle === 'function') + +function extensions(parentClass) { return { + + initialize: function (arg1, arg2) { + var options; + if (parentClass === L.GeoJSON) { + parentClass.prototype.initialize.call(this, arg1, arg2); + options = arg2; + } else { + parentClass.prototype.initialize.call(this, arg1); + options = arg1; + } + this._originalLayers = []; + this._visibleLayers = []; + this._staticLayers = []; + this._rbush = []; + this._cachedRelativeBoxes = []; + this._margin = options.margin || 0; + this._rbush = null; + }, + + addLayer: function(layer) { + if ( !('options' in layer) || !('icon' in layer.options)) { + this._staticLayers.push(layer); + parentClass.prototype.addLayer.call(this, layer); + return; + } + + this._originalLayers.push(layer); + if (this._map) { + this._maybeAddLayerToRBush( layer ); + } + }, + + removeLayer: function(layer) { + this._rbush.remove(this._cachedRelativeBoxes[layer._leaflet_id]); + delete this._cachedRelativeBoxes[layer._leaflet_id]; + parentClass.prototype.removeLayer.call(this,layer); + var i; + + i = this._originalLayers.indexOf(layer); + if (i !== -1) { this._originalLayers.splice(i,1); } + + i = this._visibleLayers.indexOf(layer); + if (i !== -1) { this._visibleLayers.splice(i,1); } + + i = this._staticLayers.indexOf(layer); + if (i !== -1) { this._staticLayers.splice(i,1); } + }, + + clearLayers: function() { + this._rbush = rbush(); + this._originalLayers = []; + this._visibleLayers = []; + this._staticLayers = []; + this._cachedRelativeBoxes = []; + parentClass.prototype.clearLayers.call(this); + }, + + onAdd: function (map) { + this._map = map; + + for (var i in this._staticLayers) { + map.addLayer(this._staticLayers[i]); + } + + this._onZoomEnd(); + map.on('zoomend', this._onZoomEnd, this); + }, + + onRemove: function(map) { + for (var i in this._staticLayers) { + map.removeLayer(this._staticLayers[i]); + } + map.off('zoomend', this._onZoomEnd, this); + parentClass.prototype.onRemove.call(this, map); + }, + + _maybeAddLayerToRBush: function(layer) { + + var z = this._map.getZoom(); + var bush = this._rbush; + + var boxes = this._cachedRelativeBoxes[layer._leaflet_id]; + var visible = false; + if (!boxes) { + // Add the layer to the map so it's instantiated on the DOM, + // in order to fetch its position and size. + parentClass.prototype.addLayer.call(this, layer); + var visible = true; +// var htmlElement = layer._icon; + var box = this._getIconBox(layer._icon); + boxes = this._getRelativeBoxes(layer._icon.children, box); + boxes.push(box); + this._cachedRelativeBoxes[layer._leaflet_id] = boxes; + } + + boxes = this._positionBoxes(this._map.latLngToLayerPoint(layer.getLatLng()),boxes); + + var collision = false; + for (var i=0; i 0; + } + + if (!collision) { + if (!visible) { + parentClass.prototype.addLayer.call(this, layer); + } + this._visibleLayers.push(layer); + bush.load(boxes); + } else { + parentClass.prototype.removeLayer.call(this, layer); + } + }, + + + // Returns a plain array with the relative dimensions of a L.Icon, based + // on the computed values from iconSize and iconAnchor. + _getIconBox: function (el) { + + if (isMSIE8) { + // Fallback for MSIE8, will most probably fail on edge cases + return [ 0, 0, el.offsetWidth, el.offsetHeight]; + } + + var styles = window.getComputedStyle(el); + + // getComputedStyle() should return values already in pixels, so using parseInt() + // is not as much as a hack as it seems to be. + + return [ + parseInt(styles.marginLeft), + parseInt(styles.marginTop), + parseInt(styles.marginLeft) + parseInt(styles.width), + parseInt(styles.marginTop) + parseInt(styles.height) + ]; + }, + + + // Much like _getIconBox, but works for positioned HTML elements, based on offsetWidth/offsetHeight. + _getRelativeBoxes: function(els,baseBox) { + var boxes = []; + for (var i=0; i" + + feat.properties["html__"] + + "" + }) + , interactive: false // Post-0.7.3 + , clickable: false // 0.7.3 + }); + + collisionLayer.addLayer(marker); + } + this.layerManager.addLayer(collisionLayer, "collison", null, group); + +}; diff --git a/inst/htmlwidgets/lfx-layergroupcollision/rbush.min.js b/inst/htmlwidgets/lfx-layergroupcollision/rbush.min.js new file mode 100644 index 00000000..5df616d0 --- /dev/null +++ b/inst/htmlwidgets/lfx-layergroupcollision/rbush.min.js @@ -0,0 +1,617 @@ +/* + (c) 2015, Vladimir Agafonkin + RBush, a JavaScript library for high-performance 2D spatial indexing of points and rectangles. + https://github.com/mourner/rbush +*/ + +(function () { +'use strict'; + +function rbush(maxEntries, format) { + if (!(this instanceof rbush)) return new rbush(maxEntries, format); + + // max entries in a node is 9 by default; min node fill is 40% for best performance + this._maxEntries = Math.max(4, maxEntries || 9); + this._minEntries = Math.max(2, Math.ceil(this._maxEntries * 0.4)); + + if (format) { + this._initFormat(format); + } + + this.clear(); +} + +rbush.prototype = { + + all: function () { + return this._all(this.data, []); + }, + + search: function (bbox) { + + var node = this.data, + result = [], + toBBox = this.toBBox; + + if (!intersects(bbox, node.bbox)) return result; + + var nodesToSearch = [], + i, len, child, childBBox; + + while (node) { + for (i = 0, len = node.children.length; i < len; i++) { + + child = node.children[i]; + childBBox = node.leaf ? toBBox(child) : child.bbox; + + if (intersects(bbox, childBBox)) { + if (node.leaf) result.push(child); + else if (contains(bbox, childBBox)) this._all(child, result); + else nodesToSearch.push(child); + } + } + node = nodesToSearch.pop(); + } + + return result; + }, + + collides: function (bbox) { + + var node = this.data, + toBBox = this.toBBox; + + if (!intersects(bbox, node.bbox)) return false; + + var nodesToSearch = [], + i, len, child, childBBox; + + while (node) { + for (i = 0, len = node.children.length; i < len; i++) { + + child = node.children[i]; + childBBox = node.leaf ? toBBox(child) : child.bbox; + + if (intersects(bbox, childBBox)) { + if (node.leaf || contains(bbox, childBBox)) return true; + nodesToSearch.push(child); + } + } + node = nodesToSearch.pop(); + } + + return false; + }, + + load: function (data) { + if (!(data && data.length)) return this; + + if (data.length < this._minEntries) { + for (var i = 0, len = data.length; i < len; i++) { + this.insert(data[i]); + } + return this; + } + + // recursively build the tree with the given data from stratch using OMT algorithm + var node = this._build(data.slice(), 0, data.length - 1, 0); + + if (!this.data.children.length) { + // save as is if tree is empty + this.data = node; + + } else if (this.data.height === node.height) { + // split root if trees have the same height + this._splitRoot(this.data, node); + + } else { + if (this.data.height < node.height) { + // swap trees if inserted one is bigger + var tmpNode = this.data; + this.data = node; + node = tmpNode; + } + + // insert the small tree into the large tree at appropriate level + this._insert(node, this.data.height - node.height - 1, true); + } + + return this; + }, + + insert: function (item) { + if (item) this._insert(item, this.data.height - 1); + return this; + }, + + clear: function () { + this.data = { + children: [], + height: 1, + bbox: empty(), + leaf: true + }; + return this; + }, + + remove: function (item) { + if (!item) return this; + + var node = this.data, + bbox = this.toBBox(item), + path = [], + indexes = [], + i, parent, index, goingUp; + + // depth-first iterative tree traversal + while (node || path.length) { + + if (!node) { // go up + node = path.pop(); + parent = path[path.length - 1]; + i = indexes.pop(); + goingUp = true; + } + + if (node.leaf) { // check current node + index = node.children.indexOf(item); + + if (index !== -1) { + // item found, remove the item and condense tree upwards + node.children.splice(index, 1); + path.push(node); + this._condense(path); + return this; + } + } + + if (!goingUp && !node.leaf && contains(node.bbox, bbox)) { // go down + path.push(node); + indexes.push(i); + i = 0; + parent = node; + node = node.children[0]; + + } else if (parent) { // go right + i++; + node = parent.children[i]; + goingUp = false; + + } else node = null; // nothing found + } + + return this; + }, + + toBBox: function (item) { return item; }, + + compareMinX: function (a, b) { return a[0] - b[0]; }, + compareMinY: function (a, b) { return a[1] - b[1]; }, + + toJSON: function () { return this.data; }, + + fromJSON: function (data) { + this.data = data; + return this; + }, + + _all: function (node, result) { + var nodesToSearch = []; + while (node) { + if (node.leaf) result.push.apply(result, node.children); + else nodesToSearch.push.apply(nodesToSearch, node.children); + + node = nodesToSearch.pop(); + } + return result; + }, + + _build: function (items, left, right, height) { + + var N = right - left + 1, + M = this._maxEntries, + node; + + if (N <= M) { + // reached leaf level; return leaf + node = { + children: items.slice(left, right + 1), + height: 1, + bbox: null, + leaf: true + }; + calcBBox(node, this.toBBox); + return node; + } + + if (!height) { + // target height of the bulk-loaded tree + height = Math.ceil(Math.log(N) / Math.log(M)); + + // target number of root entries to maximize storage utilization + M = Math.ceil(N / Math.pow(M, height - 1)); + } + + node = { + children: [], + height: height, + bbox: null, + leaf: false + }; + + // split the items into M mostly square tiles + + var N2 = Math.ceil(N / M), + N1 = N2 * Math.ceil(Math.sqrt(M)), + i, j, right2, right3; + + multiSelect(items, left, right, N1, this.compareMinX); + + for (i = left; i <= right; i += N1) { + + right2 = Math.min(i + N1 - 1, right); + + multiSelect(items, i, right2, N2, this.compareMinY); + + for (j = i; j <= right2; j += N2) { + + right3 = Math.min(j + N2 - 1, right2); + + // pack each entry recursively + node.children.push(this._build(items, j, right3, height - 1)); + } + } + + calcBBox(node, this.toBBox); + + return node; + }, + + _chooseSubtree: function (bbox, node, level, path) { + + var i, len, child, targetNode, area, enlargement, minArea, minEnlargement; + + while (true) { + path.push(node); + + if (node.leaf || path.length - 1 === level) break; + + minArea = minEnlargement = Infinity; + + for (i = 0, len = node.children.length; i < len; i++) { + child = node.children[i]; + area = bboxArea(child.bbox); + enlargement = enlargedArea(bbox, child.bbox) - area; + + // choose entry with the least area enlargement + if (enlargement < minEnlargement) { + minEnlargement = enlargement; + minArea = area < minArea ? area : minArea; + targetNode = child; + + } else if (enlargement === minEnlargement) { + // otherwise choose one with the smallest area + if (area < minArea) { + minArea = area; + targetNode = child; + } + } + } + + node = targetNode || node.children[0]; + } + + return node; + }, + + _insert: function (item, level, isNode) { + + var toBBox = this.toBBox, + bbox = isNode ? item.bbox : toBBox(item), + insertPath = []; + + // find the best node for accommodating the item, saving all nodes along the path too + var node = this._chooseSubtree(bbox, this.data, level, insertPath); + + // put the item into the node + node.children.push(item); + extend(node.bbox, bbox); + + // split on node overflow; propagate upwards if necessary + while (level >= 0) { + if (insertPath[level].children.length > this._maxEntries) { + this._split(insertPath, level); + level--; + } else break; + } + + // adjust bboxes along the insertion path + this._adjustParentBBoxes(bbox, insertPath, level); + }, + + // split overflowed node into two + _split: function (insertPath, level) { + + var node = insertPath[level], + M = node.children.length, + m = this._minEntries; + + this._chooseSplitAxis(node, m, M); + + var splitIndex = this._chooseSplitIndex(node, m, M); + + var newNode = { + children: node.children.splice(splitIndex, node.children.length - splitIndex), + height: node.height, + bbox: null, + leaf: false + }; + + if (node.leaf) newNode.leaf = true; + + calcBBox(node, this.toBBox); + calcBBox(newNode, this.toBBox); + + if (level) insertPath[level - 1].children.push(newNode); + else this._splitRoot(node, newNode); + }, + + _splitRoot: function (node, newNode) { + // split root node + this.data = { + children: [node, newNode], + height: node.height + 1, + bbox: null, + leaf: false + }; + calcBBox(this.data, this.toBBox); + }, + + _chooseSplitIndex: function (node, m, M) { + + var i, bbox1, bbox2, overlap, area, minOverlap, minArea, index; + + minOverlap = minArea = Infinity; + + for (i = m; i <= M - m; i++) { + bbox1 = distBBox(node, 0, i, this.toBBox); + bbox2 = distBBox(node, i, M, this.toBBox); + + overlap = intersectionArea(bbox1, bbox2); + area = bboxArea(bbox1) + bboxArea(bbox2); + + // choose distribution with minimum overlap + if (overlap < minOverlap) { + minOverlap = overlap; + index = i; + + minArea = area < minArea ? area : minArea; + + } else if (overlap === minOverlap) { + // otherwise choose distribution with minimum area + if (area < minArea) { + minArea = area; + index = i; + } + } + } + + return index; + }, + + // sorts node children by the best axis for split + _chooseSplitAxis: function (node, m, M) { + + var compareMinX = node.leaf ? this.compareMinX : compareNodeMinX, + compareMinY = node.leaf ? this.compareMinY : compareNodeMinY, + xMargin = this._allDistMargin(node, m, M, compareMinX), + yMargin = this._allDistMargin(node, m, M, compareMinY); + + // if total distributions margin value is minimal for x, sort by minX, + // otherwise it's already sorted by minY + if (xMargin < yMargin) node.children.sort(compareMinX); + }, + + // total margin of all possible split distributions where each node is at least m full + _allDistMargin: function (node, m, M, compare) { + + node.children.sort(compare); + + var toBBox = this.toBBox, + leftBBox = distBBox(node, 0, m, toBBox), + rightBBox = distBBox(node, M - m, M, toBBox), + margin = bboxMargin(leftBBox) + bboxMargin(rightBBox), + i, child; + + for (i = m; i < M - m; i++) { + child = node.children[i]; + extend(leftBBox, node.leaf ? toBBox(child) : child.bbox); + margin += bboxMargin(leftBBox); + } + + for (i = M - m - 1; i >= m; i--) { + child = node.children[i]; + extend(rightBBox, node.leaf ? toBBox(child) : child.bbox); + margin += bboxMargin(rightBBox); + } + + return margin; + }, + + _adjustParentBBoxes: function (bbox, path, level) { + // adjust bboxes along the given tree path + for (var i = level; i >= 0; i--) { + extend(path[i].bbox, bbox); + } + }, + + _condense: function (path) { + // go through the path, removing empty nodes and updating bboxes + for (var i = path.length - 1, siblings; i >= 0; i--) { + if (path[i].children.length === 0) { + if (i > 0) { + siblings = path[i - 1].children; + siblings.splice(siblings.indexOf(path[i]), 1); + + } else this.clear(); + + } else calcBBox(path[i], this.toBBox); + } + }, + + _initFormat: function (format) { + // data format (minX, minY, maxX, maxY accessors) + + // uses eval-type function compilation instead of just accepting a toBBox function + // because the algorithms are very sensitive to sorting functions performance, + // so they should be dead simple and without inner calls + + var compareArr = ['return a', ' - b', ';']; + + this.compareMinX = new Function('a', 'b', compareArr.join(format[0])); + this.compareMinY = new Function('a', 'b', compareArr.join(format[1])); + + this.toBBox = new Function('a', 'return [a' + format.join(', a') + '];'); + } +}; + + +// calculate node's bbox from bboxes of its children +function calcBBox(node, toBBox) { + node.bbox = distBBox(node, 0, node.children.length, toBBox); +} + +// min bounding rectangle of node children from k to p-1 +function distBBox(node, k, p, toBBox) { + var bbox = empty(); + + for (var i = k, child; i < p; i++) { + child = node.children[i]; + extend(bbox, node.leaf ? toBBox(child) : child.bbox); + } + + return bbox; +} + +function empty() { return [Infinity, Infinity, -Infinity, -Infinity]; } + +function extend(a, b) { + a[0] = Math.min(a[0], b[0]); + a[1] = Math.min(a[1], b[1]); + a[2] = Math.max(a[2], b[2]); + a[3] = Math.max(a[3], b[3]); + return a; +} + +function compareNodeMinX(a, b) { return a.bbox[0] - b.bbox[0]; } +function compareNodeMinY(a, b) { return a.bbox[1] - b.bbox[1]; } + +function bboxArea(a) { return (a[2] - a[0]) * (a[3] - a[1]); } +function bboxMargin(a) { return (a[2] - a[0]) + (a[3] - a[1]); } + +function enlargedArea(a, b) { + return (Math.max(b[2], a[2]) - Math.min(b[0], a[0])) * + (Math.max(b[3], a[3]) - Math.min(b[1], a[1])); +} + +function intersectionArea(a, b) { + var minX = Math.max(a[0], b[0]), + minY = Math.max(a[1], b[1]), + maxX = Math.min(a[2], b[2]), + maxY = Math.min(a[3], b[3]); + + return Math.max(0, maxX - minX) * + Math.max(0, maxY - minY); +} + +function contains(a, b) { + return a[0] <= b[0] && + a[1] <= b[1] && + b[2] <= a[2] && + b[3] <= a[3]; +} + +function intersects(a, b) { + return b[0] <= a[2] && + b[1] <= a[3] && + b[2] >= a[0] && + b[3] >= a[1]; +} + +// sort an array so that items come in groups of n unsorted items, with groups sorted between each other; +// combines selection algorithm with binary divide & conquer approach + +function multiSelect(arr, left, right, n, compare) { + var stack = [left, right], + mid; + + while (stack.length) { + right = stack.pop(); + left = stack.pop(); + + if (right - left <= n) continue; + + mid = left + Math.ceil((right - left) / n / 2) * n; + select(arr, left, right, mid, compare); + + stack.push(left, mid, mid, right); + } +} + +// Floyd-Rivest selection algorithm: +// sort an array between left and right (inclusive) so that the smallest k elements come first (unordered) +function select(arr, left, right, k, compare) { + var n, i, z, s, sd, newLeft, newRight, t, j; + + while (right > left) { + if (right - left > 600) { + n = right - left + 1; + i = k - left + 1; + z = Math.log(n); + s = 0.5 * Math.exp(2 * z / 3); + sd = 0.5 * Math.sqrt(z * s * (n - s) / n) * (i - n / 2 < 0 ? -1 : 1); + newLeft = Math.max(left, Math.floor(k - i * s / n + sd)); + newRight = Math.min(right, Math.floor(k + (n - i) * s / n + sd)); + select(arr, newLeft, newRight, k, compare); + } + + t = arr[k]; + i = left; + j = right; + + swap(arr, left, k); + if (compare(arr[right], t) > 0) swap(arr, left, right); + + while (i < j) { + swap(arr, i, j); + i++; + j--; + while (compare(arr[i], t) < 0) i++; + while (compare(arr[j], t) > 0) j--; + } + + if (compare(arr[left], t) === 0) swap(arr, left, j); + else { + j++; + swap(arr, j, right); + } + + if (j <= k) left = j + 1; + if (k <= j) right = j - 1; + } +} + +function swap(arr, i, j) { + var tmp = arr[i]; + arr[i] = arr[j]; + arr[j] = tmp; +} + + +// export as AMD/CommonJS module or global variable +if (typeof define === 'function' && define.amd) define('rbush', function () { return rbush; }); +else if (typeof module !== 'undefined') module.exports = rbush; +else if (typeof self !== 'undefined') self.rbush = rbush; +else window.rbush = rbush; + +})(); diff --git a/man/LayerGroupCollision.Rd b/man/LayerGroupCollision.Rd new file mode 100644 index 00000000..4dc53d6a --- /dev/null +++ b/man/LayerGroupCollision.Rd @@ -0,0 +1,70 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/layergroupcollision.R +\name{LayerGroupCollision} +\alias{LayerGroupCollision} +\alias{addLayerGroupCollision} +\title{Add LayerGroup Collision Plugin} +\usage{ +addLayerGroupCollision( + map, + group = NULL, + className = NULL, + html = NULL, + margin = 5, + data = getMapData(map) +) +} +\arguments{ +\item{map}{the map to add awesome Markers to.} + +\item{group}{the name of the group. It needs to be single string.} + +\item{className}{A single CSS class or a vector of CSS classes.} + +\item{html}{A single HTML string or a vector of HTML strings.} + +\item{margin}{defines the margin between markers, in pixels} + +\item{data}{the data object from which the argument values are derived; by +default, it is the \code{data} object provided to \code{leaflet()} +initially, but can be overridden} +} +\value{ +A leaflet map object with the LayerGroup Collision plugin added. +} +\description{ +Integrates the LayerGroup Collision plugin into a Leaflet map, +which hides overlapping markers and only displays the first added marker in a +collision group. Markers must be static; dynamic changes, dragging, and +deletions are not supported. +The function transforms spatial data into GeoJSON format and uses `L.DivIcon`, +allowing you to pass HTML content and CSS classes to style the markers. +} +\examples{ +library(leaflet) +library(sf) +library(leaflet.extras2) + +df <- sf::st_as_sf(atlStorms2005) +df <- suppressWarnings(st_cast(df, "POINT")) +df$classes <- sample(x = 1:5, nrow(df), replace = TRUE) + +leaflet() \%>\% + addProviderTiles("CartoDB.Positron") \%>\% + leaflet::addLayersControl(overlayGroups = c("Labels")) \%>\% + addPolylines(data = sf::st_as_sf(atlStorms2005), label = ~Name) \%>\% + addLayerGroupCollision( + data = df, margin = 40, + html = ~ paste0( + '
', + '
', Name, "
", + '
MaxWind: ', MaxWind, "
", + "
" + ), + className = ~ paste0("my-label my-label-", classes), + group = "Labels" + ) +} +\references{ +\url{https://github.com/MazeMap/Leaflet.LayerGroup.Collision} +} diff --git a/man/addDivicon.Rd b/man/addDivicon.Rd index 32f7c1d8..ef22b11d 100644 --- a/man/addDivicon.Rd +++ b/man/addDivicon.Rd @@ -24,7 +24,7 @@ addDivicon( ) } \arguments{ -\item{map}{The Leaflet map object to which the DivIcon markers will be added.} +\item{map}{the map to add awesome Markers to.} \item{lng}{a numeric vector of longitudes, or a one-sided formula of the form \code{~x} where \code{x} is a variable in \code{data}; by default (if not @@ -55,12 +55,12 @@ for security reasons)} \item{labelOptions}{A Vector of \code{\link[leaflet]{labelOptions}} to provide label options for each label. Default \code{NULL}} -\item{className}{A single CSS class or a vector of CSS classes to apply to the DivIcon markers.} +\item{className}{A single CSS class or a vector of CSS classes.} -\item{html}{A single HTML string or a vector of HTML strings to display within the DivIcon markers.} +\item{html}{A single HTML string or a vector of HTML strings.} -\item{options}{A list of extra options for the markers. -See \code{\link[leaflet]{markerOptions}} for more details.} +\item{options}{a list of extra options for tile layers, popups, paths +(circles, rectangles, polygons, ...), or other map elements} \item{clusterOptions}{if not \code{NULL}, markers will be clustered using \href{https://github.com/Leaflet/Leaflet.markercluster}{Leaflet.markercluster}; diff --git a/package-lock.json b/package-lock.json index bbc4ce3d..630bd259 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,8 @@ "leaflet-arrowheads": "^1.4.0", "leaflet-easyprint": "^2.1.9", "leaflet-geometryutil": "^0.10.1", - "leaflet.heightgraph": "^1.4.0" + "leaflet.heightgraph": "^1.4.0", + "rbush": "^2.0.2" } }, "node_modules/commander": { diff --git a/package.json b/package.json index fd03ebeb..03b82524 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "leaflet-arrowheads": "^1.4.0", "leaflet-easyprint": "^2.1.9", "leaflet-geometryutil": "^0.10.1", - "leaflet.heightgraph": "^1.4.0" + "leaflet.heightgraph": "^1.4.0", + "rbush": "^2.0.2" } } diff --git a/tests/testthat/test-layergroupcollision.R b/tests/testthat/test-layergroupcollision.R new file mode 100644 index 00000000..d6e569b3 --- /dev/null +++ b/tests/testthat/test-layergroupcollision.R @@ -0,0 +1,99 @@ +library(testthat) +library(sf) +library(leaflet) + +# Sample data for testing +df <- sf::st_as_sf(atlStorms2005) +df <- suppressWarnings(st_cast(df, "POINT")) +df <- df[sample(1:nrow(df), 50, replace = FALSE), ] +df$classes <- sample( + x = c("myclass1", "myclass2", "myclass3"), + nrow(df), replace = TRUE +) +df$ID <- paste0("ID_", 1:nrow(df)) +df$lon <- st_coordinates(df)[, 1] +df$lat <- st_coordinates(df)[, 2] + +# Function to generate map object for reuse in tests +generate_test_map <- function() { + leaflet() %>% + addTiles() +} + +# Test 1: Basic functionality of addLayerGroupCollision +test_that("addLayerGroupCollision works", { + map <- generate_test_map() %>% + addLayerGroupCollision( + data = df, + group = "Myclass", + className = ~ paste("class", classes), + html = ~ paste0("
", ID, "
") + ) + + expect_is(map, "leaflet") + expect_true(any(sapply( + map$dependencies, + function(dep) dep$name == "lfx-layergroupcollision" + ))) + expect_length(map$dependencies[[length(map$dependencies)]]$script, 3) + expect_identical(map$x$calls[[2]]$method, "addLayerGroupCollision") + expect_is(map$x$calls[[2]]$args[[1]], "geojson") + expect_identical(map$x$calls[[2]]$args[[2]], "Myclass") + expect_identical(map$x$calls[[2]]$args[[3]], 5) # Default margin +}) + +# Test 2: Handling of custom margin +test_that("addLayerGroupCollision handles custom margin", { + map <- generate_test_map() %>% + addLayerGroupCollision( + data = df, + margin = 10 + ) + + expect_is(map, "leaflet") + expect_identical(map$x$calls[[2]]$method, "addLayerGroupCollision") + expect_identical(map$x$calls[[2]]$args[[3]], 10) # Custom margin +}) + +# Test 3: Adding HTML and className with custom values +test_that("addLayerGroupCollision assigns HTML and className correctly", { + map <- generate_test_map() %>% + addLayerGroupCollision( + data = df, + className = ~ paste("myclass", classes), + html = ~ paste0("
", ID, "
") + ) + + expect_is(map, "leaflet") + expect_identical(map$x$calls[[2]]$method, "addLayerGroupCollision") + expect_null(map$x$calls[[2]]$args[[2]]) + expect_identical(map$x$calls[[2]]$args[[3]], 5) +}) + +# Test 4: Verifying map data transformation to GeoJSON +test_that("addLayerGroupCollision transforms spatial data to GeoJSON", { + map <- generate_test_map() %>% + addLayerGroupCollision( + data = df + ) + + geojson <- map$x$calls[[2]]$args[[1]] + expect_true(inherits(geojson, "geojson")) +}) + +# Test 5: Error handling for invalid data +test_that("addLayerGroupCollision handles invalid data gracefully", { + expect_error({ + map <- generate_test_map() %>% + addLayerGroupCollision( + data = NULL + ) + }) + + expect_error({ + map <- generate_test_map() %>% + addLayerGroupCollision( + data = data.frame() + ) + }) +})