R/generate-maps.R

# most of this pilfered and otherwise mildly adapted from code by @mdsumner from
# hypertidy/ceramic

#' Generate maps for 'mapscanner' use
#'
#' Generate a map image for a specified area or bounding box, by downloading
#' tiles from \url{https://www.mapbox.com/}. Map is automatically saved in both
#' `.pdf` and `.png` formats, by default in current working directory, or
#' alternative location when `mapname` includes the full path.
#'
#' @param bbox Either a string specifying the location, or a numeric bounding
#' box as a single vector of (xmin, ymin, xmax, ymax), or a 2-by-2 matrix with
#' columns of (min, max) and rows of (x, y), respectively.
#' @param max_tiles Maximum number of tiles to use to create map
#' @param mapname Name of map to be produced, optionally including full path.
#' Extension will be ignored.
#' @param bw If `FALSE`, print maps in colour, otherwise black-and-white. Note
#' that the default `style = "light"` is monochrome, and that this parameter
#' only has effect for `style` values of `"streets"` or `"outdoors"`.
#' @param style The style of the map to be generated; one of 'light', 'streets',
#' or 'outdoors', rendered in black and white. See
#' \url{https://docs.mapbox.com/api/maps/#styles/} for examples.
#' @param raster_brick Instead of automatically downloading tiles within a given
#' `bbox`, a pre-downloaded `raster::rasterBrick` object may be submitted and
#' used to generate the `.pdf` and `.png` equivalents.
#' @return Invisibly returns a `rasterBrick` object from the \pkg{raster}
#' package containing all data used to generate the map.
#'
#' @examples
#' \dontrun{
#' # code used to generate internal files for a portion of Omaha:
#' bb <- osmdata::getbb ("omaha nebraska")
#' shrink <- 0.3 # shrink that bb to 30% size
#' bb <- t (apply (bb, 1, function (i) {
#'     mean (i) + c (-shrink, shrink) * diff (i) / 2
#' }))
#' ms_generate_map (bb, max_tiles = 16L, mapname = "omaha")
#' }
#'
#' @export
ms_generate_map <- function (bbox,
                             max_tiles = 16L,
                             mapname = NULL,
                             bw = TRUE,
                             style = "light",
                             raster_brick = NULL) {
    if (is.null (mapname)) {
        stop (
            "Please provide a 'mapname' (with optional path) ",
            "for the maps to be generated"
        )
    }

    style <- match.arg (tolower (style), c ("light", "streets", "outdoors"))


    if (is.null (raster_brick)) {
        # nocov start
        # used here just to confirm token exists:
        mapbox_token <- get_mapbox_token ()
        raster_brick <- get_raster_brick (
            bbox = bbox,
            max_tiles = max_tiles,
            style = style,
            bw = bw
        )
        # nocov end
    }

    fname_p <- map_to_pdf (raster_brick, mapname)
    fname_j <- map_to_png (raster_brick, mapname)

    message ("Successfully generated '", fname_p, "' and '", fname_j, "'")

    invisible (raster_brick)
}

# nocov start
get_raster_brick <- function (bbox, max_tiles = 16L, style, bw) {
    bbox <- convert_bbox (bbox)
    bbox_pair <- slippy_bbox (bbox)
    tiles <- get_tiles (bbox_pair, max_tiles = max_tiles, style = style)
    tgrid <- tiles$tiles
    br <- lapply (tiles$files, raster_brick)

    for (i in seq_along (br)) {
        br [[i]] <- raster::setExtent (
            br [[i]],
            mercator_tile_extent (tgrid$tiles$x [i],
                tgrid$tiles$y [i],
                zoom = tgrid$zoom
            )
        )
    }

    out <- fast_merge (br)

    out <- raster::crop (out, tiles$extent, snap = "out")
    if (bw) {
        out_avg <- raster::stackApply (out, c (1, 1, 1), fun = mean)
        out <- raster::brick (out_avg, out_avg, out_avg)
    }

    # raster::projection (out) <- .sph_merc()
    # # "+proj=merc +a=6378137 +b=6378137"
    out@crs@projargs <- .sph_merc () ## no churn through mill

    return (out)
}
# nocov end

map_to_pdf <- function (my_map, file) {
    file <- paste0 (tools::file_path_sans_ext (file), ".pdf")

    ex <- attributes (raster::extent (my_map))
    aspect <- (ex$ymax - ex$ymin) / (ex$xmax - ex$xmin)
    A4L <- 11.7 # A4 paper is 8.3-by-11.7
    # A4H <- 8.3

    # embed extent as file name
    fname <- paste0 (
        "EX", ex$xmin, "+", ex$ymin, "+",
        ex$xmax, "+", ex$ymax
    )

    # the following issue warnings about mode(onefile)
    suppressWarnings ({
        if (aspect < 1) {
            grDevices::pdf (my_map,
                width = A4L, paper = "a4r",
                colormodel = "gray",
                title = fname, file = file
            )
        } else {
            grDevices::pdf (my_map,
                height = A4L, paper = "a4", # nocov
                colormodel = "gray", # nocov
                title = fname, file = file
            ) # nocov
        }
    })
    suppressWarnings (raster::plotRGB (my_map)) ## #33
    grDevices::graphics.off ()

    invisible (file)
}

map_to_png <- function (my_map, file) {
    file <- paste0 (tools::file_path_sans_ext (file), ".png")

    ex <- attributes (raster::extent (my_map))
    bb_comment <- paste0 (
        "EX", ex$xmin, "+", ex$ymin, "+",
        ex$xmax, "+", ex$ymax
    )

    aspect <- (ex$ymax - ex$ymin) / (ex$xmax - ex$xmin)
    w <- h <- 480 * 4
    if (aspect < 1) {
        h <- 480 * 4 * aspect
    } else {
        w <- 480 * 4 / aspect # nocov
    }

    grDevices::png (file = file, width = w, height = h, units = "px")
    suppressWarnings (raster::plotRGB (my_map)) ## #33
    grDevices::graphics.off ()

    # Then read in png, attach comment containing bbox, and re-save
    img <- magick::image_read (file)
    magick::image_write (img, path = file, comment = bb_comment)

    invisible (file)
}

convert_bbox <- function (bbox) {
    if (is.character (bbox)) {
        if (!requireNamespace ("osmdata")) { # nocov
            stop ("Extracting bounding boxes requires ",
                "the 'osmdata' package to be installed.",
                call. = FALSE
            )
        }
        bbox <- osmdata::getbb (bbox) # nocov
    }

    if (!is.matrix (bbox)) {
        if (length (bbox) != 4) {
            stop ("bbox must have four elements")
        }
        bbox <- matrix (bbox, nrow = 2)
    }
    return (bbox)
}

slippy_bbox <- function (bbox) {
    pxy <- matrix (rowMeans (bbox), nrow = 1)
    idx <- c (
        1, 1, 3, 3, 1,
        2, 4, 4, 2, 2
    )
    xy <- matrix (as.vector (bbox) [idx], ncol = 2L)

    afun <- function (aa) {
        stats::approx (seq_along (aa), aa, n = 180L)$y
    }
    srcproj <- .lonlat ()
    # "+proj=longlat +datum=WGS84 +no_defs +ellps=WGS84 +towgs84=0,0,0"
    crs <- .sph_merc ()
    # "+proj=merc +a=6378137 +b=6378137"
    ex <- cbind (afun (xy [, 1L]), afun (xy [, 2L])) %>%
        reproj::reproj (target = crs, source = srcproj)
    ex <- raster::extent (ex [, 1:2])
    buffer <- c (
        raster::xmax (ex) - raster::xmin (ex),
        raster::ymax (ex) - raster::ymin (ex)
    ) / 2

    loc <- slippymath::lonlat_to_merc (pxy)
    xp <- buffer [1] ## buffer is meant to be from a central point, so a radius
    yp <- buffer [2]
    ## xmin, ymin
    ## xmax, ymax
    bb_points <- matrix (c (
        loc [1, 1] - xp,
        loc [1, 2] - yp,
        loc [1, 1] + xp,
        loc [1, 2] + yp
    ), 2, 2, byrow = TRUE)

    ## convert bb_points back to lonlat
    bb_points_lonlat <- slippymath::merc_to_lonlat (bb_points)

    tile_bbox <- c (
        xmin = bb_points_lonlat [1, 1],
        ymin = bb_points_lonlat [1, 2],
        xmax = bb_points_lonlat [2, 1],
        ymax = bb_points_lonlat [2, 2]
    )

    list (tile_bbox = tile_bbox, user_points = bb_points)
}

url_to_cache <- function (x) {
    cache <- file.path (tempdir (), ".ceramic")
    if (!fs::dir_exists (cache)) fs::dir_create (cache)
    base_filepath <- file.path (cache, gsub (
        "^//", "",
        gsub (
            "^https\\:", "",
            gsub ("^https\\:", "", x)
        )
    ))
    unlist (lapply (strsplit (base_filepath, "\\?"), "[", 1L))
}

# nocov start
down_loader <- function (x, query_string) {
    purrr::pmap (x$tiles,
        function (x, y, zoom) {
            api_query <- glue::glue (query_string)

            outfile <- url_to_cache (api_query)

            if (!file.exists (outfile) ||
                fs::file_info (outfile)$size < 101) {
                cachedir <- fs::path_dir (outfile)

                if (!fs::dir_exists (cachedir)) {
                    dir.create (cachedir, recursive = TRUE)
                }

                zup <- curl::curl_download ( # nolint
                    url = api_query, outfile
                )
            }
            outfile
        },
        zoom = x$zoom
    )
}

get_tiles <- function (bbox_pair, max_tiles = 16L, style) {
    bb_points <- bbox_pair$user_points

    tile_grid <- slippymath::bbox_to_tile_grid (bbox_pair$tile_bbox,
        max_tiles = max_tiles
    )
    # Maxbox style API migration:
    styles <- c ("mapbox.light", "mapbox.streets", "mapbox.outdoors")
    style <- grep (style, styles)
    style <- c ("light-v10", "streets-v11", "outdoors-v11") [style]

    # format <- "jpg" # new API is strict png only
    # baseurl <- "https://api.mapbox.com/v4" # old API
    baseurl <- "https://api.mapbox.com/styles/v1/mapbox"
    mapbox_token <- get_mapbox_token ()
    query_string <- paste0 (
        sprintf (
            "%s/%s/tiles/{zoom}/{x}/{y}",
            baseurl, style
        ),
        "?access_token=", mapbox_token
    )

    files <- unlist (down_loader (tile_grid, query_string))
    bad <- file.info (files)$size < 35
    if (all (bad)) {
        mess <- paste (files, collapse = "\n")
        stop (sprintf ("no sensible tiles found, check cache?\n%s", mess))
    }
    user_ex <- raster::extent (as.vector (bb_points))
    list (files = files [!bad], tiles = tile_grid, extent = user_ex)
}
# nocov end


is_jpeg <- function (x) {
    if (!file.exists (x [1])) {
        return (FALSE)
    } # nocov
    if (file.info (x [1])$size <= 11L) {
        return (FALSE)
    } # nocov
    rawb <- readBin (x [1], "raw", n = 11L)
    all (rawb [1:2] == as.raw (c (0xff, 0xd8))) &&
        rawToChar (rawb [7:11]) == "JFIF"
}

is_png <- function (x) {
    # "89 50 4e 47 0d 0a 1a 0a"
    if (!file.exists (x [1])) {
        return (FALSE)
    } # nocov
    if (file.info (x [1])$size <= 8L) {
        return (FALSE)
    } # nocov
    rawb <- readBin (x [1], "raw", n = 8L)
    all (rawb == as.raw (c (0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a)))
}

# nocov start
is_pdf <- function (x) {
    # "25 50 44 46 2d
    if (!file.exists (x [1])) {
        return (FALSE)
    }
    if (file.info (x [1])$size <= 8L) {
        return (FALSE)
    }
    rawb <- readBin (x [1], "raw", n = 5L)
    all (rawb == as.raw (c (0x25, 0x50, 0x44, 0x46, 0x2d)))
}
# nocov end

raster_brick <- function (x) {
    out <- NULL
    if (is_jpeg (x)) {
        if (!requireNamespace ("jpeg")) {
            stop ("jpeg conversion requires the 'jpeg' ",
                "package to be installed",
                call. = FALSE
            )
        }
        out <- jpeg::readJPEG (x) # nocov
    } else if (is_png (x)) {
        out <- png::readPNG (x)
    } else {
        stop ("Unrecognised format; must be jpg or png") # nocov
    }

    if (is.null (out)) {
        stop (sprintf ("cannot read %s", x))
    } # nocov
    out <- out * 255
    mode (out) <- "integer"
    ## in case it's greyscale ...
    if (length (dim (out)) == 2L) {
        out <- array (out, c (dim (out), 1L))
    }
    raster::setExtent (
        raster::brick (out),
        raster::extent (0, nrow (out), 0, ncol (out))
    )
}

spherical_mercator <- function (provider = "mapbox") {
    # maxextent is the bounds between [-180, 180] and [-85.0511, 85.0511]
    res <- tibble::tibble (
        provider = provider,
        maxextent = 20037508.342789244,
        A = 6378137.0, B = 6378137.0,
        crs = .sph_merc ()
    ) ## hardcoded, not using the A,B
    # crs = glue::glue("+proj=merc +a={A} +b={A}"))
    res [, res$provider == provider]
}

# nocov start
mercator_tile_extent <- function (tile_x, tile_y, zoom, tile_size = 256) {
    params <- spherical_mercator (provider = "mapbox")
    params <- params [1, ] ## FIXME: param query should provide a unique set
    maxextent <- params$maxextent
    z0_size <- (maxextent * 2)
    xlim <- -maxextent + (tile_x + c (0, 1)) * (z0_size / (2^zoom))
    ylim <- range (maxextent - (tile_y + c (0, 1) - 0) * (z0_size / (2^zoom)))
    stats::setNames (c (xlim, ylim), c ("xmin", "xmax", "ymin", "ymax"))
}

raster_readAll <- function (x) {
    if (!raster::hasValues (x)) {
        x <- raster::readAll (x)
    }
    x
}

fast_merge <- function (x) {
    ## about 3 times faster than reduce(, merge)
    out <- purrr::map (x, raster::extent) %>%
        purrr::reduce (raster::union) %>%
        raster::raster (crs = raster::projection (x [[1]]))
    raster::res (out) <- raster::res (x [[1]])
    cells <- unlist (purrr::map (x, ~ raster::cellsFromExtent (out, .x)))
    vals <- do.call (rbind, purrr::map (x, ~ raster::values (raster_readAll (.x))))
    raster::setValues (raster::brick (out, out, out), vals [order (cells), ])
}
# nocov end
ropensci/mapscanner documentation built on Feb. 8, 2025, 10:33 p.m.