Nothing
# Internal helpers for osrm_gui
# These functions are not exported
#' Check for required GUI dependencies
#' @noRd
gui_check_dependencies <- function() {
required_pkgs <- c("shiny", "mapgl", "osrm", "sf", "DT", "viridisLite")
missing_pkgs <- required_pkgs[
!vapply(required_pkgs, requireNamespace, logical(1), quietly = TRUE)
]
if (length(missing_pkgs) > 0) {
stop(
"The following packages are required for the GUI but are not installed: ",
paste(missing_pkgs, collapse = ", "),
".
",
"Please install them using install.packages(c(",
paste(sprintf("'%s'", missing_pkgs), collapse = ", "),
"))",
call. = FALSE
)
}
}
#' Manage OSRM Server Lifecycle for GUI
#' @noRd
gui_setup_server <- function(input_osrm, port) {
if (!identical(port, "auto")) {
port_val <- suppressWarnings(as.integer(port))
if (is.na(port_val) || port_val <= 0) {
stop("Invalid port specified. Must be 'auto' or a positive integer.", call. = FALSE)
}
}
server_process <- NULL
kill_on_exit <- FALSE
host <- "http://127.0.0.1"
active_port <- port
if (inherits(input_osrm, "process")) {
if (!input_osrm$is_alive()) {
stop("The provided OSRM server process is not running.", call. = FALSE)
}
if (identical(port, "auto")) {
stop(
"When providing a process object, you must also specify the port explicitly.
",
"Example: osrm_gui(my_process, port = 5001)",
call. = FALSE
)
}
active_port <- as.integer(port)
message("Using provided OSRM server process on port ", active_port)
} else if (is.character(input_osrm)) {
message("Starting temporary OSRM server for ", basename(input_osrm), "...")
server_port <- if (identical(port, "auto")) 5001L else as.integer(port)
server_process <- osrm_start(
path = input_osrm,
port = server_port,
quiet = FALSE
)
if (!server_process$is_alive()) {
stop("Failed to start OSRM server. Check logs.", call. = FALSE)
}
active_port <- server_port
kill_on_exit <- TRUE
} else if (identical(port, "auto")) {
servers <- osrm_servers()
alive_servers <- servers[servers$alive, ]
if (nrow(alive_servers) == 0) {
stop(
"No running OSRM servers detected. ",
"Start a server with osrm_start() or specify an explicit port.",
call. = FALSE
)
} else if (nrow(alive_servers) == 1) {
active_port <- alive_servers$port[1]
message("Connected to OSRM server on port ", active_port)
} else {
most_recent <- alive_servers[which.max(alive_servers$started_at), ]
active_port <- most_recent$port
warning(
"Multiple OSRM servers running (ports: ",
paste(alive_servers$port, collapse = ", "),
"). Using most recent (port ",
active_port,
", started at ",
format(most_recent$started_at),
"). ",
"Specify 'port' explicitly to select a different server.",
call. = FALSE
)
}
} else {
active_port <- as.integer(port)
message(
"Attempting to connect to existing OSRM server at ",
host,
":",
active_port
)
}
cleanup_fn <- function() {
if (kill_on_exit && !is.null(server_process) && server_process$is_alive()) {
message("Stopping temporary OSRM server...")
server_process$kill()
}
}
list(
active_port = active_port,
host = host,
server_process = server_process,
cleanup_fn = cleanup_fn
)
}
#' Resolve Map Center and Zoom
#' @noRd
gui_resolve_map_view <- function(center, zoom, input_osrm) {
auto_center <- NULL
auto_zoom <- NULL
if (is.null(center)) {
# 1. Try to extract from osrm_server object metadata directly
if (inherits(input_osrm, "osrm_server")) {
meta <- attr(input_osrm, "osrm_metadata")
if (!is.null(meta$center) && length(meta$center) == 2 && !any(is.na(meta$center))) {
auto_center <- meta$center
}
}
# 2. Try to extract from registry
if (is.null(auto_center)) {
srv_info <- osrm_servers()
alive_srv <- srv_info[srv_info$alive, ]
# Match by port if input is a process or "auto"
if (nrow(alive_srv) > 0) {
selected <- NULL
# If input_osrm is an object with a specific port, match it
if (inherits(input_osrm, "osrm_server")) {
target_port <- attr(input_osrm, "osrm_metadata")$port
if (!is.null(target_port)) {
selected <- alive_srv[alive_srv$port == target_port, ]
}
}
# Otherwise take the most recent
if (is.null(selected) || nrow(selected) == 0) {
selected <- alive_srv[which.max(alive_srv$started_at), ]
}
# Check if the registry actually stored the center
if (!is.null(selected$center_lon) && !is.null(selected$center_lat) &&
!is.na(selected$center_lon[1]) && !is.na(selected$center_lat[1])) {
auto_center <- c(selected$center_lon[1], selected$center_lat[1])
}
}
}
# 3. Fallback: PBF Parsing
if (is.null(auto_center)) {
pbf_path <- NULL
if (
is.character(input_osrm) &&
grepl("\\.osm\\.pbf$", input_osrm, ignore.case = TRUE)
) {
pbf_path <- input_osrm
} else if (exists("selected") && !is.null(selected) && nrow(selected) > 0) {
if (nzchar(selected$input_osm[1])) pbf_path <- selected$input_osm[1]
}
if (!is.null(pbf_path) && file.exists(pbf_path)) {
pbf_info <- .get_pbf_extent(pbf_path)
if (!is.null(pbf_info)) {
auto_center <- pbf_info$center
} else {
message("Could not auto-detect map center from PBF file. Defaulting to world view.")
}
} else {
message("Source .osm.pbf file not found. Could not auto-detect map center. Defaulting to world view.")
}
}
if (!is.null(auto_center)) {
auto_zoom <- if (is.null(zoom)) 9 else zoom
message(
"Auto-centered map on bounding box center: ",
paste(round(auto_center, 4), collapse = ", ")
)
}
}
# Normalize
final_center <- center %||% auto_center
final_zoom <- zoom %||% auto_zoom
if (!is.null(final_center)) {
if (is.list(final_center)) {
final_center <- c(
final_center$lng %||% final_center$lon %||% final_center$x,
final_center$lat %||% final_center$y
)
}
final_center <- as.numeric(final_center)
if (length(final_center) != 2 || any(is.na(final_center))) {
stop(
"'center' must be a numeric vector of length 2 (lng, lat) or a named list.",
call. = FALSE
)
}
}
list(center = final_center, zoom = final_zoom)
}
#' Robustly Parse Isochrone Breaks from Text Input
#' @noRd
gui_parse_breaks <- function(input_str, default = c(5, 10, 15)) {
if (is.null(input_str) || !nzchar(trimws(input_str))) {
return(default)
}
# Split, trim, and convert
parts <- unlist(strsplit(input_str, ","))
nums <- suppressWarnings(as.numeric(trimws(parts)))
# Clean up
nums <- nums[!is.na(nums) & nums > 0]
if (length(nums) == 0) {
return(default)
}
sort(unique(nums))
}
#' UI Resources (CSS/JS)
#' @noRd
gui_ui_resources <- function() {
css <- "
html, body { height: 100%; margin: 0; overflow: hidden; }
.container-fluid { height: 100%; display: flex; flex-direction: column; }
#shiny-notification-panel { top: 70px; right: 10px; left: auto; bottom: auto; }
.sidebar-layout { flex: 1; display: flex; overflow: hidden; min-height: 0; }
.sidebar-panel {
padding: 15px;
background: #f8f9fa;
border-right: 1px solid #dee2e6;
overflow-y: auto;
}
.sidebar-panel h4:first-child { margin-top: 0; }
.sidebar-panel h4 { margin-top: 20px; }
.main-panel {
height: 100%;
display: flex;
flex-direction: column;
padding: 15px;
overflow: hidden;
}
.map-wrapper { position: relative; flex: 3; min-height: 300px; }
#map { height: 100% !important; }
.table-wrapper {
flex: 2;
overflow-y: auto;
margin-top: 10px;
border-top: 1px solid #eee;
padding-top: 10px;
}
.route-stats-overlay {
position: absolute;
top: 10px;
left: 10px;
z-index: 1000;
background: rgba(245, 245, 245, 0.9);
padding: 8px 12px;
border-radius: 4px;
box-shadow: 0 2px 4px rgba(0,0,0,0.15);
pointer-events: none;
display: flex;
gap: 15px;
font-size: 14px;
border: 1px solid #ccc;
}
.stat-val { font-weight: bold; }
.trip-marker-label {
background-color: #984ea3;
color: white;
border: 2px solid white;
border-radius: 50%;
width: 24px;
height: 24px;
display: flex;
justify-content: center;
align-items: center;
font-weight: bold;
font-size: 12px;
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
cursor: pointer;
}
.exec-time-overlay {
position: absolute;
top: 10px;
right: 50px; /* Offset to avoid overlap with standard zoom/fullscreen controls */
z-index: 1000;
background: rgba(255, 255, 255, 0.8);
padding: 2px 6px;
border-radius: 4px;
font-size: 11px;
color: #777;
pointer-events: none;
border: 1px solid rgba(0,0,0,0.1);
}
.segments-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 5px;
}
/* Mobile/Responsive Styles */
.hamburger-btn { display: none; margin-right: 10px; font-size: 20px; cursor: pointer; }
/* Remove slider fill for discrete look */
.irs-bar, .irs-bar-edge {
background: none !important;
border: none !important;
}
.irs-single {
background: #555 !important;
}
@media (max-width: 768px) {
.sidebar-panel {
display: none;
width: 250px !important;
flex-shrink: 0;
}
.sidebar-panel.show-sidebar {
display: block;
}
.main-panel {
width: 100% !important;
flex: 1;
min-width: 0;
}
.hamburger-btn {
display: inline-block;
}
/* Hide the overlay since we are pushing the map instead */
.sidebar-overlay {
display: none !important;
}
.route-stats-overlay {
flex-direction: column;
gap: 2px;
}
}
"
js <- "
$(document).on('click', '#hamburger_btn', function() {
$('.sidebar-panel').toggleClass('show-sidebar');
});
function initializeMapListeners(mapId) {
const mapElement = document.getElementById(mapId);
if (!mapElement) return;
const observer = new MutationObserver((mutations, obs) => {
const map = mapElement.map;
if (map) {
// Disable default context menu to allow right-click
map.getCanvas().addEventListener('contextmenu', (e) => e.preventDefault());
map.on('contextmenu', (e) => {
Shiny.setInputValue('js_right_click', {
lng: e.lngLat.lng,
lat: e.lngLat.lat,
nonce: Math.random()
});
});
let startMarker = null;
let endMarker = null;
let isoStartMarker = null;
const tripMarkers = {};
Shiny.addCustomMessageHandler('updateMarker', function(message) {
const lngLat = [message.lng, message.lat];
const markerId = message.id;
const createDragEndCallback = (id) => {
return (marker) => {
const coords = marker.getLngLat();
Shiny.setInputValue('marker_dragged', {
id: id,
lng: coords.lng,
lat: coords.lat,
nonce: Math.random()
});
};
};
const createDragCallback = (id) => {
return (marker) => {
const coords = marker.getLngLat();
Shiny.setInputValue('marker_moving', {
id: id,
lng: coords.lng,
lat: coords.lat,
nonce: Math.random()
});
};
};
if (markerId === 'start') {
if (!startMarker) {
startMarker = new maplibregl.Marker({ draggable: true, color: '#009E73' })
.setLngLat(lngLat)
.addTo(map);
startMarker.on('dragend', () => createDragEndCallback('start')(startMarker));
startMarker.on('drag', () => createDragCallback('start')(startMarker));
} else {
startMarker.setLngLat(lngLat);
}
} else if (markerId === 'end') {
if (!endMarker) {
endMarker = new maplibregl.Marker({ draggable: true, color: '#D55E00' })
.setLngLat(lngLat)
.addTo(map);
endMarker.on('dragend', () => createDragEndCallback('end')(endMarker));
endMarker.on('drag', () => createDragCallback('end')(endMarker));
} else {
endMarker.setLngLat(lngLat);
}
} else if (markerId === 'iso_start') {
if (!isoStartMarker) {
isoStartMarker = new maplibregl.Marker({ draggable: true, color: '#CC79A7' })
.setLngLat(lngLat)
.addTo(map);
isoStartMarker.on('dragend', () => createDragEndCallback('iso_start')(isoStartMarker));
isoStartMarker.on('drag', () => createDragCallback('iso_start')(isoStartMarker));
} else {
isoStartMarker.setLngLat(lngLat);
}
}
});
Shiny.addCustomMessageHandler('updateTripMarker', function(message) {
const id = message.id;
if (message.action === 'add') {
const lngLat = [message.lng, message.lat];
const el = document.createElement('div');
el.className = 'trip-marker-label';
el.innerText = '?'; // Default until optimized
const marker = new maplibregl.Marker({ draggable: true, element: el })
.setLngLat(lngLat)
.addTo(map);
marker.on('dragend', () => {
const coords = marker.getLngLat();
Shiny.setInputValue('move_trip_point', {
id: id,
lng: coords.lng,
lat: coords.lat,
nonce: Math.random()
});
});
marker.on('drag', () => {
const coords = marker.getLngLat();
Shiny.setInputValue('marker_moving', {
id: id,
lng: coords.lng,
lat: coords.lat,
nonce: Math.random()
});
});
// Add click listener to element for removal
el.addEventListener('click', (e) => {
e.stopPropagation(); // Prevent map click
Shiny.setInputValue('remove_trip_point', {id: id, nonce: Math.random()});
});
tripMarkers[id] = marker;
} else if (message.action === 'remove') {
if (tripMarkers[id]) {
tripMarkers[id].remove();
delete tripMarkers[id];
}
} else if (message.action === 'clear') {
for (const id in tripMarkers) {
tripMarkers[id].remove();
}
// Clear object
for (const key in tripMarkers) delete tripMarkers[key];
}
});
Shiny.addCustomMessageHandler('updateTripLabels', function(message) {
// message is an array of {id: ..., label: ...}
message.forEach(item => {
const marker = tripMarkers[item.id];
if (marker) {
const el = marker.getElement();
if (el) el.innerText = item.label;
}
});
});
Shiny.addCustomMessageHandler('clearAllMarkers', function(message) {
if(startMarker) {
startMarker.remove();
startMarker = null;
}
if(endMarker) {
endMarker.remove();
endMarker = null;
}
if(isoStartMarker) {
isoStartMarker.remove();
isoStartMarker = null;
}
// Also clear trip markers
for (const id in tripMarkers) {
tripMarkers[id].remove();
}
for (const key in tripMarkers) delete tripMarkers[key];
});
obs.disconnect();
}
});
observer.observe(mapElement, { childList: true, subtree: true });
}
$(document).on('shiny:connected', () => {
initializeMapListeners('map');
// Customize the Isochrone slider labels to show real values
// We wait briefly to ensure the slider is initialized
setTimeout(function() {
[\"#iso_res\", \"#iso_live_res\"].forEach(function(id) {
const $el = $(id);
const slider = $el.data(\"ionRangeSlider\");
if (slider) {
const valMap = {
1: '100', 2: '200', 3: '500', 4: '1k',
5: '2k', 6: '5k', 7: '10k', 8: '20k', 9: '50k'
};
slider.update({
prettify: function(n) {
return valMap[Math.round(n)] || n;
}
});
}
});
}, 500);
});
"
list(css = css, js = js)
}
#' Construct Main UI
#' @noRd
gui_ui_layout <- function() {
res <- gui_ui_resources()
shiny::fluidPage(
# Header
shiny::div(
style = "display: flex; justify-content: space-between; align-items: center; padding: 10px 0; flex-wrap: wrap; gap: 10px;",
shiny::div(
style = "display: flex; align-items: center;",
shiny::HTML(
'<div id="hamburger_btn" class="hamburger-btn">☰</div>'
),
shiny::h3(
shiny::HTML("<b>osrm.backend</b> GUI"),
style = "margin: 0;"
)
),
shiny::div(
style = "display: flex; gap: 10px; align-items: center;",
shiny::uiOutput("mode_button_ui", inline = TRUE),
shiny::uiOutput("autozoom_button_ui", inline = TRUE),
shiny::uiOutput("tracking_button_ui", inline = TRUE),
shiny::actionButton(
"quit_app",
"Quit",
style = "background-color: #d9534f; color: white; border-width: 0px;"
)
)
),
shiny::tags$head(
shiny::tags$style(shiny::HTML(res$css)),
shiny::tags$script(shiny::HTML(res$js))
),
shiny::div(
class = "sidebar-overlay"
),
shiny::div(
class = "sidebar-layout",
shiny::div(
class = "sidebar-panel",
style = "width: 25%;",
shiny::h4("OSRM Controls"),
# Hidden but functional for R/JS conditions
shiny::div(
style = "display: none;",
shiny::selectInput(
"mode",
"Mode",
choices = c("Route" = "route", "Isochrone" = "iso", "Trip" = "trip")
)
),
shiny::conditionalPanel(
condition = "input.mode == 'iso'",
shiny::textInput(
"iso_breaks",
"Time Breaks (min, comma sep):",
value = "5, 10, 15"
),
shiny::sliderInput(
"iso_res",
"Resolution (Detail):",
min = 1,
max = 9,
value = 3,
step = 1,
ticks = TRUE
),
shiny::sliderInput(
"iso_live_res",
"Live Resolution (Drag):",
min = 1,
max = 9,
value = 2,
step = 1,
ticks = TRUE
)
),
shiny::hr(),
shiny::h4("Locations"),
shiny::helpText(
shiny::conditionalPanel(
"input.mode != 'trip'",
"Left-click map: Start point",
shiny::tags$br(),
"Right-click map: End point (Route mode)",
shiny::tags$br(),
"Drag markers to adjust."
),
shiny::conditionalPanel(
"input.mode == 'trip'",
"Left-click map: Add waypoints",
shiny::tags$br(),
"Click marker: Remove waypoint",
shiny::tags$br(),
"Drag markers to adjust."
)
),
shiny::actionButton(
"reset",
"Reset / Clear",
style = "width: 100%; margin-bottom: 10px;"
),
shiny::textInput(
"start_coords_input",
"Start (Lat, Lon)",
placeholder = "-30.03, -51.22"
),
shiny::textInput(
"end_coords_input",
"End (Lat, Lon)",
placeholder = "-30.05, -51.18"
)
),
shiny::div(
class = "main-panel",
style = "width: 75%;",
shiny::div(
class = "map-wrapper",
shiny::conditionalPanel(
condition = "input.mode == 'route' || input.mode == 'trip' || input.mode == 'iso'",
shiny::uiOutput("route_stats")
),
shiny::uiOutput("map_edit_controls"),
shiny::uiOutput("exec_time_overlay"),
mapgl::maplibreOutput("map"),
shiny::div(
style = "position: absolute; bottom: 40px; right: 10px; z-index: 1000;",
shiny::actionButton(
"copy_code",
"Copy R Code",
icon = shiny::icon("code"),
style = "background: white; border: 1px solid #ccc; font-size: 12px; padding: 4px 8px;"
)
)
),
shiny::div(
class = "table-wrapper",
shiny::conditionalPanel(
condition = "input.mode == 'route'",
shiny::div(
class = "segments-header",
shiny::h4("Route Segments", style = "margin: 0;"),
shiny::actionButton(
"clear_selection",
"Deselect All",
class = "btn-xs btn-default",
style = "font-size: 11px; padding: 2px 8px;"
)
),
DT::dataTableOutput("itinerary_table")
),
shiny::conditionalPanel(
condition = "input.mode == 'trip'",
shiny::h4("Trip Sequence"),
DT::dataTableOutput("trip_table")
)
)
)
)
)
}
#' Fetch Detailed Route Steps
#' @noRd
api_fetch_route_detailed <- function(
src,
dst,
overview = "false",
debug = FALSE
) {
server_url <- getOption("osrm.server")
profile <- getOption("osrm.profile")
# Use geometries=geojson to get coordinate arrays directly
url <- sprintf(
"%sroute/v1/%s/%f,%f;%f,%f?steps=true&overview=%s&geometries=geojson",
server_url,
profile,
src$lng,
src$lat,
dst$lng,
dst$lat,
overview
)
if (debug) {
message("DEBUG [Detailed Route]: Fetching URL: ", url)
}
tryCatch(
{
req <- httr2::request(url)
resp <- httr2::req_perform(req)
httr2::resp_body_json(resp)
},
error = function(e) NULL
)
}
#' Fetch Trip via Direct HTTP Request
#' @description Direct HTTP request to OSRM trip endpoint, bypassing osrm::osrmTrip.
#' This is a workaround for issues where osrmTrip fails inside Shiny reactive contexts.
#' @param pts_df data.frame with lon and lat columns
#' @param debug Logical, print debug messages
#' @return List with trip (sf LINESTRING) and summary, or NULL on error
#' @noRd
api_fetch_trip <- function(pts_df, debug = FALSE) {
server_url <- getOption("osrm.server")
profile <- getOption("osrm.profile")
# Build coordinate string: "lon1,lat1;lon2,lat2;..."
coords_str <- paste(
sprintf("%.6f,%.6f", pts_df$lon, pts_df$lat),
collapse = ";"
)
url <- sprintf(
"%strip/v1/%s/%s?steps=false&geometries=geojson&overview=full&generate_hints=false",
server_url,
profile,
coords_str
)
if (debug) {
message("DEBUG [Trip API]: Fetching URL: ", url)
}
tryCatch(
{
req <- httr2::request(url)
resp <- httr2::req_perform(req)
res <- httr2::resp_body_json(resp)
if (is.null(res$trips) || length(res$trips) == 0) {
if (debug) {
message("DEBUG [Trip API]: No trips in response")
}
return(NULL)
}
# Parse the first trip
trip_data <- res$trips[[1]]
# Extract waypoint order (which input index maps to which trip position)
# res$waypoints is in the order of input coordinates.
# res$waypoints[[i]]$waypoint_index is the position in the optimized trip.
waypoint_order <- vapply(res$waypoints, function(w) as.integer(w$waypoint_index) + 1L, integer(1))
# Extract overall geometry (full route as one line)
coords <- trip_data$geometry$coordinates
if (length(coords) < 2) {
if (debug) {
message("DEBUG [Trip API]: Not enough coordinates for LINESTRING")
}
return(NULL)
}
coord_matrix <- do.call(
rbind,
lapply(coords, as.numeric)
)
# Validate matrix dimensions and contents to prevent segfaults in st_linestring
if (
is.null(coord_matrix) ||
!is.matrix(coord_matrix) ||
nrow(coord_matrix) < 2 ||
ncol(coord_matrix) != 2 ||
any(is.na(coord_matrix))
) {
if (debug) {
message("DEBUG [Trip API]: Invalid coordinate matrix")
}
return(NULL)
}
# Build SINGLE-ROW sf LINESTRING for mapping (most stable)
trip_line <- sf::st_linestring(coord_matrix)
trip_sfc <- sf::st_sfc(trip_line, crs = 4326)
trip_sf_map <- sf::st_sf(geometry = trip_sfc)
# 2. Extract Legs for Table Display (Pure data, no geometry)
legs <- trip_data$legs
n_legs <- length(legs)
if (n_legs > 0) {
legs_df <- data.frame(
Start = seq_len(n_legs),
End = c(seq(2, n_legs), 1), # Circular: last leg returns to start
`Duration (min)` = round(
unname(vapply(
legs,
function(l) as.numeric(l$duration) / 60,
numeric(1)
)),
2
),
`Distance (km)` = round(
unname(vapply(
legs,
function(l) as.numeric(l$distance) / 1000,
numeric(1)
)),
3
),
stringsAsFactors = FALSE,
check.names = FALSE
)
} else {
# Fallback: single entry
legs_df <- data.frame(
Start = 1,
End = 2,
`Duration (min)` = round(as.numeric(trip_data$duration) / 60, 2),
`Distance (km)` = round(as.numeric(trip_data$distance) / 1000, 3),
stringsAsFactors = FALSE,
check.names = FALSE
)
}
summary <- list(
duration = as.numeric(trip_data$duration) / 60,
distance = as.numeric(trip_data$distance) / 1000
)
list(trip = trip_sf_map, legs = legs_df, summary = summary, waypoint_order = waypoint_order)
},
error = function(e) {
if (debug) {
message("DEBUG [Trip API]: Error: ", e$message)
}
NULL
}
)
}
Any scripts or data that you put into this service are public.
Add the following code to your website.
For more information on customizing the embed code, read Embedding Snippets.