diff --git a/DESCRIPTION b/DESCRIPTION index 4bb1a456a..6aa0cb98b 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -31,5 +31,6 @@ Suggests: maps, shiny, sp, - testit + testit, + R6 VignetteBuilder: knitr diff --git a/NAMESPACE b/NAMESPACE index c58141e62..950002b4c 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -15,20 +15,33 @@ export(addPopups) export(addRectangles) export(addTiles) export(clearBounds) +export(clearGeoJSON) +export(clearMarkers) +export(clearPopups) +export(clearShapes) +export(clearTiles) export(colorBin) export(colorFactor) export(colorNumeric) export(colorQuantile) export(createLeafletMap) +export(dispatch) export(fitBounds) +export(invokeMethod) export(leaflet) export(leafletMap) export(leafletOutput) +export(leafletProxy) export(mapOptions) export(markerOptions) export(pathOptions) export(popupOptions) export(previewColors) +export(removeGeoJSON) +export(removeMarker) +export(removePopup) +export(removeShape) +export(removeTiles) export(renderLeaflet) export(setView) export(tileOptions) diff --git a/R/layers.R b/R/layers.R index 3c818aa81..ff3e8010a 100644 --- a/R/layers.R +++ b/R/layers.R @@ -60,6 +60,7 @@ bboxAdd = function(a, b) { #' @param attribution the attribution text of the tile layer (HTML) #' @param options a list of extra options for tile layers, popups, paths #' (circles, rectangles, polygons, ...), or other map elements +#' @return the new \code{map} object #' @seealso \code{\link{tileOptions}}, \code{\link{popupOptions}}, #' \code{\link{markerOptions}}, \code{\link{pathOptions}} #' @references The Leaflet API documentation: @@ -70,6 +71,7 @@ addTiles = function( map, urlTemplate = 'http://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', attribution = NULL, + layerId = NULL, options = tileOptions() ) { options$attribution = attribution @@ -78,7 +80,7 @@ addTiles = function( '© OpenStreetMap', 'contributors, CC-BY-SA' ) - appendMapData(map, getMapData(map), 'tileLayer', urlTemplate, options) + invokeMethod(map, getMapData(map), 'addTiles', urlTemplate, layerId, options) } #' Extra options for map elements and layers @@ -122,6 +124,40 @@ tileOptions = function( ) } +#' Remove elements from a map +#' +#' Remove one or more features from a map, identified by \code{layerId}; or, +#' clear all features of the given type. +#' +#' @note When used with a \code{\link{leaflet}}() map object, these functions +#' don't actually remove the features from the map object, but simply add an +#' operation that will cause those features to be removed after they are added. +#' In other words, if you add a polygon \code{"foo"} and the call +#' \code{removeShape("foo")}, it's not smart enough to prevent the polygon from +#' being added in the first place; instead, when the map is rendered, the +#' polygon will be added and then removed. +#' +#' For that reason, these functions aren't that useful with \code{leaflet} map +#' objects and are really intended to be used with \code{\link{leafletProxy}} +#' instead. +#' +#' @param map a map widget object, possibly created from \code{\link{leaflet}}() +#' but more likely from \code{\link{leafletProxy}}() +#' @param layerId character vector; the layer id(s) of the item to remove +#' @return the new \code{map} object +#' +#' @name remove +#' @export +removeTiles = function(map, layerId) { + invokeMethod(map, NULL, 'removeTiles', layerId) +} + +#' @rdname remove +#' @export +clearTiles = function(map) { + invokeMethod(map, NULL, 'clearTiles') +} + #' @param 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 #' explicitly provided), it will be automatically inferred from \code{data} by @@ -145,7 +181,7 @@ addPopups = function( data = getMapData(map) ) { pts = derivePoints(data, lng, lat, missing(lng), missing(lat), "addPopups") - appendMapData(map, data, 'popup', pts$lat, pts$lng, popup, layerId, options) %>% + invokeMethod(map, data, 'addPopups', pts$lat, pts$lng, popup, layerId, options) %>% expandLimits(pts$lat, pts$lng) } @@ -177,6 +213,18 @@ popupOptions = function( ) } +#' @rdname remove +#' @export +removePopup = function(map, layerId) { + invokeMethod(map, NULL, 'removePopup', layerId) +} + +#' @rdname remove +#' @export +clearPopups = function(map) { + invokeMethod(map, NULL, 'clearPopups') +} + #' @param icon the icon for markers; if you want to create a new icon using #' JavaScript, please remember to use \code{\link[htmlwidgets]{JS}()} on the #' JavaScript string; see \url{http://leafletjs.com/reference.html#icon} @@ -191,7 +239,7 @@ addMarkers = function( ) { options$icon = icon pts = derivePoints(data, lng, lat, missing(lng), missing(lat), "addMarkers") - appendMapData(map, data, 'marker', pts$lat, pts$lng, layerId, options, popup) %>% + invokeMethod(map, data, 'addMarkers', pts$lat, pts$lng, layerId, options, popup) %>% expandLimits(pts$lat, pts$lng) } @@ -256,10 +304,22 @@ addCircleMarkers = function( dashArray = dashArray )) pts = derivePoints(data, lng, lat, missing(lng), missing(lat), "addCircleMarkers") - appendMapData(map, data, 'circleMarker', pts$lat, pts$lng, radius, layerId, options, popup) %>% + invokeMethod(map, data, 'addCircleMarkers', pts$lat, pts$lng, radius, layerId, options, popup) %>% expandLimits(pts$lat, pts$lng) } +#' @rdname remove +#' @export +removeMarker = function(map, layerId) { + invokeMethod(map, NULL, 'removeMarker', layerId) +} + +#' @rdname remove +#' @export +clearMarkers = function(map) { + invokeMethod(map, NULL, 'clearMarkers') +} + #' @param lineCap a string that defines #' \href{https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/stroke-linecap}{shape #' to be used at the end} of the stroke @@ -306,7 +366,7 @@ addCircles = function( dashArray = dashArray )) pts = derivePoints(data, lng, lat, missing(lng), missing(lat), "addCircles") - appendMapData(map, data, 'circle', pts$lat, pts$lng, radius, layerId, options, popup) %>% + invokeMethod(map, data, 'addCircles', pts$lat, pts$lng, radius, layerId, options, popup) %>% expandLimits(pts$lat, pts$lng) } @@ -337,7 +397,7 @@ addPolylines = function( dashArray = dashArray, smoothFactor = smoothFactor, noClip = noClip )) pgons = derivePolygons(data, lng, lat, missing(lng), missing(lat), "addPolylines") - appendMapData(map, data, 'polyline', pgons, layerId, options, popup) %>% + invokeMethod(map, data, 'addPolylines', pgons, layerId, options, popup) %>% expandLimitsBbox(pgons) } @@ -370,7 +430,7 @@ addRectangles = function( lat1 = resolveFormula(lat1, data) lng2 = resolveFormula(lng2, data) lat2 = resolveFormula(lat2, data) - appendMapData(map, data, 'rectangle',lat1, lng1, lat2, lng2, layerId, options, popup) %>% + invokeMethod(map, data, 'addRectangles',lat1, lng1, lat2, lng2, layerId, options, popup) %>% expandLimits(c(lat1, lat2), c(lng1, lng2)) } @@ -398,13 +458,37 @@ addPolygons = function( dashArray = dashArray, smoothFactor = smoothFactor, noClip = noClip )) pgons = derivePolygons(data, lng, lat, missing(lng), missing(lat), "addPolygons") - appendMapData(map, data, 'polygon', pgons, layerId, options, popup) %>% + invokeMethod(map, data, 'addPolygons', pgons, layerId, options, popup) %>% expandLimitsBbox(pgons) } +#' @rdname remove +#' @export +removeShape = function(map, layerId) { + invokeMethod(map, NULL, 'removeShape', layerId) +} + +#' @rdname remove +#' @export +clearShapes = function(map) { + invokeMethod(map, NULL, 'clearShapes') +} + #' @param geojson a GeoJSON list #' @describeIn map-layers Add GeoJSON layers to the map #' @export addGeoJSON = function(map, geojson, layerId = NULL) { - appendMapData(map, getMapData(map), 'geoJSON', geojson, layerId) + invokeMethod(map, getMapData(map), 'addGeoJSON', geojson, layerId) +} + +#' @rdname remove +#' @export +removeGeoJSON = function(map, layerId) { + invokeMethod(map, NULL, 'removeGeoJSON', layerId) +} + +#' @rdname remove +#' @export +clearGeoJSON = function(map) { + invokeMethod(map, NULL, 'clearGeoJSON') } diff --git a/R/methods.R b/R/methods.R index f7aef2332..bb4a3b9c5 100644 --- a/R/methods.R +++ b/R/methods.R @@ -17,18 +17,39 @@ #' m %>% fitBounds(-72, 40, -70, 43) #' m %>% clearBounds() # world view setView = function(map, lng, lat, zoom, options = list()) { - map$x$setView = list(c(lat, lng), zoom, options) - map$x$fitBounds = NULL - map + view = list(c(lat, lng), zoom, options) + + dispatch(map, + "setView", + leaflet = { + map$x$setView = view + map$x$fitBounds = NULL + map + }, + leaflet_proxy = { + invokeRemote(map, "setView", view) + map + } + ) } #' @describeIn map-methods Set the bounds of a map #' @param lng1,lat1,lng2,lat2 the coordinates of the map bounds #' @export fitBounds = function(map, lng1, lat1, lng2, lat2) { - map$x$fitBounds = list(lat1, lng1, lat2, lng2) - map$x$setView = NULL - map + bounds = list(lat1, lng1, lat2, lng2) + + dispatch(map, + "fitBounds", + leaflet = { + map$x$fitBounds = bounds + map$x$setView = NULL + map + }, + leaflet_proxy = { + invokeRemote(map, "fitBounds", bounds) + } + ) } #' @describeIn map-methods Clear the bounds of a map, and the bounds will be @@ -36,7 +57,12 @@ fitBounds = function(map, lng1, lat1, lng2, lat2) { #' if available (otherwise the full world view is used) #' @export clearBounds = function(map) { - map$x$fitBounds = NULL - map$x$setView = NULL - map + dispatch(map, + "clearBounds", + leaflet = { + map$x$fitBounds = NULL + map$x$setView = NULL + map + } + ) } diff --git a/R/shiny.R b/R/shiny.R index 6a47f3108..1d5afd3ed 100644 --- a/R/shiny.R +++ b/R/shiny.R @@ -29,92 +29,3 @@ renderLeaflet = function(expr, env = parent.frame(), quoted = FALSE) { if (!quoted) expr = substitute(expr) # force quoted htmlwidgets::shinyRenderWidget(expr, leafletOutput, env, quoted = TRUE) } - -#' Create a Leaflet map controller -#' -#' This function is called from \file{server.R} and returns an object that can -#' be used to manipulate the Leaflet map from R. -#' @param session The \code{session} argument passed through from the -#' \code{\link[shiny]{shinyServer}} server function. -#' @param id The string identifier that was passed to -#' \code{\link{leaflet}()}. -#' @return A list of methods. See the package vignette \code{vignette('intro', -#' 'leaflet'} for details. -#' @keywords internal -leafletController = function(session, id) { - - # This function is how we "dynamically" invoke code on the client. The - # method parameter indicates what leaflet operation we want to perform, - # and the other arguments will be serialized to JS objects and used as - # client side function args. - send = function(method, func, msg) { - - msg = msg[names(formals(func))] - names(msg) = NULL - - opts = options(digits = 22) - on.exit(options(opts)) - - session$sendCustomMessage('leaflet', list( - mapId = id, - method = method, - args = msg - )) - - } - - # Turns a call like: - # - # stub(expression(setView(lat, lng, zoom, forceReset = FALSE))) - # - # into: - # - # list(setView = function(lat, lng, zoom, forceReset = FALSE) { - # send("setView", sys.function(), as.list(environment())) - # }) - stub = function(p) { - # The function name is the first element - name = as.character(p[[1]]) - - # Get textual representation of the expression; change name to "function" - # and add a NULL function body - txt = paste(deparse(p), collapse = "\n") - txt = sub(name, "function", txt, fixed = TRUE) - txt = paste0(txt, "NULL") - - # Create the function - func = eval(parse(text = txt)) - - # Replace the function body - body(func) = substituteDirect( - quote(send(name, sys.function(), as.list(environment()))), - list(name = name) - ) - environment(func) = environment(send) - - # Return as list - structure(list(func), names = name) - } - - obj = lapply(expression( - setView(lat, lng, zoom, forceReset = FALSE), - addMarker(lat, lng, layerId = NULL, options = list()), - addCircleMarker(lat, lng, radius, layerId = NULL, options = list()), - clearMarkers(), - clearShapes(), - fitBounds(lat1, lng1, lat2, lng2), - addCircle(lat, lng, radius, layerId = NULL, options = list()), - addRectangle(lat1, lng1, lat2, lng2, layerId = NULL, options = list()), - addPolyline(lat, lng, layerId, options = list()), - addPolygon(lat, lng, layerId, options = list()), - addGeoJSON(data, layerId), - showPopup(lat, lng, content, layerId = NULL, options = list()), - removePopup(layerId), - clearPopups(), - removeShape(layerId), - clearShapes(), - removeMarker(layerId), - clearMarkers() - ), stub) - unlist(obj, recursive = FALSE) -} diff --git a/R/utils.R b/R/utils.R index 0c86f6dd5..6bbe67c12 100644 --- a/R/utils.R +++ b/R/utils.R @@ -1,19 +1,165 @@ +# Given a local and/or remote operation and a map, execute one or the other +# depending on the type of the map object (regular or map proxy). If code was +# not provided for the appropriate mode, an error will be raised. + +#' Extension points for plugins +#' +#' @param map a map object, as returned from \code{\link{leaflet}} or +#' \code{\link{leafletProxy}} +#' @param funcName the name of the function that the user called that caused +#' this \code{dispatch} call; for error message purposes +#' @param leaflet an action to be performed if the map is from +#' \code{\link{leaflet}} +#' @param leaflet_proxy an action to be performed if the map is from +#' \code{\link{leafletProxy}} +#' +#' @return \code{dispatch} returns the value of \code{leaflet} or +#' \code{leaflet_proxy}, or an error. \code{invokeMethod} returns the +#' \code{map} object that was passed in, possibly modified. +#' +#' @export +dispatch = function(map, + funcName, + leaflet = stop(paste(funcName, "requires a map proxy object")), + leaflet_proxy = stop(paste(funcName, "does not support map proxy objects")) +) { + if (inherits(map, "leaflet")) + return(leaflet) + else if (inherits(map, "leaflet_proxy")) + return(leaflet_proxy) + else + stop("Invalid map parameter") +} + # remove NULL elements from a list filterNULL = function(x) { if (length(x) == 0 || !is.list(x)) return(x) x[!unlist(lapply(x, is.null))] } -appendMapData = function(map, data, component, ...) { - x = map$x$calls - if (is.null(x)) x = list() - n = length(x) - x[[n + 1]] = list( - method = component, - args = evalFormula(list(...), data) +#' @param data a data object that will be used when evaluating formulas in +#' \code{...} +#' @param method the name of the JavaScript method to invoke +#' @param ... unnamed arguments to be passed to the JavaScript method +#' @rdname dispatch +#' @export +invokeMethod = function(map, data, method, ...) { + args = evalFormula(list(...), data) + + dispatch(map, + method, + leaflet = { + x = map$x$calls + if (is.null(x)) x = list() + n = length(x) + x[[n + 1]] = list(method = method, args = args) + map$x$calls = x + map + }, + leaflet_proxy = { + invokeRemote(map, method, args) + map + } + ) +} + +#' Send commands to a Leaflet instance in a Shiny app +#' +#' Creates a map-like object that can be used to customize and control a map +#' that has already been rendered. For use in Shiny apps and Shiny docs only. +#' +#' Normally, you create a Leaflet map using the \code{\link{leaflet}} function. +#' This creates an in-memory representation of a map that you can customize +#' using functions like \code{\link{addPolygons}} and \code{\link{setView}}. +#' Such a map can be printed at the R console, included in an R Markdown +#' document, or rendered as a Shiny output. +#' +#' In the case of Shiny, you may want to further customize a map, even after it +#' is rendered to an output. At this point, the in-memory representation of the +#' map is long gone, and the user's web browser has already realized the Leaflet +#' map instance. +#' +#' This is where \code{leafletProxy} comes in. It returns an object that can +#' stand in for the usual Leaflet map object. The usual map functions like +#' \code{\link{addPolygons}} and \code{\link{setView}} can be called, and +#' instead of customizing an in-memory representation, these commands will +#' execute on the live Leaflet map instance. +#' +#' @param mapId single-element character vector indicating the output ID of the +#' map to modify +#' @param session the Shiny session object to which the map belongs; usually the +#' default value will suffice +#' @param data a data object; see Details under the \code{\link{leaflet}} help +#' topic +#' @param deferUntilFlush indicates whether actions performed against this +#' instance should be carried out right away, or whether they should be held +#' until after the next time all of the outputs are updated; defaults to +#' \code{TRUE} +#' +#' @examples +#' \donttest{ +#' library(shiny) +#' +#' ui <- fluidPage( +#' leafletOutput("map1") +#' ) +#' +#' server <- function(input, output, session) { +#' output$map1 <- renderLeaflet({ +#' leaflet() %>% addCircleMarkers( +#' lng = runif(10), +#' lat = runif(10), +#' layerId = paste0("marker", 1:10)) +#' }) +#' +#' observeEvent(input$map1_marker_click, { +#' leafletProxy("map1", session) %>% +#' removeMarker(input$map1_marker_click$id) +#' }) +#' } +#' +#' shinyApp(ui, server) +#' +#' } +#' +#' @export +leafletProxy <- function(mapId, session = shiny::getDefaultReactiveDomain(), + data = NULL, deferUntilFlush = TRUE) { + structure( + list( + session = session, + id = mapId, + x = structure( + list(), + leafletData = data + ), + deferUntilFlush = deferUntilFlush + ), + class = "leaflet_proxy" + ) +} + +invokeRemote = function(map, method, args = list()) { + if (!inherits(map, "leaflet_proxy")) + stop("Invalid map parameter; map proxy object was expected") + + msg <- list( + id = map$id, + calls = list( + list( + method = method, + args = args + ) + ) ) - map$x$calls = x - map + + if (map$deferUntilFlush) { + map$session$onFlushed(function() { + map$session$sendCustomMessage("leaflet-calls", msg) + }, once = TRUE) + } else { + map$session$sendCustomMessage("leaflet-calls", msg) + } } # A helper function to generate the body of function(x, y) list(x = x, y = y), diff --git a/inst/examples/shiny.R b/inst/examples/shiny.R index 3d45fbcb1..143352721 100644 --- a/inst/examples/shiny.R +++ b/inst/examples/shiny.R @@ -5,6 +5,8 @@ geodata <- paste(readLines(system.file("examples/test.json", package = "leaflet" ui <- fluidPage( leafletOutput("map1"), + checkboxInput("addMarker", "Add marker on click"), + actionButton("clearMarkers", "Clear all markers"), textOutput("message", container = h3) ) @@ -38,6 +40,10 @@ server <- function(input, output, session) { }) observeEvent(input$map1_click, { v$msg <- paste("Clicked map at", input$map1_click$lat, "/", input$map1_click$lng) + if (input$addMarker) { + leafletProxy("map1") %>% + addMarkers(lng = input$map1_click$lng, lat = input$map1_click$lat) + } }) observeEvent(input$map1_zoom, { v$msg <- paste("Zoom changed to", input$map1_zoom) @@ -45,6 +51,9 @@ server <- function(input, output, session) { observeEvent(input$map1_bounds, { v$msg <- paste("Bounds changed to", paste(input$map1_bounds, collapse = ", ")) }) + observeEvent(input$clearMarkers, { + leafletProxy("map1") %>% clearMarkers() + }) output$message <- renderText(v$msg) } diff --git a/inst/htmlwidgets/leaflet.js b/inst/htmlwidgets/leaflet.js index ab9cc77ee..cb43c49fa 100644 --- a/inst/htmlwidgets/leaflet.js +++ b/inst/htmlwidgets/leaflet.js @@ -166,9 +166,16 @@ var dataframe = (function() { }; LayerStore.prototype.remove = function(id) { - if (this._layers[id]) { - this._group.removeLayer(this._layers[id]); - delete this._layers[id]; + if (typeof(id) === 'undefined' || id === null) { + return; + } + + id = asArray(id); + for (var i = 0; i < id.length; i++) { + if (this._layers[id[i]]) { + this._group.removeLayer(this._layers[id[i]]); + delete this._layers[id[i]]; + } } }; @@ -222,7 +229,8 @@ var dataframe = (function() { Shiny.onInputChange(id + '_zoom', map.getZoom()); } - var methods = {}; + window.LeafletWidget = {}; + var methods = window.LeafletWidget.methods = {}; methods.setView = function(center, zoom, options) { this.setView(center, zoom, options); @@ -234,7 +242,7 @@ var dataframe = (function() { ]); }; - methods.popup = function(lat, lng, popup, layerId, options) { + methods.addPopups = function(lat, lng, popup, layerId, options) { var df = dataframe.create() .col('lat', lat) .col('lng', lng) @@ -264,11 +272,19 @@ var dataframe = (function() { this.popups.clear(); }; - methods.tileLayer = function(urlTemplate, options) { - this.tiles.add(L.tileLayer(urlTemplate, options)); + methods.addTiles = function(urlTemplate, layerId, options) { + this.tiles.add(L.tileLayer(urlTemplate, options), layerId); }; - methods.marker = function(lat, lng, layerId, options, popup) { + methods.removeTiles = function(layerId) { + this.tiles.remove(layerId); + }; + + methods.clearTiles = function() { + this.tiles.clear(); + }; + + methods.addMarkers = function(lat, lng, layerId, options, popup) { var df = dataframe.create() .col('lat', lat) .col('lng', lng) @@ -290,7 +306,7 @@ var dataframe = (function() { } }; - methods.circle = function(lat, lng, radius, layerId, options, popup) { + methods.addCircles = function(lat, lng, radius, layerId, options, popup) { var df = dataframe.create() .col('lat', lat) .col('lng', lng) @@ -313,7 +329,7 @@ var dataframe = (function() { } }; - methods.circleMarker = function(lat, lng, radius, layerId, options, popup) { + methods.addCircleMarkers = function(lat, lng, radius, layerId, options, popup) { var df = dataframe.create() .col('lat', lat) .col('lng', lng) @@ -340,7 +356,7 @@ var dataframe = (function() { * @param lat Array of arrays of latitude coordinates for polylines * @param lng Array of arrays of longitude coordinates for polylines */ - methods.polyline = function(polygons, layerId, options, popup) { + methods.addPolylines = function(polygons, layerId, options, popup) { var df = dataframe.create() .col('shapes', polygons) .col('layerId', layerId) @@ -379,7 +395,7 @@ var dataframe = (function() { this.shapes.clear(); }; - methods.rectangle = function(lat1, lng1, lat2, lng2, layerId, options, popup) { + methods.addRectangles = function(lat1, lng1, lat2, lng2, layerId, options, popup) { var df = dataframe.create() .col('lat1', lat1) .col('lng1', lng1) @@ -411,7 +427,7 @@ var dataframe = (function() { * @param lat Array of arrays of latitude coordinates for polygons * @param lng Array of arrays of longitude coordinates for polygons */ - methods.polygon = function(polygons, layerId, options, popup) { + methods.addPolygons = function(polygons, layerId, options, popup) { var df = dataframe.create() .col('shapes', polygons) .col('layerId', layerId) @@ -436,7 +452,7 @@ var dataframe = (function() { } }; - methods.geoJSON = function(data, layerId) { + methods.addGeoJSON = function(data, layerId) { var self = this; if (typeof(data) === "string") { data = JSON.parse(data); @@ -467,6 +483,16 @@ var dataframe = (function() { this.geojson.add(gjlayer, layerId); }; + methods.removeGeoJSON = function(layerId) { + this.geojson.remove(layerId); + }; + + methods.clearGeoJSON = function() { + this.geojson.clear(); + }; + + + HTMLWidgets.widget({ name: "leaflet", type: "output", @@ -490,6 +516,9 @@ var dataframe = (function() { map.id = this.getId(el); + // Store the map on the element so we can find it later by ID + $(el).data("leaflet-map", map); + // When the map is clicked, send the coordinates back to the app map.on('click', function(e) { Shiny.onInputChange(map.id + '_click', { @@ -564,6 +593,8 @@ var dataframe = (function() { var call = data.calls[i]; if (methods[call.method]) methods[call.method].apply(map, call.args); + else + console.log("Unknown method " + call.method); } map.leafletr.hasRendered = true; @@ -579,17 +610,21 @@ var dataframe = (function() { if (!HTMLWidgets.shinyMode) return; - // Shiny support via the Leaflet map controller - Shiny.addCustomMessageHandler('leaflet', function(data) { - var mapId = data.mapId; - var map = document.getElementById(mapId); - if (!map) + Shiny.addCustomMessageHandler('leaflet-calls', function(data) { + var id = data.id; + var el = document.getElementById(id); + var map = el ? $(el).data('leaflet-map') : null; + if (!map) { + console.log("Couldn't find map with id " + id); return; + } - if (methods[data.method]) { - methods[data.method].apply(map, data.args); - } else { - throw new Error('Unknown method ' + data.method); + for (var i = 0; i < data.calls.length; i++) { + var call = data.calls[i]; + if (methods[call.method]) + methods[call.method].apply(map, call.args); + else + console.log("Unknown method " + call.method); } }); diff --git a/man/dispatch.Rd b/man/dispatch.Rd new file mode 100644 index 000000000..0034e4842 --- /dev/null +++ b/man/dispatch.Rd @@ -0,0 +1,39 @@ +% Please edit documentation in R/utils.R +\name{dispatch} +\alias{dispatch} +\alias{invokeMethod} +\title{Extension points for plugins} +\usage{ +dispatch(map, funcName, leaflet = stop(paste(funcName, "requires a map proxy object")), + leaflet_proxy = stop(paste(funcName, "does not support map proxy objects"))) + +invokeMethod(map, data, method, ...) +} +\arguments{ +\item{map}{a map object, as returned from \code{\link{leaflet}} or +\code{\link{leafletProxy}}} + +\item{funcName}{the name of the function that the user called that caused +this \code{dispatch} call; for error message purposes} + +\item{leaflet}{an action to be performed if the map is from +\code{\link{leaflet}}} + +\item{leaflet_proxy}{an action to be performed if the map is from + \code{\link{leafletProxy}}} + +\item{data}{a data object that will be used when evaluating formulas in +\code{...}} + +\item{method}{the name of the JavaScript method to invoke} + +\item{...}{unnamed arguments to be passed to the JavaScript method} +} +\value{ +\code{dispatch} returns the value of \code{leaflet} or + \code{leaflet_proxy}, or an error. \code{invokeMethod} returns the + \code{map} object that was passed in, possibly modified. +} +\description{ +Extension points for plugins +} diff --git a/man/leafletController.Rd b/man/leafletController.Rd deleted file mode 100644 index 396815439..000000000 --- a/man/leafletController.Rd +++ /dev/null @@ -1,23 +0,0 @@ -% Please edit documentation in R/shiny.R -\name{leafletController} -\alias{leafletController} -\title{Create a Leaflet map controller} -\usage{ -leafletController(session, id) -} -\arguments{ -\item{session}{The \code{session} argument passed through from the -\code{\link[shiny]{shinyServer}} server function.} - -\item{id}{The string identifier that was passed to -\code{\link{leaflet}()}.} -} -\value{ -A list of methods. See the package vignette \code{vignette('intro', - 'leaflet'} for details. -} -\description{ -This function is called from \file{server.R} and returns an object that can -be used to manipulate the Leaflet map from R. -} -\keyword{internal} diff --git a/man/leafletProxy.Rd b/man/leafletProxy.Rd new file mode 100644 index 000000000..e756f3750 --- /dev/null +++ b/man/leafletProxy.Rd @@ -0,0 +1,66 @@ +% Please edit documentation in R/utils.R +\name{leafletProxy} +\alias{leafletProxy} +\title{Send commands to a Leaflet instance in a Shiny app} +\usage{ +leafletProxy(mapId, session = shiny::getDefaultReactiveDomain(), data = NULL, + deferUntilFlush = TRUE) +} +\arguments{ +\item{mapId}{single-element character vector indicating the output ID of the +map to modify} + +\item{session}{the Shiny session object to which the map belongs; usually the +default value will suffice} + +\item{data}{a data object; see Details under the \code{\link{leaflet}} help +topic} + +\item{deferUntilFlush}{indicates whether actions performed against this + instance should be carried out right away, or whether they should be held + until after the next time all of the outputs are updated; defaults to + \code{TRUE}} +} +\description{ +Creates a map-like object that can be used to customize and control a map +that has already been rendered. For use in Shiny apps and Shiny docs only. +} +\details{ +Normally, you create a Leaflet map using the \code{\link{leaflet}} function. +This creates an in-memory representation of a map that you can customize +using functions like \code{\link{addPolygons}} and \code{\link{setView}}. +Such a map can be printed at the R console, included in an R Markdown +document, or rendered as a Shiny output. + +In the case of Shiny, you may want to further customize a map, even after it +is rendered to an output. At this point, the in-memory representation of the +map is long gone, and the user's web browser has already realized the Leaflet +map instance. + +This is where \code{leafletProxy} comes in. It returns an object that can +stand in for the usual Leaflet map object. The usual map functions like +\code{\link{addPolygons}} and \code{\link{setView}} can be called, and +instead of customizing an in-memory representation, these commands will +execute on the live Leaflet map instance. +} +\examples{ +\donttest{ +library(shiny) + +ui <- fluidPage(leafletOutput("map1")) + +server <- function(input, output, session) { + output$map1 <- renderLeaflet({ + leaflet() \%>\% addCircleMarkers(lng = runif(10), lat = runif(10), + layerId = paste0("marker", 1:10)) + }) + + observeEvent(input$map1_marker_click, { + leafletProxy("map1", session) \%>\% removeMarker(input$map1_marker_click$id) + }) +} + +shinyApp(ui, server) + +} +} diff --git a/man/map-layers.Rd b/man/map-layers.Rd index b9757303c..3baa53905 100644 --- a/man/map-layers.Rd +++ b/man/map-layers.Rd @@ -12,7 +12,7 @@ \title{Graphics elements and layers} \usage{ addTiles(map, urlTemplate = "http://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", - attribution = NULL, options = tileOptions()) + attribution = NULL, layerId = NULL, options = tileOptions()) addPopups(map, lng = NULL, lat = NULL, popup, layerId = NULL, options = popupOptions(), data = getMapData(map)) @@ -54,6 +54,8 @@ addGeoJSON(map, geojson, layerId = NULL) \item{attribution}{the attribution text of the tile layer (HTML)} +\item{layerId}{the layer id} + \item{options}{a list of extra options for tile layers, popups, paths (circles, rectangles, polygons, ...), or other map elements} @@ -71,8 +73,6 @@ the latitude column from \code{data})} recommended to escape the text using \code{\link[htmltools]{htmlEscape}()} for security reasons)} -\item{layerId}{the layer id} - \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} @@ -115,6 +115,9 @@ north-east corners of rectangles} \item{geojson}{a GeoJSON list} } +\value{ +the new \code{map} object +} \description{ Add graphics elements and layers to the map widget. } diff --git a/man/remove.Rd b/man/remove.Rd new file mode 100644 index 000000000..60fc82ff0 --- /dev/null +++ b/man/remove.Rd @@ -0,0 +1,61 @@ +% Please edit documentation in R/layers.R +\name{remove} +\alias{clearGeoJSON} +\alias{clearMarkers} +\alias{clearPopups} +\alias{clearShapes} +\alias{clearTiles} +\alias{remove} +\alias{removeGeoJSON} +\alias{removeMarker} +\alias{removePopup} +\alias{removeShape} +\alias{removeTiles} +\title{Remove elements from a map} +\usage{ +removeTiles(map, layerId) + +clearTiles(map) + +removePopup(map, layerId) + +clearPopups(map) + +removeMarker(map, layerId) + +clearMarkers(map) + +removeShape(map, layerId) + +clearShapes(map) + +removeGeoJSON(map, layerId) + +clearGeoJSON(map) +} +\arguments{ +\item{map}{a map widget object, possibly created from \code{\link{leaflet}}() +but more likely from \code{\link{leafletProxy}}()} + +\item{layerId}{character vector; the layer id(s) of the item to remove} +} +\value{ +the new \code{map} object +} +\description{ +Remove one or more features from a map, identified by \code{layerId}; or, +clear all features of the given type. +} +\note{ +When used with a \code{\link{leaflet}}() map object, these functions +don't actually remove the features from the map object, but simply add an +operation that will cause those features to be removed after they are added. +In other words, if you add a polygon \code{"foo"} and the call +\code{removeShape("foo")}, it's not smart enough to prevent the polygon from +being added in the first place; instead, when the map is rendered, the +polygon will be added and then removed. + +For that reason, these functions aren't that useful with \code{leaflet} map +objects and are really intended to be used with \code{\link{leafletProxy}} +instead. +} diff --git a/tests/testit/test-colors.R b/tests/testit/test-colors.R index 4f73090bb..44bc18d16 100644 --- a/tests/testit/test-colors.R +++ b/tests/testit/test-colors.R @@ -10,6 +10,7 @@ assert( ) # Outside of domain? Return na.color +suppressWarnings( assert( identical("#808080", colorFactor(bw, letters)("foo")), identical("#808080", colorQuantile(bw, 0:1)(-1)), @@ -28,6 +29,7 @@ assert( has_warning(colorNumeric(bw, c(0, 1), na.color = NA)(2)), TRUE ) +) assert( identical( diff --git a/tests/testit/test-normalize.R b/tests/testit/test-normalize.R index b4250ca9e..1c67e0816 100644 --- a/tests/testit/test-normalize.R +++ b/tests/testit/test-normalize.R @@ -8,7 +8,7 @@ assert( identical(guessLL(c('lat', 'long')), list(lng = 'long', lat = 'lat')), identical(guessLL(c('latitude', 'lng')), list(lng = 'lng', lat = 'latitude')), identical(guessLL(c('Latitude', 'Long')), list(lng = 'Long', lat = 'Latitude')), - identical(guessLL(c('Lat', 'lng', 'latt')), list(lng = 'lng', lat = 'Lat')), + identical(suppressMessages(guessLL(c('Lat', 'lng', 'latt'))), list(lng = 'lng', lat = 'Lat')), identical(guessLL(c('Lat', 'foo'), stopOnFailure = FALSE), list(lng = NA, lat = NA)), TRUE ) diff --git a/tests/testit/test-remote.R b/tests/testit/test-remote.R new file mode 100644 index 000000000..4271bc401 --- /dev/null +++ b/tests/testit/test-remote.R @@ -0,0 +1,156 @@ +library(testit) +library(R6) + +# This class is copied from Shiny +Map <- R6Class( + 'Map', + portable = FALSE, + public = list( + initialize = function() { + private$env <- new.env(parent=emptyenv()) + }, + get = function(key) { + env[[key]] + }, + set = function(key, value) { + env[[key]] <- value + value + }, + mset = function(...) { + args <- list(...) + if (length(args) == 0) + return() + + arg_names <- names(args) + if (is.null(arg_names) || any(!nzchar(arg_names))) + stop("All elements must be named") + + list2env(args, envir = env) + }, + remove = function(key) { + if (!self$containsKey(key)) + return(NULL) + + result <- env[[key]] + rm(list=key, envir=env, inherits=FALSE) + result + }, + containsKey = function(key) { + exists(key, envir=env, inherits=FALSE) + }, + keys = function() { + # Sadly, this is much faster than ls(), because it doesn't sort the keys. + names(as.list(env, all.names=TRUE)) + }, + values = function() { + as.list(env, all.names=TRUE) + }, + clear = function() { + private$env <- new.env(parent=emptyenv()) + invisible(NULL) + }, + size = function() { + length(env) + } + ), + + private = list( + env = 'environment' + ) +) + + +# This class is copied from Shiny +Callbacks <- R6Class( + 'Callbacks', + portable = FALSE, + class = FALSE, + public = list( + .nextId = integer(0), + .callbacks = 'Map', + + initialize = function() { + .nextId <<- as.integer(.Machine$integer.max) + .callbacks <<- Map$new() + }, + register = function(callback) { + id <- as.character(.nextId) + .nextId <<- .nextId - 1L + .callbacks$set(id, callback) + return(function() { + .callbacks$remove(id) + }) + }, + invoke = function(..., onError=NULL) { + for (callback in .callbacks$values()) { + if (is.null(onError)) { + callback(...) + } else { + tryCatch(callback(...), error = onError) + } + } + }, + count = function() { + .callbacks$size() + } + ) +) + + +MockSession <- R6Class("MockSession", + public = list( + sendCustomMessage = function(type, message) { + self$.calls <- c(self$.calls, list(list( + type = type, + message = shiny:::toJSON(message) + ))) + }, + onFlushed = function(func, once = TRUE) { + unregister <- private$flushCallbacks$register(function(...) { + func(...) + if (once) + unregister() + }) + }, + .flush = function() { + private$flushCallbacks$invoke() + }, + .calls = list() + ), + private = list( + flushCallbacks = Callbacks$new() + ) +) + + +local <- leaflet() + +mockSession <- MockSession$new() +remote <- leafletProxy("map", mockSession) + +remote %>% addPolygons(lng=1:5, lat=1:5) + +# Check that remote functions only get invoked after flush, by default +assert(identical(mockSession$.calls, list())) +mockSession$.flush() +expected <- list( + list(type = "leaflet-calls", message = structure("{\"id\":\"map\",\"calls\":[{\"method\":\"addPolygons\",\"args\":[[[{\"lng\":[1,2,3,4,5],\"lat\":[1,2,3,4,5]}]],null,{\"lineCap\":null,\"lineJoin\":null,\"clickable\":true,\"pointerEvents\":null,\"className\":\"\",\"stroke\":true,\"color\":\"#03F\",\"weight\":5,\"opacity\":0.5,\"fill\":true,\"fillColor\":\"#03F\",\"fillOpacity\":0.2,\"dashArray\":null,\"smoothFactor\":1,\"noClip\":false},null]}]}", class = "json")) +) +assert(identical(mockSession$.calls, expected)) + + +# Reset mock session +mockSession$.calls <- list() + +# Create another remote map which doesn't wait until flush +remote2 <- leafletProxy("map", mockSession, + data.frame(lat=10:1, lng=10:1), + deferUntilFlush = FALSE +) +# Check that addMarkers() takes effect immediately, no flush required +remote2 %>% addMarkers() +expected <- list(list(type = "leaflet-calls", message = structure("{\"id\":\"map\",\"calls\":[{\"method\":\"addMarkers\",\"args\":[[10,9,8,7,6,5,4,3,2,1],[10,9,8,7,6,5,4,3,2,1],null,{\"clickable\":true,\"draggable\":false,\"keyboard\":true,\"title\":\"\",\"alt\":\"\",\"zIndexOffset\":0,\"opacity\":1,\"riseOnHover\":false,\"riseOffset\":250},null]}]}", class = "json"))) +assert(identical(mockSession$.calls, expected)) +# Flushing should do nothing +mockSession$.flush() +assert(identical(mockSession$.calls, expected))