Map with Tile Background — Makie.jl

A map visualization that displays data points on top of tile-based backgrounds such as OpenStreetMap, Stamen, or satellite imagery. Unlike vector-based basemaps, tile backgrounds provide rich geographic context including street-level detail, terrain, or satellite imagery that loads dynamically as the user navigates. This visualization is essential for location-based analysis where real-world geographic context enhances data interpretation.

Map with Tile Background rendered with Makie.jl

Renders

Julia source (Makie.jl)

# anyplot.ai
# map-tile-background: Map with Tile Background
# Library: makie 0.22.10 | Julia 1.11.9
# Quality: 88/100 | Created: 2026-05-27

using CairoMakie
using Colors
using Random
using Downloads
using PNGFiles

Random.seed!(42)

# Theme tokens
const THEME       = get(ENV, "ANYPLOT_THEME", "light")
const PAGE_BG     = THEME == "light" ? colorant"#FAF8F1" : colorant"#1A1A17"
const INK         = THEME == "light" ? colorant"#1A1A17" : colorant"#F0EFE8"
const INK_SOFT    = THEME == "light" ? colorant"#4A4A44" : colorant"#B8B7B0"
const ANYPLOT_SEQ = cgrad([colorant"#009E73", colorant"#4467A3"])

# Theme-adaptive tile providers
const TILE_URL   = THEME == "light" ?
    "https://tile.openstreetmap.org/{z}/{x}/{y}.png" :
    "https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png"
const TILE_CREDIT = THEME == "light" ?
    "© OpenStreetMap contributors" :
    "© OpenStreetMap contributors © CARTO"

# Web Mercator helpers (EPSG:3857 slippy-map tile convention)
lat_to_merc(lat)     = log(tan(lat * π / 180) + 1 / cos(lat * π / 180))
merc_to_lat(m)       = atan(sinh(m)) * 180 / π
lon_to_tx(lon, z)    = clamp(floor(Int, (lon + 180) / 360 * (1 << z)), 0, (1 << z) - 1)
lat_to_ty(lat, z)    = clamp(floor(Int, (π - lat_to_merc(lat)) / (2π) * (1 << z)), 0, (1 << z) - 1)
tx_lon_west(tx, z)   = tx / (1 << z) * 360 - 180
tx_lon_east(tx, z)   = (tx + 1) / (1 << z) * 360 - 180
ty_merc_north(ty, z) = π * (1 - 2 * ty / (1 << z))
ty_merc_south(ty, z) = π * (1 - 2 * (ty + 1) / (1 << z))

# City data: major tourist destinations with annual visitor counts (millions)
const city_names = [
    "Paris", "London", "New York", "Tokyo", "Barcelona",
    "Rome", "Dubai", "Istanbul", "Amsterdam", "Singapore",
    "Sydney", "Bangkok", "Mumbai", "Berlin", "Prague",
    "Vienna", "Budapest", "Lisbon", "Copenhagen", "Stockholm",
]
const lons = Float64[
    2.35, -0.12, -74.0, 139.7, 2.17,
    12.5, 55.3, 28.98, 4.9, 103.8,
    151.2, 100.5, 72.88, 13.41, 14.42,
    16.37, 19.04, -9.14, 12.57, 18.07,
]
const lats = Float64[
    48.85, 51.51, 40.71, 35.68, 41.39,
    41.9, 25.2, 41.01, 52.37, 1.35,
    -33.87, 13.75, 19.08, 52.52, 50.08,
    48.21, 47.5, 38.72, 55.68, 59.33,
]
const visitors = Float64[
    34.0, 31.0, 13.6, 12.9, 9.0,
    9.7, 16.7, 14.0, 8.6, 19.1,
    3.8, 22.8, 7.0, 5.3, 4.4,
    6.8, 4.4, 4.1, 2.7, 2.9,
]

# Convert latitudes to Mercator Y for correct Web Mercator projection
y_merc       = lat_to_merc.(lats)
v_min        = minimum(visitors)
v_max        = maximum(visitors)
v_norm       = (visitors .- v_min) ./ (v_max - v_min)
marker_sizes = 10.0 .+ 28.0 .* v_norm

# Padded axis extent (12 % padding in each direction)
lon_pad      = (maximum(lons)   - minimum(lons))   * 0.12
merc_pad     = (maximum(y_merc) - minimum(y_merc)) * 0.12
ax_lon_min   = minimum(lons)   - lon_pad
ax_lon_max   = maximum(lons)   + lon_pad
ax_merc_min  = minimum(y_merc) - merc_pad
ax_merc_max  = maximum(y_merc) + merc_pad
pad_lat_north = merc_to_lat(ax_merc_max)
pad_lat_south = merc_to_lat(ax_merc_min)

# Tile grid parameters
const ZOOM    = 3
const TILE_PX = 256

tx_min = lon_to_tx(ax_lon_min, ZOOM)
tx_max = lon_to_tx(ax_lon_max, ZOOM)
ty_min = lat_to_ty(pad_lat_north, ZOOM)   # north edge → smaller tile y index
ty_max = lat_to_ty(pad_lat_south, ZOOM)   # south edge → larger tile y index

n_tx = tx_max - tx_min + 1
n_ty = ty_max - ty_min + 1

# Stitched raster: [pixel_x, pixel_y] where x = west→east, y = north→south
stitched = fill(RGBA{Float32}(0.87f0, 0.87f0, 0.87f0, 1.0f0), TILE_PX * n_tx, TILE_PX * n_ty)

mktempdir() do tmpdir
    for iy in 0:(n_ty - 1), ix in 0:(n_tx - 1)
        tx  = tx_min + ix
        ty  = ty_min + iy
        url = replace(TILE_URL,
            "{z}" => string(ZOOM), "{x}" => string(tx), "{y}" => string(ty))
        fpath = joinpath(tmpdir, "$(tx)_$(ty).png")
        try
            Downloads.download(url, fpath;
                headers = ["User-Agent" =>
                    "anyplot.ai static visualization; [email protected]"])
            tile_mat = PNGFiles.load(fpath)   # size = (height, width) = [row, col] = [y, x]
            # Transpose to [x, y] layout expected by the stitched array
            tile_t = permutedims(RGBA{Float32}.(tile_mat), (2, 1))
            stitched[ix*TILE_PX+1:(ix+1)*TILE_PX, iy*TILE_PX+1:(iy+1)*TILE_PX] = tile_t
        catch e
            @warn "Tile ($tx, $ty) unavailable" exception = e
        end
    end
end

# Geographic bounds of the full stitched raster
img_lon_west   = tx_lon_west(tx_min, ZOOM)
img_lon_east   = tx_lon_east(tx_max, ZOOM)
img_merc_north = ty_merc_north(ty_min, ZOOM)
img_merc_south = ty_merc_south(ty_max, ZOOM)

# Flip y so index 1 maps to the south (Makie image! convention: y increases upward)
img_makie = reverse(stitched, dims = 2)

# Latitude tick marks in Mercator Y with degree labels
lat_ticks_deg = filter(l -> pad_lat_south < l < pad_lat_north, [-30.0, 0.0, 30.0, 60.0])
lat_tick_merc = lat_to_merc.(lat_ticks_deg)
lat_tick_strs = [l >= 0 ? "$(round(Int, l))°N" : "$(round(Int, -l))°S" for l in lat_ticks_deg]

lon_ticks_deg = filter(l -> ax_lon_min < l < ax_lon_max, [-60.0, 0.0, 60.0, 120.0])
lon_tick_strs = [l >= 0 ? "$(round(Int, l))°E" : "$(round(Int, -l))°W" for l in lon_ticks_deg]

# Figure
fig = Figure(
    size            = (1600, 900),
    fontsize        = 14,
    backgroundcolor = PAGE_BG,
)

ax = Axis(
    fig[1, 1];
    title              = "map-tile-background · julia · makie · anyplot.ai",
    titlesize          = 20,
    titlecolor         = INK,
    xlabel             = "Longitude",
    ylabel             = "Latitude",
    xlabelsize         = 14,
    ylabelsize         = 14,
    xlabelcolor        = INK,
    ylabelcolor        = INK,
    xticklabelsize     = 11,
    yticklabelsize     = 11,
    xticklabelcolor    = INK_SOFT,
    yticklabelcolor    = INK_SOFT,
    xtickcolor         = INK_SOFT,
    ytickcolor         = INK_SOFT,
    backgroundcolor    = PAGE_BG,
    topspinevisible    = false,
    rightspinevisible  = false,
    leftspinecolor     = INK_SOFT,
    bottomspinecolor   = INK_SOFT,
    xgridvisible       = false,
    ygridvisible       = false,
    xticks             = (lon_ticks_deg, lon_tick_strs),
    yticks             = (lat_tick_merc, lat_tick_strs),
    limits             = (ax_lon_min, ax_lon_max, ax_merc_min, ax_merc_max),
)

# Tile background layer
image!(ax, (img_lon_west, img_lon_east), (img_merc_south, img_merc_north), img_makie)

# Data layer: scatter by visitor count (size + color)
scatter!(ax, lons, y_merc;
    color       = v_norm,
    colormap    = ANYPLOT_SEQ,
    markersize  = marker_sizes,
    strokewidth = 1.5,
    strokecolor = PAGE_BG,
)

# Labels for top 8 most-visited cities
top8 = sortperm(visitors, rev = true)[1:8]
for i in top8
    text!(ax, lons[i], y_merc[i];
        text     = city_names[i],
        fontsize = 10,
        color    = INK,
        offset   = (8, 5),
        align    = (:left, :bottom),
    )
end

# Tile provider attribution (bottom-right)
text!(ax, ax_lon_max, ax_merc_min;
    text     = TILE_CREDIT,
    fontsize = 9,
    color    = INK_SOFT,
    align    = (:right, :bottom),
    offset   = (-4, 4),
)

Colorbar(
    fig[1, 2];
    colormap       = ANYPLOT_SEQ,
    limits         = (v_min, v_max),
    label          = "Annual Visitors (millions)",
    labelcolor     = INK,
    tickcolor      = INK_SOFT,
    ticklabelcolor = INK_SOFT,
    width          = 16,
)

save("plot-$(THEME).png", fig; px_per_unit = 2)

Part of Map with Tile Background on anyplot.ai.

Other implementations