walkerke / mapgl

R interface to Mapbox GL JS v3 and Maplibre GL JS
https://walker-data.com/mapgl
Other
91 stars 5 forks source link

Allow layering of basemaps and setting of z-indices (if possible with maplibre/mapbox gl js) #7

Open kmcd39 opened 4 months ago

kmcd39 commented 4 months ago

Hi!

I'm making plans to switch from leaflet to either deck.gl or maplibre gl js for a lot of my interactive mapping / shiny development work.

Using R/leaflet, I made a practice of initializing maps with different layers: one w/o labels and another with labels separate, and I put the data between them by setting z-indices.

For example, in leaflet, I had:

leaflet() %>%
    addMapPane("tileLabels", # place name labels
               zIndex = 599) %>%
    addProviderTiles(providers$CartoDB.PositronNoLabels) %>%
    addProviderTiles(providers$CartoDB.PositronOnlyLabels,
                     group = 'Place names',
                     options =
                       providerTileOptions(
                         pane = "tileLabels",
                       )) 

And then I could also add map panes for the data with a z-index between the NoLabel layer and the OnlyLabel layer, and give user ability to toggle labels on/off, and have labels appear above the data by default.

I don't see a way to layer provider tiles or basemaps in any R interface to maplibre or other gl js packages, like mapbox or deck.gl. If it's possible, i'd love to see features to layer basemaps and set z-indices using mapgl!!!!

Thank you! I use many of your packages quite frequently.

walkerke commented 4 months ago

Thanks for the note! This is actually natively supported in Mapbox / Maplibre - I'll need to make a few edits to expose it. I'll work up an example to show how!

RWParsons commented 4 months ago

Hi @walkerke,

I'd be really keen to see an example here showing that it's possible to show a map with the polygon layer above the base tiles but have the place labels and road networks above the polygon layer. I've seen it looks possible with mapbox (links below) but I'm not familiar enough with it to implement it within my shiny app.

https://stackoverflow.com/questions/42753217/mapbox-gl-js-display-map-labels-above-layer https://docs.mapbox.com/mapbox-gl-js/example/geojson-layer-in-stack/

Thanks for your work on this package - I really look forward to being able to move my current shiny app (currently leaflet/leafgl) to using mapgl!

walkerke commented 4 months ago

This is now implemented! If you are using Mapbox's new Standard style, there is a slot argument that makes this easier. If you are using MapLibre or one of the other styles, you'll need to use the new before_id argument that tells the mapping engine which layer to put your layer "before" in the stack. The name of this layer may vary from style to style. I use Mapbox Studio to look this up for Mapbox styles; I need to look into how to do this for Carto and MapTiler.

Here's how it works:

A regular map with data on top of everything (default behavior):

library(tigris)
library(mapgl)
options(tigris_use_cache = TRUE)

dallas <- tracts("TX", "Dallas", cb = TRUE)

# Above labels (default)
mapboxgl(mapbox_style("streets")) |> 
  fit_bounds(dallas) |> 
  add_fill_layer(
    id = "dallas",
    source = dallas,
    fill_color = "green",
    fill_opacity = 0.5
  )
image

Below place labels, above road networks:

# Below labels with `before_id`: 
mapboxgl(mapbox_style("streets")) |> 
  fit_bounds(dallas) |> 
  add_fill_layer(
    id = "dallas",
    source = dallas,
    fill_color = "green",
    fill_opacity = 0.5,
    before_id = "building-entrance"
  )
image

Below road networks as well:

# Below roads as well: 
mapboxgl(mapbox_style("streets")) |> 
  fit_bounds(dallas) |> 
  add_fill_layer(
    id = "dallas",
    source = dallas,
    fill_color = "green",
    fill_opacity = 0.5,
    before_id = "tunnel-path"
  )
image

I'll leave this issue open for now as I don't have an options to do the labels toggle you described yet; this is possible in Shiny with set_config_property() but I'll try to handle it alongside #5 as well.

kmcd39 commented 3 months ago

Okay.

These are awesome new features.

But I'm trying to work out a fairly graceful way of rearranging layers, including both layers of the vector tile basemap and data that i add to the map, and am not sure there's a clear way to do so yet.

I was playing with splicing apart the json that defines the base tiles, which would let me implement what I did in leaflet in the first example as directly as possible. That is: I plucked out layers from the json, separated the base map into roads, background, labels, etc. Then I was hoping to use mapgl::add_vector_source and/or add_layer to add different portions of the base map, along with my data layers, and layer them in front of the data and give option to toggle their visibility.

It may be possible but just a little underdocumented right now, but my attempts to use add_layer with portions of the basemap (defined by json) don't work either.

I get how I can toggle visibility of layers, including portions of the basemap, with mapgl::add_layers_control, but the basemap has so many layers -- 23 different layers, just for roads, using the carto json -- that this doesn't really work for many contexts.

Repex to help illustrate:

library(tidyverse)
library(sf)

library(mapgl)

carto.json <-
  jsonlite::read_json("https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json"
                      ,simplifyVector = F)

carto.json.tibble <-
  jsonlite::read_json("https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json"
                      ,simplifyVector = T) %>% tibble()

pluck.layer.ids <- function(layers.json) {
  map_chr(layers.json, pluck("id"))
}

regx.json.filter <- function(mapgl.json, regx) {
  mapgl.json$layers[ grepl(regx, pluck.layer.ids(mapgl.json$layers) ) ]
}

# to look at all the layers:
carto.json$layers %>% pluck.layer.ids()

# create two json objects with only the base layers and the road/rail/other transpo layers:
base.json <- carto.json
base.json$layers <- base.json$layers %>%
  head(11) # up until railways

transpo.json <- carto.json
transpo.json$layers <- carto.json %>%
  regx.json.filter("^aeroway|^tunnel|^road_|^rail|^bridge")

# i get to play with the vector base layers this way, but without grouping them together, it's too clumsy:
road.lyr.ids <- pluck.layer.ids(carto.json$layers)[grepl("^road", pluck.layer.ids(carto.json$layers))]

mapgl::maplibre(
  style =
    carto.json) %>%
 mapgl::add_layers_control(
    position = "top-right"
    ,layers = road.lyr.ids  # 23 road layers!
    ,collapsible = T
  )

I'm hoping for a way to do at least one of the following: 1) add a vector layer defined by json to an maplibre map. (I.e., add transpo.json as a layer when map began with only base.json as the style.) 2) use mapgl::add_layers_control to toggle groups of basemap layers all at once (i.e., define a number of layer_ids in the basemap as belonging to a group, and then toggle them on/off all at once) 3) use a function like mapgl::set_layout_property to change the slot and/or before_id of my data layer, without having to redraw the whole data layer.

walkerke commented 3 months ago

Good questions! Regarding your three points:

  1. This is do-able but I don't see any examples anywhere that don't involve hosting the JSON remotely and pulling it into the url argument of addSource() (which I connect to with add_vector_source().
  2. Including grouped layers in the layers control would be a nice feature and something that I think would be do-able.
  3. set_layout_property() can't interact with before_id as before_id isn't a layout property. There is a method, moveLayer(), in MapLibre / Mapbox that appears to do what you want; a move_layer() function could perhaps invoke this in a Shiny session.
kmcd39 commented 3 months ago

Hm. Okay. I wanna try (1) with local Json. i'd be interested in seeing a example doing something like this using the mapgl library...

For the other two, amazing. I hope these may become mapgl features in the future!

CIOData commented 1 month ago

I concur with a need for more control on layer ordering. I'm getting weird behavior in a dynamic mapping application. I have a fill layer, a potential circle layer, and potential additional line or fill layers. The circle layer always stays on top, no matter what else is done. The added fill and/or line layers go between the base fill layer and the circle layer when first called, but if I update the base fill layer, the added fill and/or line layers fall beneath it. Having just a before_id parameter doesn't help because it throws an error when the posted ID isn't present (or requires an intricate if-then statement to cover all contingencies).

Base fill, then added circle layer, then added line layer cif_map

Updated base fill from previous map cif_map2

walkerke commented 1 month ago

@CIOData so in your ideal use-case, you would have your line layer always sitting above any added fill layer?

I believe user-added circles are always plotted on top of user-added fills and lines by default in the JS libraries - this is a behavior I do like as I think users will typically not want their circles obscured by new fills.

For the line layer, I'll have to think this through. A new move_layer() function could be put in an observe() block that listens for when a new layer is added, then makes sure to move the existing layer (e.g. the lines) above that layer. I do know that when map.moveLayer() is called without a before argument, it should be moved above all other layers.

CIOData commented 1 month ago

Yes, I would have those additional geographic accents (mostly line layers, but one is a fill layer) above the base choropleth map at all times. In leaflet I am able to accomplish this from the initial map call by adding panes, setting the z-indices accordingly, and then having the appropriate layers assigned to the desired panes. I tried to see if slot could do that here, but I couldn't uncover a way to make it work.

kmcd39 commented 1 month ago

Good questions! Regarding your three points: ....

  1. set_layout_property() can't interact with before_id as before_id isn't a layout property. There is a method, moveLayer(), in MapLibre / Mapbox that appears to do what you want; a move_layer() function could perhaps invoke this in a Shiny session.

Checking in on these!

In particular, move_layer feels like it could be a fine solution, but there seems to be no exported function mapgl::move_layer

walkerke commented 1 month ago

move_layer() is now implemented for use in Shiny. Try out this Shiny app, it showcases what you can do:

library(shiny)
library(mapgl)
library(tigris)
library(sf)
library(dplyr)

options(tigris_use_cache = TRUE)

# Load data outside of server function
rds <- roads("TX", "Tarrant")
tr <- tracts("TX", "Tarrant", cb = TRUE)

# Create random points within the bounds of Tarrant County
set.seed(123)
pois <- st_sample(st_as_sfc(st_bbox(tr)), 100) %>%
  st_sf() %>%
  mutate(id = row_number())

layer_ids <- c("Census tracts", "Local roads", "Points of Interest")

ui <- fluidPage(
  titlePanel("Move Layers Example with Before ID"),
  sidebarLayout(
    sidebarPanel(
      selectInput("layer_to_move", "Layer to Move:", choices = layer_ids),
      selectInput("before_layer", "Move to:", choices = c("Top" = "Top", 
                                                         "Behind Census tracts" = "Census tracts",
                                                         "Behind Local roads" = "Local roads",
                                                         "Behind Points of Interest" = "Points of Interest")),
      actionButton("move_layer", "Move Layer")
    ),
    mainPanel(
      maplibreOutput("map")
    )
  )
)

server <- function(input, output, session) {
  output$map <- renderMaplibre({
    maplibre() |>
      fit_bounds(rds) |>
      add_fill_layer(
        id = "Census tracts",
        source = tr,
        fill_color = "purple",
        fill_opacity = 0.6
      ) |>
      add_line_layer(
        id = "Local roads",
        source = rds,
        line_color = "pink"
      ) |>
      add_circle_layer(
        id = "Points of Interest",
        source = pois,
        circle_color = "yellow",
        circle_radius = 5,
        circle_stroke_width = 1,
        circle_stroke_color = "black"
      ) |>
      add_layers_control(collapsible = TRUE)
  })

  observeEvent(input$move_layer, {
    layer_id <- input$layer_to_move
    before_id <- if(input$before_layer == "Top") NULL else input$before_layer

    maplibre_proxy("map") |>
      move_layer(layer_id = layer_id, before_id = before_id)
  })
}

shinyApp(ui, server)
walkerke commented 1 month ago

@CIOData this app does something similar to what you've requested. When a given fill layer is updated (by clearing one layer and adding a new one), move_layer() is called twice to "move up" the line and circle layers. There doesn't appear to be a direct equivalent of map panes in Mapbox/MapLibre but this should give you more control over layer ordering.

library(shiny)
library(mapgl)
library(tigris)
library(sf)
library(dplyr)

options(tigris_use_cache = TRUE)

# Load data outside of server function
rds <- roads("TX", "Tarrant")
block_groups <- block_groups("TX", "Tarrant", cb = TRUE)
tracts <- tracts("TX", "Tarrant", cb = TRUE)
counties <- counties("TX", cb = TRUE) %>% filter(NAME == "Tarrant")

# Create random points within the bounds of Tarrant County
set.seed(123)
pois <- st_sample(st_as_sfc(st_bbox(tracts)), 100) %>%
  st_sf() %>%
  mutate(id = row_number())

ui <- fluidPage(
  titlePanel("Census Geography Selector with Layer Ordering"),
  sidebarLayout(
    sidebarPanel(
      selectInput("geography", "Select Census Geography:", 
                  choices = c("Block Groups", "Tracts", "County"))
    ),
    mainPanel(
      maplibreOutput("map")
    )
  )
)

server <- function(input, output, session) {
  output$map <- renderMaplibre({
    maplibre() %>%
      fit_bounds(rds) %>%
      add_line_layer(
        id = "roads",
        source = rds,
        line_color = "red",
        line_width = 1
      ) %>%
      add_circle_layer(
        id = "pois",
        source = pois,
        circle_color = "yellow",
        circle_radius = 5,
        circle_stroke_width = 1,
        circle_stroke_color = "black"
      )
  })

  observeEvent(input$geography, {
    geo_data <- switch(input$geography,
                       "Block Groups" = block_groups,
                       "Tracts" = tracts,
                       "County" = counties)

    maplibre_proxy("map") %>%
      clear_layer("fill_layer") %>%
      add_fill_layer(
        id = "fill_layer",
        source = geo_data,
        fill_color = "purple",
        fill_outline_color = "black",
        fill_opacity = 0.6
      ) %>%
      move_layer("roads") %>%
      move_layer("pois")
  })
}

shinyApp(ui, server)