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()
+ )
+ })
+})