Skip to contents

dragmapr is useful in Shiny when the draggable plot is the interface itself. Users can move regions, panels, or labels directly instead of typing numeric offsets.

Embed A Draggable Plot

The simplest Shiny pattern is the native widget. It renders the draggable map in place and sends browser edits back to Shiny as structured inputs.

library(shiny)
library(dragmapr)

ui <- fluidPage(
  dragmaprOutput("map", height = "650px"),
  verbatimTextOutput("state")
)

server <- function(input, output, session) {
  output$map <- renderDragmapr({
    dragmapr_widget(regions, region_col = "region")
  })

  output$state <- renderPrint({
    req(input$map_state)
    dragmapr_widget_state(input$map_state)
  })
}

shinyApp(ui, server)

That is enough for an app where the map itself is the editing surface. The server receives input$map_state after edits, plus more specific event inputs such as input$map_drag_start, input$map_drag_end, and input$map_region_click.

Custom Labels

Apps can provide their own label table instead of using one label per region. This is useful for annotations, review notes, callouts, or workflow-specific metadata. Labels can be ordinary text labels, text-only draggable labels, or annotation boxes created with as_drag_annotations().

labels <- as_drag_annotations(
  data.frame(
    label_id = "east-note",
    region = "East",
    label = "A review note can move separately from the region.",
    x = 210000,
    y = 120000
  ),
  width_px = 190,
  height_px = 80,
  connector = TRUE,
  connector_type = "elbow"
)

output$map <- renderDragmapr({
  dragmapr_widget(
    regions,
    region_col = "region",
    labels = labels,
    display_options = dragmapr_display_options(
      connector_color = "#334155",
      connector_linewidth = 1.3
    )
  )
})

The user-supplied label table is created with as_drag_labels() and can carry extra columns for app-specific behavior. Connector columns such as connector, connector_type, connector_start_x, and connector_mid_x are also preserved, so apps can let users choose straight, elbow, curved, or squiggle leader lines.

Export A Report Image

Some apps need a static image after the user has finished dragging. The helper posts offset state to its Shiny parent window. The Shiny app can then render a preview and expose a PNG download.

current_state <- reactiveVal(dragmapr_state(crs = 3857))

observeEvent(input$map_state, {
  current_state(dragmapr_widget_state(input$map_state))
})

output$preview <- renderPlot({
  render_dragged_map(
    regions,
    region_col = "region",
    state = current_state(),
    title = "Current edited layout"
  )
})

output$png <- downloadHandler(
  filename = function() "dragmapr-layout.png",
  content = function(file) {
    ggplot2::ggsave(
      file,
      plot = render_dragged_map(
        regions,
        region_col = "region",
        state = current_state()
      ),
      width = 10,
      height = 8,
      dpi = 300
    )
  }
)

This pattern is useful for report builders, document workflows, or review apps where the interactive layout is the editing surface and the exported PNG is the deliverable.

The bundled shiny_draggable_export.R app intentionally has many controls because it acts as a smoke test for the package surface:

  • show or hide labels;
  • switch between short labels and info boxes;
  • show text labels with or without circular markers;
  • change info-box width and height;
  • show connector lines;
  • choose straight, elbow, curve, or squiggle connectors;
  • adjust connector thickness, line pattern, and arrow endpoints;
  • show or hide the region legend;
  • filter visible legend keys and labels while preserving offsets;
  • show origin outlines, movement connectors, and browser-only drag trails;
  • download a static PNG reconstructed from the current region and label offset state.

The important design point is that the Shiny app does not hold magic layout state. The parent app receives the same region and label offset tables that a non-Shiny workflow can copy or download from the helper.

Static Only

If offsets have already been saved, an app can skip the draggable helper and only provide static preview/export.

output$static_map <- renderPlot({
  region_offsets <- read_offsets("drag_region_offsets.csv")
  label_offsets <- read_label_state("drag_label_offsets.csv")

  render_dragged_map(
    regions,
    region_col = "region",
    region_offsets = region_offsets,
    label_offsets = label_offsets
  )
})

Spatial Studio

shiny_spatial_studio.R is the most complete Shiny example. It is a small spatial workspace rather than a fixed demo: users upload local polygon data, or reopen a saved project ZIP; pick grouping and label columns; edit label text and colors; undo and redo drag-state changes; drag boundaries; switch between short labels and info boxes; control text size, connector geometry, connector line color, line pattern, smart connectors, arrow endpoints, legend title, visible legend keys, visible labels, origin outlines, movement connectors, drag preview trails, and map background; preview a static render; set static export size and DPI; and download the current artifacts.

if (interactive()) {
  shiny::runApp(system.file("examples", "shiny_spatial_studio.R", package = "dragmapr"))
}

Supported inputs: zipped shapefiles, shapefile sidecar files (.shp + .dbf + .shx), GeoJSON, and GeoPackage files uploaded locally. The app reads geometry with sf::st_read(), repairs it with sf::st_make_valid(), and transforms longitude/latitude data to EPSG:3857 so metre offsets work correctly. Region group names are sorted using a natural (numeric-aware) order, so “1”, “2”, …, “10” are displayed in the right sequence rather than lexicographic order.

The label sidebar consolidates controls so only sliders relevant to the current label type are shown: text size is always visible; marker size (width + height) appears only for rounded-box labels; circle radius only for circle labels; box dimensions only for info boxes.

The geography editing panel is ordinary Shiny state, so Undo can restore a mistaken deletion:

observeEvent(input$remove_selected_geography, {
  key <- selected_geography_key()
  showModal(modalDialog(
    title = "Remove selected geography?",
    sprintf("Remove %s from the working spatial file?", key),
    footer = tagList(
      modalButton("Cancel"),
      actionButton("confirm_remove_selected_geography", "Remove")
    )
  ))
})

observeEvent(input$confirm_remove_selected_geography, {
  push_history(state_snapshot(include_source = TRUE))
  set_source(remove_spatial_features(
    state$source,
    ids = selected_geography_key(),
    key_col = effective_region_col()
  ))
})

Available downloads from the studio sidebar:

  • PNG - static render from render_dragged_map()
  • Region CSV - current region offsets, importable by any later session
  • Label CSV - current label offsets
  • Labels table - current label geometry and edited label text
  • GeoJSON / GPKG - the adjusted sf geometry with offsets applied
  • HTML helper - the standalone D3 drag file you can share or reopen
  • Project ZIP - source geometry, offsets, labels, palette.csv, metadata.json, and recreate-static-map.R; enough to reopen the project in Spatial Studio, reconstruct the layout in a new session, including legend and label selections plus movement context settings, or hand off to a collaborator
  • Static bundle - the project bundle plus ready-to-share PNG and PDF files

Spatial Studio intentionally keeps its adjusted-geometry exports focused on GeoJSON and GeoPackage. If another shape format is needed, download either file, open it in Mapshaper, and export the required format there. The same handoff works for an sf object created elsewhere in R: write it to a supported spatial file, open that file in Mapshaper, and choose the desired export format.

The export panel also includes a reproducible R script and a static bundle. The R script calls render_dragmapr_project() and expects dragmapr-project.zip to be in the same folder unless project_path is edited. The static bundle includes the project files plus ready-to-share PNG and PDF files. The project ZIP is a one-line static rendering input:

render_dragmapr_project(
  "dragmapr-project.zip",
  file = "final-map.png",
  width = 10,
  height = 8,
  dpi = 300
)

render_dragmapr_project() validates the bundle before rendering. It gives file-specific errors for missing metadata, malformed CSVs, unknown region columns, and labels that refer to regions that are not present in source.gpkg. When an offset row is absent, it reports the missing rows and uses zero movement for that region or label so users can still inspect the output.

When launched with ?debug=1, the app includes a State tab that names its central reactive values so they are easy to find when adapting the code:

  • source_sf() - raw sf from upload / demo / project bundle
  • projected_sf() - after prepare_dragmapr_sf()
  • region_col(), label_col() - chosen columns
  • label_table() - styled label data frame passed to drag_map_prototype()
  • region_state(), label_state() - current drag offsets as data frames
  • region_palette() - named colour vector
  • current_plot() - ggplot2 object ready to save

Removing geography

Spatial Studio and Pipeline Studio both treat removal as an editable state change. The app can show a feature table, remove the selected geography from the live widget, and then apply the same removal to the server-side sf object.

output$feature_rows <- renderTable({
  req(input$map_state)
  selected <- dragmapr_widget_state(input$map_state)$selected_feature
  req(selected)

  rows <- spatial_feature_table(source_sf(), key_col = "region")
  rows[rows$.feature_id == selected, , drop = FALSE]
})

observeEvent(input$remove_selected, {
  req(input$selected_region)

  updateDragmapr(
    session,
    "map",
    remove_features = input$selected_region
  )

  source_sf(remove_spatial_features(
    source_sf(),
    ids = input$selected_region,
    key_col = "region"
  ))
})

In an app with Undo, store a snapshot before calling remove_spatial_features(). Restoring the snapshot brings back the source geometry, offsets, selection, and visible widget state together.

Loading veil

The studio shows a loading veil while data is being read and the D3 helper is being built. The veil is dismissed when the helper iframe signals it has finished its first render() call by posting a dragmapr-ready message to the parent page. This avoids the race condition where the browser fires the iframe load event before the parent’s listener has been attached.

Reusable Shiny Helpers

The spatial studio internally uses four package-level helpers that are also available directly for custom apps.

read_dragmapr_sf_upload(upload) wraps a [shiny::fileInput()] result - including multi-file shapefile sidecar uploads and zip archives - into a single sf::st_read() call. Returns NULL when the upload is empty so callers can fall back to demo data.

read_dragmapr_sf_url(url, timeout = 60) downloads a spatial file from a URL into a temporary directory, unpacks zip archives, and returns an sf object. Raises a descriptive error on network or format failures.

prepare_dragmapr_sf(x, target_crs = 3857) repairs invalid geometry, keeps only polygon types, assigns a fallback CRS when none is present, and reprojects geographic data to the target projected CRS so metre offsets are meaningful.

dragmapr_iframe_bridge(...) returns a JavaScript string that installs the postMessage listener and polling loop needed to relay drag state from the helper iframe back to Shiny inputs. Wrap it in tags$head(tags$script(HTML( dragmapr_iframe_bridge()))) in your UI. The function accepts region_input, label_input, slow_poll_ms, fast_poll_ms, allowed_origin, and iframe_selector arguments to customise the input names, timing, origin check, and helper iframe selection.

library(shiny)
library(dragmapr)

ui <- fluidPage(
  tags$head(tags$script(HTML(dragmapr_iframe_bridge()))),
  uiOutput("helper")
)

server <- function(input, output, session) {
  helper_dir <- tempfile("myapp_")
  dir.create(helper_dir)
  shiny::addResourcePath("myapp_static", helper_dir)

  x <- prepare_dragmapr_sf(my_sf)
  drag_map_prototype(x, region_col = "region",
                     file = file.path(helper_dir, "helper.html"))

  output$helper <- renderUI(
    tags$iframe(src = "myapp_static/helper.html",
                style = "width:100%;height:700px;border:none;")
  )
}

shinyApp(ui, server)

Switching Grouping Columns in Spatial Studio

Spatial Studio stores drag positions per region column and propagates them when you change the Group / region column menu. This means you can work at multiple levels of geographic hierarchy without losing your layout.

How inheritance works

Each column maintains its own independent layout cache. Switching columns never displaces regions that you have not personally dragged in that column:

  • Coarser → finer (e.g. HHS region → state name): the finer column resumes its own last saved layout. If you have not visited it before, all fine units start at their natural geographic positions - they are not displaced by the parent column’s drag offsets.

  • Finer → coarser (e.g. state name → HHS region): each parent group is placed at the mean of its member units’ current positions, or (if you choose “Restore parent’s last position”) at the position the parent had when you last worked at that column.

Example: HHS regions and state names

The bundled HHS demo has both an hhs_region column (ten groups) and a NAME column (individual states). A typical workflow:

  1. Set Group column to hhs_region. Drag the ten regions into an exploded layout.
  2. Switch to NAME. The states appear at their natural positions (first visit) - ready for individual fine-tuning without any carry-over from the HHS drag.
  3. Fine-tune individual states as needed.
  4. Switch back to hhs_region. Each region lands at the mean of its states’ current positions, reflecting any individual fine-tuning.

What resets and what is preserved

On column switch Behaviour
Region offsets (coarser→finer) Restored to that column’s last saved positions, or zero if never visited
Region offsets (finer→coarser) Average of children’s positions, or restored to parent’s last position
Label offsets Reset - label IDs are derived from the new column’s region names
Undo / redo stack Reset - new column starts with a clean history
Region palette Preserved
Legend and label filter selections Preserved

Round-trip precision

The only step that involves averaging is finer → coarser. If all child regions had identical offsets, the round-trip is lossless. Mixed individual child moves are summarized into an average for the parent - or you can choose “Restore parent’s last position” to skip averaging and return the parent to exactly where it was.

Changing only the Label column while keeping the region column the same leaves region offsets completely untouched. Only the label IDs change.