diff --git a/R/dependencies.R b/R/dependencies.R index 1bfc4c706..3fa118304 100644 --- a/R/dependencies.R +++ b/R/dependencies.R @@ -25,6 +25,9 @@ leafletDependencies <- list( omnivore = function() { leafletOmnivoreDependencies() }, + polylineoffset = function() { + leafletPolylineoffsetDependencies() + }, # the ones below are not really expected to be used directly # but are included for completeness sake. diff --git a/R/layers.R b/R/layers.R index ca958da1f..e62544cfd 100644 --- a/R/layers.R +++ b/R/layers.R @@ -1116,6 +1116,9 @@ addCircles <- function( #' @param smoothFactor how much to simplify the polyline on each zoom level #' (more means better performance and less accurate representation) #' @param highlightOptions Options for highlighting the shape on mouse over. +#' @param offset relative pixel offset from their actual LatLngs. The offset +#' value can be either negative or positive, for left- or right-side offset, +#' and remains constant across zoom levels. #' @param noClip whether to disable polyline clipping #' @describeIn map-layers Add polylines to the map #' @export @@ -1137,15 +1140,27 @@ addPolylines <- function( labelOptions = NULL, options = pathOptions(), highlightOptions = NULL, - data = getMapData(map) + data = getMapData(map), + offset = NULL ) { if (missing(labelOptions)) labelOptions <- labelOptions() + if (is.numeric(offset)) { + if (offset == 0) offset <- NULL + } else { + offset <- NULL + } + options <- c(options, filterNULL(list( stroke = stroke, color = color, weight = weight, opacity = opacity, - fill = fill, fillColor = fillColor, fillOpacity = fillOpacity, + fill = fill, fillColor = fillColor, fillOpacity = fillOpacity, offset = offset, dashArray = dashArray, smoothFactor = smoothFactor, noClip = noClip ))) + + if(!is.null(offset)){ + map$dependencies <- c(map$dependencies, leafletPolylineoffsetDependencies()) + } + pgons <- derivePolygons(data, lng, lat, missing(lng), missing(lat), "addPolylines") invokeMethod(map, data, "addPolylines", pgons, layerId, group, options, popup, popupOptions, safeLabel(label, data), labelOptions, highlightOptions) %>% diff --git a/R/plugin-polylineoffset.R b/R/plugin-polylineoffset.R new file mode 100644 index 000000000..e5bc47716 --- /dev/null +++ b/R/plugin-polylineoffset.R @@ -0,0 +1,10 @@ +leafletPolylineoffsetDependencies <- function() { + list( + htmltools::htmlDependency( + "leaflet-polylineoffset", + "1.1.1", + system.file("htmlwidgets/lib/leaflet-polylineoffset", package = "leaflet"), + script = "leaflet.polylineoffset.js" + ) + ) +} diff --git a/inst/htmlwidgets/leaflet.js b/inst/htmlwidgets/leaflet.js index c6c233bff..e8164eceb 100644 --- a/inst/htmlwidgets/leaflet.js +++ b/inst/htmlwidgets/leaflet.js @@ -1649,9 +1649,9 @@ methods.addCircleMarkers = function (lat, lng, radius, layerId, group, options, * @param lat Array of arrays of latitude coordinates for polylines * @param lng Array of arrays of longitude coordinates for polylines */ -methods.addPolylines = function (polygons, layerId, group, options, popup, popupOptions, label, labelOptions, highlightOptions) { +methods.addPolylines = function (polygons, layerId, group, options, popup, popupOptions, label, labelOptions, highlightOptions, offset) { if (polygons.length > 0) { - var df = new _dataframe2.default().col("shapes", polygons).col("layerId", layerId).col("group", group).col("popup", popup).col("popupOptions", popupOptions).col("label", label).col("labelOptions", labelOptions).col("highlightOptions", highlightOptions).cbind(options); + var df = new _dataframe2.default().col("shapes", polygons).col("layerId", layerId).col("group", group).col("popup", popup).col("popupOptions", popupOptions).col("label", label).col("labelOptions", labelOptions).col("highlightOptions", highlightOptions).col("offset", offset).cbind(options); addLayers(this, "shape", df, function (df, i) { var shapes = df.get(i, "shapes"); diff --git a/inst/htmlwidgets/leaflet.yaml b/inst/htmlwidgets/leaflet.yaml index 0cfedbe93..1505ea57a 100644 --- a/inst/htmlwidgets/leaflet.yaml +++ b/inst/htmlwidgets/leaflet.yaml @@ -22,3 +22,7 @@ dependencies: version: 1.3.1 # match leaflet version src: "htmlwidgets/lib/rstudio_leaflet" stylesheet: rstudio_leaflet.css + - name: leaflet-polylineoffset + version: 1.1.1 # match leaflet version + src: "htmlwidgets/lib/leaflet-polylineoffset" + script: leaflet.polylineoffset.js diff --git a/inst/htmlwidgets/lib/leaflet-polylineoffset/LICENSE b/inst/htmlwidgets/lib/leaflet-polylineoffset/LICENSE new file mode 100644 index 000000000..f3794ddde --- /dev/null +++ b/inst/htmlwidgets/lib/leaflet-polylineoffset/LICENSE @@ -0,0 +1,22 @@ +The MIT License (MIT) + +Copyright (c) 2014 Benjamin Becquet + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + diff --git a/inst/htmlwidgets/lib/leaflet-polylineoffset/README.md b/inst/htmlwidgets/lib/leaflet-polylineoffset/README.md new file mode 100644 index 000000000..ad72f532a --- /dev/null +++ b/inst/htmlwidgets/lib/leaflet-polylineoffset/README.md @@ -0,0 +1,49 @@ +Leaflet Polyline Offset +=== +Works with Leaflet >= 1.0. + +This plugin adds to Leaflet `Polyline`s the ability to be drawn with a relative pixel offset, without modifying their actual `LatLng`s. The offset value can be either negative or positive, for left- or right-side offset, and remains constant across zoom levels. + +## Install with NPM + +```bash +npm install leaflet-polylineoffset +``` + +## Use cases and demos + +Line offsetting is the process of drawing a line parallel to an existant one, at a fixed distance. It's not a simple (x,y) translation of the whole shape, as it shouldn't overlap. It can be used to visually emphasize different properties of the same linear feature, or achieve complex composite styling. + +This plugin brings this feature to Leaflet, to apply to client-side vectors. + +Demos are clearer than words: +* [Basic demo](http://bbecquet.github.io/Leaflet.PolylineOffset/examples/example.html). The dashed line is the "model", with no offset applied. Red is with a -10px offset, green is with a 5px offset. The three are distinct `Polyline` objects but uses the same coordinate array. +* [Cycle lanes](http://bbecquet.github.io/Leaflet.PolylineOffset/examples/example_cycle.html). Drawing a road with two directions of cycle lanes, a main one and one shared. +* [Bus lines](http://bbecquet.github.io/Leaflet.PolylineOffset/examples/example_bus.html). A more complex demo. Offsets are computed automatically depending on the number of bus lines using the same segment. Other non-offset polylines are used to achieve the white and black outline effect. + +## Usage + +The plugin adds offset capabilities directly to the `L.Polyline` class. +```javascript +// Instantiate a normal Polyline with an 'offset' options, in pixels. +var pl = L.polyline([[48.8508, 2.3455], [48.8497, 2.3504], [48.8494, 2.35654]], { + offset: 5 +}); + +// Setting the 'offset' property through the 'setStyle' method won't work. +// If you want to set the offset afterwards, use 'setOffset'. +pl.setOffset(-10); + +// To cancel the offset, simply set it to 0 +pl.setOffset(0); +``` + +## License +MIT. + +## Contributors +* [Benjamin Becquet](https://github.com/bbecquet) (original author) +* [sanderd17](https://github.com/sanderd17) +* [jellevoost](https://github.com/jellevoost) +* [ghybs](https://github.com/ghybs) +* [BartWaardenburg](https://github.com/BartWaardenburg) diff --git a/inst/htmlwidgets/lib/leaflet-polylineoffset/examples/example.html b/inst/htmlwidgets/lib/leaflet-polylineoffset/examples/example.html new file mode 100644 index 000000000..5fdbf911e --- /dev/null +++ b/inst/htmlwidgets/lib/leaflet-polylineoffset/examples/example.html @@ -0,0 +1,59 @@ + + + + + + + Leaflet Polyline Offset example + + + + + + + + + + +
+ + diff --git a/inst/htmlwidgets/lib/leaflet-polylineoffset/examples/example_bus.html b/inst/htmlwidgets/lib/leaflet-polylineoffset/examples/example_bus.html new file mode 100644 index 000000000..56d901d83 --- /dev/null +++ b/inst/htmlwidgets/lib/leaflet-polylineoffset/examples/example_bus.html @@ -0,0 +1,284 @@ + + + + + + + Leaflet Polyline Offset - Bus lines example + + + + + + + + + + +
+ + diff --git a/inst/htmlwidgets/lib/leaflet-polylineoffset/examples/example_cycle.html b/inst/htmlwidgets/lib/leaflet-polylineoffset/examples/example_cycle.html new file mode 100644 index 000000000..cfa7877c2 --- /dev/null +++ b/inst/htmlwidgets/lib/leaflet-polylineoffset/examples/example_cycle.html @@ -0,0 +1,65 @@ + + + + + + + Leaflet Polyline Offset - Cycle lanes example + + + + + + + + + + +
+ + diff --git a/inst/htmlwidgets/lib/leaflet-polylineoffset/leaflet.polylineoffset.js b/inst/htmlwidgets/lib/leaflet-polylineoffset/leaflet.polylineoffset.js new file mode 100644 index 000000000..855abadb8 --- /dev/null +++ b/inst/htmlwidgets/lib/leaflet-polylineoffset/leaflet.polylineoffset.js @@ -0,0 +1,227 @@ +(function (factory, window) { + if (typeof define === 'function' && define.amd) { + define(['leaflet'], factory); + } else if (typeof exports === 'object') { + module.exports = factory(require('leaflet')); + } + if (typeof window !== 'undefined' && window.L) { + window.L.PolylineOffset = factory(L); + } +}(function (L) { + +function forEachPair(list, callback) { + if (!list || list.length < 1) { return; } + for (var i = 1, l = list.length; i < l; i++) { + callback(list[i-1], list[i]); + } +} + +/** +Find the coefficients (a,b) of a line of equation y = a.x + b, +or the constant x for vertical lines +Return null if there's no equation possible +*/ +function lineEquation(pt1, pt2) { + if (pt1.x === pt2.x) { + return pt1.y === pt2.y ? null : { x: pt1.x }; + } + + var a = (pt2.y - pt1.y) / (pt2.x - pt1.x); + return { + a: a, + b: pt1.y - a * pt1.x, + }; +} + +/** +Return the intersection point of two lines defined by two points each +Return null when there's no unique intersection +*/ +function intersection(l1a, l1b, l2a, l2b) { + var line1 = lineEquation(l1a, l1b); + var line2 = lineEquation(l2a, l2b); + + if (line1 === null || line2 === null) { + return null; + } + + if (line1.hasOwnProperty('x')) { + return line2.hasOwnProperty('x') + ? null + : { + x: line1.x, + y: line2.a * line1.x + line2.b, + }; + } + if (line2.hasOwnProperty('x')) { + return { + x: line2.x, + y: line1.a * line2.x + line1.b, + }; + } + + if (line1.a === line2.a) { + return null; + } + + var x = (line2.b - line1.b) / (line1.a - line2.a); + return { + x: x, + y: line1.a * x + line1.b, + }; +} + +function translatePoint(pt, dist, heading) { + return { + x: pt.x + dist * Math.cos(heading), + y: pt.y + dist * Math.sin(heading), + }; +} + +var PolylineOffset = { + offsetPointLine: function(points, distance) { + var offsetSegments = []; + + forEachPair(points, L.bind(function(a, b) { + if (a.x === b.x && a.y === b.y) { return; } + + // angles in (-PI, PI] + var segmentAngle = Math.atan2(a.y - b.y, a.x - b.x); + var offsetAngle = segmentAngle - Math.PI/2; + + offsetSegments.push({ + offsetAngle: offsetAngle, + original: [a, b], + offset: [ + translatePoint(a, distance, offsetAngle), + translatePoint(b, distance, offsetAngle) + ] + }); + }, this)); + + return offsetSegments; + }, + + offsetPoints: function(pts, offset) { + var offsetSegments = this.offsetPointLine(pts, offset); + return this.joinLineSegments(offsetSegments, offset); + }, + + /** + Join 2 line segments defined by 2 points each with a circular arc + */ + joinSegments: function(s1, s2, offset) { + // TODO: different join styles + return this.circularArc(s1, s2, offset) + .filter(function(x) { return x; }) + }, + + joinLineSegments: function(segments, offset) { + var joinedPoints = []; + var first = segments[0]; + var last = segments[segments.length - 1]; + + if (first && last) { + joinedPoints.push(first.offset[0]); + forEachPair(segments, L.bind(function(s1, s2) { + joinedPoints = joinedPoints.concat(this.joinSegments(s1, s2, offset)); + }, this)); + joinedPoints.push(last.offset[1]); + } + + return joinedPoints; + }, + + segmentAsVector: function(s) { + return { + x: s[1].x - s[0].x, + y: s[1].y - s[0].y, + }; + }, + + getSignedAngle: function(s1, s2) { + const a = this.segmentAsVector(s1); + const b = this.segmentAsVector(s2); + return Math.atan2(a.x * b.y - a.y * b.x, a.x * b.x + a.y * b.y); + }, + + /** + Interpolates points between two offset segments in a circular form + */ + circularArc: function(s1, s2, distance) { + // if the segments are the same angle, + // there should be a single join point + if (s1.offsetAngle === s2.offsetAngle) { + return [s1.offset[1]]; + } + + const signedAngle = this.getSignedAngle(s1.offset, s2.offset); + // for inner angles, just find the offset segments intersection + if ((signedAngle * distance > 0) && + (signedAngle * this.getSignedAngle(s1.offset, [s1.offset[0], s2.offset[1]]) > 0)) { + return [intersection(s1.offset[0], s1.offset[1], s2.offset[0], s2.offset[1])]; + } + + // draws a circular arc with R = offset distance, C = original meeting point + var points = []; + var center = s1.original[1]; + // ensure angles go in the anti-clockwise direction + var rightOffset = distance > 0; + var startAngle = rightOffset ? s2.offsetAngle : s1.offsetAngle; + var endAngle = rightOffset ? s1.offsetAngle : s2.offsetAngle; + // and that the end angle is bigger than the start angle + if (endAngle < startAngle) { + endAngle += Math.PI * 2; + } + var step = Math.PI / 8; + for (var alpha = startAngle; alpha < endAngle; alpha += step) { + points.push(translatePoint(center, distance, alpha)); + } + points.push(translatePoint(center, distance, endAngle)); + + return rightOffset ? points.reverse() : points; + } +} + +// Modify the L.Polyline class by overwriting the projection function +L.Polyline.include({ + _projectLatlngs: function (latlngs, result, projectedBounds) { + var isFlat = latlngs.length > 0 && latlngs[0] instanceof L.LatLng; + + if (isFlat) { + var ring = latlngs.map(L.bind(function(ll) { + var point = this._map.latLngToLayerPoint(ll); + if (projectedBounds) { + projectedBounds.extend(point); + } + return point; + }, this)); + + // Offset management hack --- + if (this.options.offset) { + ring = L.PolylineOffset.offsetPoints(ring, this.options.offset); + } + // Offset management hack END --- + + result.push(ring.map(function (xy) { + return L.point(xy.x, xy.y); + })); + } else { + latlngs.forEach(L.bind(function(ll) { + this._projectLatlngs(ll, result, projectedBounds); + }, this)); + } + } +}); + +L.Polyline.include({ + setOffset: function(offset) { + this.options.offset = offset; + this.redraw(); + return this; + } +}); + +return PolylineOffset; + +}, window)); diff --git a/inst/htmlwidgets/lib/leaflet-polylineoffset/package.json b/inst/htmlwidgets/lib/leaflet-polylineoffset/package.json new file mode 100644 index 000000000..54fa89572 --- /dev/null +++ b/inst/htmlwidgets/lib/leaflet-polylineoffset/package.json @@ -0,0 +1,58 @@ +{ + "_from": "leaflet-polylineoffset", + "_id": "leaflet-polylineoffset@1.1.1", + "_inBundle": false, + "_integrity": "sha512-WcEjAROx9IhIVwSMoFy9p2QBCG9YeuGtJl4ZdunIgj4xbCdTrUkBj8JdonUeCyLPnD2/Vrem/raOPHm5LvebSw==", + "_location": "/leaflet-polylineoffset", + "_phantomChildren": {}, + "_requested": { + "type": "tag", + "registry": true, + "raw": "leaflet-polylineoffset", + "name": "leaflet-polylineoffset", + "escapedName": "leaflet-polylineoffset", + "rawSpec": "", + "saveSpec": null, + "fetchSpec": "latest" + }, + "_requiredBy": [ + "#USER", + "/" + ], + "_resolved": "https://registry.npmjs.org/leaflet-polylineoffset/-/leaflet-polylineoffset-1.1.1.tgz", + "_shasum": "021b717d4f7d462f742c1ac1ac0e83e8eef74615", + "_spec": "leaflet-polylineoffset", + "_where": "C:\\Users\\Fernando.Munozmendez\\OneDrive - AECOM Directory\\Projects\\Leaflet_dev\\leaflet", + "author": { + "name": "Benjamin Becquet" + }, + "bugs": { + "url": "https://github.com/bbecquet/Leaflet.PolylineOffset/issues" + }, + "bundleDependencies": false, + "deprecated": false, + "description": "Apply a relative pixel offset to polylines without changing their coordinates.", + "directories": { + "example": "examples" + }, + "homepage": "https://github.com/bbecquet/Leaflet.PolylineOffset#readme", + "keywords": [ + "map", + "leaflet", + "polyline", + "offset", + "layer", + "vector" + ], + "license": "MIT", + "main": "leaflet.polylineoffset.js", + "name": "leaflet-polylineoffset", + "repository": { + "type": "git", + "url": "git+https://github.com/bbecquet/Leaflet.PolylineOffset.git" + }, + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "version": "1.1.1" +} diff --git a/javascript/src/methods.js b/javascript/src/methods.js index 153b1b816..01ae266a9 100644 --- a/javascript/src/methods.js +++ b/javascript/src/methods.js @@ -458,7 +458,7 @@ methods.addCircleMarkers = function(lat, lng, radius, layerId, group, options, c * @param lat Array of arrays of latitude coordinates for polylines * @param lng Array of arrays of longitude coordinates for polylines */ -methods.addPolylines = function(polygons, layerId, group, options, popup, popupOptions, label, labelOptions, highlightOptions) { +methods.addPolylines = function(polygons, layerId, group, options, popup, popupOptions, label, labelOptions, highlightOptions, offset) { if(polygons.length>0) { let df = new DataFrame() .col("shapes", polygons) @@ -469,6 +469,7 @@ methods.addPolylines = function(polygons, layerId, group, options, popup, popupO .col("label", label) .col("labelOptions", labelOptions) .col("highlightOptions", highlightOptions) + .col("offset", offset) .cbind(options); addLayers(this, "shape", df, function(df, i) { diff --git a/man/leafletDependencies.Rd b/man/leafletDependencies.Rd index 54d8959ad..10221830d 100644 --- a/man/leafletDependencies.Rd +++ b/man/leafletDependencies.Rd @@ -4,7 +4,7 @@ \name{leafletDependencies} \alias{leafletDependencies} \title{Various leaflet dependency functions for use in downstream packages} -\format{An object of class \code{list} of length 13.} +\format{An object of class \code{list} of length 14.} \usage{ leafletDependencies } diff --git a/man/map-layers.Rd b/man/map-layers.Rd index 01e1a94b2..69a679dff 100644 --- a/man/map-layers.Rd +++ b/man/map-layers.Rd @@ -70,7 +70,7 @@ addPolylines(map, lng = NULL, lat = NULL, layerId = NULL, fillOpacity = 0.2, dashArray = NULL, smoothFactor = 1, noClip = FALSE, popup = NULL, popupOptions = NULL, label = NULL, labelOptions = NULL, options = pathOptions(), - highlightOptions = NULL, data = getMapData(map)) + highlightOptions = NULL, data = getMapData(map), offset = NULL) addRectangles(map, lng1, lat1, lng2, lat2, layerId = NULL, group = NULL, stroke = TRUE, color = "#03F", weight = 5, @@ -204,6 +204,10 @@ pattern}} \item{noClip}{whether to disable polyline clipping} +\item{offset}{relative pixel offset from their actual LatLngs. The offset +value can be either negative or positive, for left- or right-side offset, +and remains constant across zoom levels.} + \item{lng1, lat1, lng2, lat2}{latitudes and longitudes of the south-west and north-east corners of rectangles} diff --git a/scripts/offset_example.R b/scripts/offset_example.R new file mode 100644 index 000000000..b6eeeaa04 --- /dev/null +++ b/scripts/offset_example.R @@ -0,0 +1,7 @@ +# example + +leaflet(atlStorms2005[1,]) %>% + addTiles() %>% + addPolylines(color = "blue", offset = 10) %>% + addPolylines(color = "black", offset = 0) +