Spilhaus, as it flows downstream from PROJ: the R view

Also in this series: index | the Python notebook | the browser version | the other R runner | source | the story so far

The companion to the Python notebook. Same sources, same sections, same landmarks, different runtime. The point is the same too: +proj=spilhaus arrived in PROJ 9.6.0 (March 2025), and whether this R session can use it depends on which PROJ each package was linked against.

R’s version of the problem is not the Python one. On Linux, sf, terra, gdalraster and vapour all link one system libproj, so they normally agree; the question is what the system has, and on a stock Ubuntu that is often too old. On macOS and Windows, CRAN’s binaries bundle their own GDAL and PROJ, chosen by CRAN’s build recipes rather than by package authors. So the row that changes between machines is the operating system, not the wheel.

The stack here is deliberately low-level: gdalraster for I/O and the warp, wk for vertex-level geometry, ximage to draw a matrix. sf and terra appear only in the provenance table and in an appendix, as the same operation in one line, so you can see which PROJ each one silently used.

SPILHAUS <- "+proj=spilhaus"
SPILHAUS_EXPLICIT <- paste(
  "+proj=spilhaus +lon_0=66.94970198 +lat_0=-49.56371678",
  "+azi=40.17823482 +rot=45 +k_0=1 +x_0=0 +y_0=0 +R=6378137"
)
SPILHAUS_ALT <- "+proj=spilhaus +lon_0=-30 +lat_0=-20 +azi=20 +rot=45"

GEBCO_URL <- "/vsicurl/https://data.source.coop/alexgleith/gebco-2024/GEBCO_2024.tif"
RASTER_URL <- Sys.getenv("SPILHAUS_RASTER", GEBCO_URL)

NE_OCEAN_URL <- paste0(
  "/vsicurl/https://raw.githubusercontent.com/nvkelso/natural-earth-vector/",
  "master/110m_physical/ne_110m_ocean.shp"
)
OCEAN_URL <- Sys.getenv("SPILHAUS_OCEAN", NE_OCEAN_URL)

NPIX <- as.integer(Sys.getenv("SPILHAUS_NPIX", "1600"))

Sys.setenv(GDAL_DISABLE_READDIR_ON_OPEN = "EMPTY_DIR",
           GDAL_HTTP_MULTIRANGE = "YES",
           GDAL_HTTP_MERGE_CONSECUTIVE_RANGES = "YES")

1. Who has which PROJ?

One row per package. spilhaus is the result of asking that package to make a CRS from +proj=spilhaus.

HOBART <- cbind(147.33, -42.88)

## One forward-transform closure per package. The probe is "transform Hobart and get
## finite numbers", because several packages will happily build a CRS object from an
## unknown projection and only fail (or return NA) when asked to use it.
engine_forward <- list(
  gdalraster = function(lonlat, ps) gdalraster::transform_xy(lonlat, "OGC:CRS84", ps),
  PROJ       = function(lonlat, ps) PROJ::proj_trans(lonlat, ps, source = "OGC:CRS84"),
  sf         = function(lonlat, ps) sf::sf_project("OGC:CRS84", ps, lonlat, keep = TRUE),
  terra      = function(lonlat, ps) terra::crds(terra::project(terra::vect(lonlat, crs = "OGC:CRS84"), ps)),
  vapour     = function(lonlat, ps) vapour::vapour_project(lonlat, ps, "OGC:CRS84")
)
engine_inverse <- list(
  gdalraster = function(xy, ps) gdalraster::transform_xy(xy, ps, "OGC:CRS84"),
  PROJ       = function(xy, ps) PROJ::proj_trans(xy, "OGC:CRS84", source = ps),
  sf         = function(xy, ps) sf::sf_project(ps, "OGC:CRS84", xy, keep = TRUE),
  terra      = function(xy, ps) terra::crds(terra::project(terra::vect(xy, crs = ps), "OGC:CRS84")),
  vapour     = function(xy, ps) vapour::vapour_project(xy, "OGC:CRS84", ps)
)

probe <- function(pkg, ps = SPILHAUS) {
  out <- tryCatch({
    xy <- suppressWarnings(matrix(as.numeric(engine_forward[[pkg]](HOBART, ps)), ncol = 2))
    if (nrow(xy) != 1 || !all(is.finite(xy))) stop("transform returned NA/Inf") else "ok"
  }, error = function(e) paste("FAIL:", conditionMessage(e)))
  substr(out, 1, 80)
}
has <- function(pkg) requireNamespace(pkg, quietly = TRUE)
## "installed" is not "loadable": a binary built against another GDAL fails at load time
load_error <- function(pkg) tryCatch({ loadNamespace(pkg); NULL }, error = function(e) conditionMessage(e))
row_for_broken <- function(pkg, msg) data.frame(package = pkg, version = as.character(packageVersion(pkg)),
                                                gdal = "cannot load", proj = "cannot load",
                                                spilhaus = paste("FAIL:", substr(msg, 1, 60)))

rows <- list()

if (has("gdalraster") && !is.null(m <- load_error("gdalraster"))) {
  rows$gdalraster <- row_for_broken("gdalraster", m)
} else if (has("gdalraster")) {
  pv <- gdalraster::proj_version()
  rows$gdalraster <- data.frame(
    package = "gdalraster", version = as.character(packageVersion("gdalraster")),
    gdal = gdalraster::gdal_version()[4], proj = pv$name,
    spilhaus = probe("gdalraster"))
} else rows$gdalraster <- data.frame(package = "gdalraster", version = NA, gdal = NA, proj = NA, spilhaus = "not installed")

if (has("PROJ") && !is.null(m <- load_error("PROJ"))) {
  rows$PROJ <- row_for_broken("PROJ", m)
} else if (has("PROJ")) {
  rows$PROJ <- data.frame(
    package = "PROJ", version = as.character(packageVersion("PROJ")),
    gdal = "-", proj = PROJ::proj_version(),
    spilhaus = probe("PROJ"))
} else rows$PROJ <- data.frame(package = "PROJ", version = NA, gdal = "-", proj = NA, spilhaus = "not installed")

if (has("sf") && !is.null(m <- load_error("sf"))) {
  rows$sf <- row_for_broken("sf", m)
} else if (has("sf")) {
  v <- sf::sf_extSoftVersion()
  rows$sf <- data.frame(
    package = "sf", version = as.character(packageVersion("sf")),
    gdal = unname(v["GDAL"]), proj = unname(v["PROJ"]),
    spilhaus = probe("sf"))
} else rows$sf <- data.frame(package = "sf", version = NA, gdal = NA, proj = NA, spilhaus = "not installed")

if (has("terra") && !is.null(m <- load_error("terra"))) {
  rows$terra <- row_for_broken("terra", m)
} else if (has("terra")) {
  rows$terra <- data.frame(
    package = "terra", version = as.character(packageVersion("terra")),
    gdal = terra::gdal(lib = "gdal"), proj = terra::gdal(lib = "proj"),
    spilhaus = probe("terra"))
} else rows$terra <- data.frame(package = "terra", version = NA, gdal = NA, proj = NA, spilhaus = "not installed")

if (has("vapour") && !is.null(m <- load_error("vapour"))) {
  rows$vapour <- row_for_broken("vapour", m)
} else if (has("vapour")) {
  rows$vapour <- data.frame(
    package = "vapour", version = as.character(packageVersion("vapour")),
    gdal = vapour::vapour_gdal_version(), proj = vapour::vapour_proj_version(),
    spilhaus = probe("vapour"))
}

if (has("wk")) rows$wk <- data.frame(package = "wk", version = as.character(packageVersion("wk")),
                                     gdal = "-", proj = "- (no PROJ dependency)", spilhaus = "n/a")
if (has("ximage")) rows$ximage <- data.frame(package = "ximage", version = as.character(packageVersion("ximage")),
                                             gdal = "-", proj = "-", spilhaus = "n/a")

provenance <- do.call(rbind, rows); rownames(provenance) <- NULL
knitr::kable(provenance, caption = sprintf("R %s on %s", getRversion(), R.version$platform))
R 4.5.3 on x86_64-conda-linux-gnu
package version gdal proj spilhaus
gdalraster 2.6.1 3.13.3 9.9.0 ok
PROJ 0.7.0 - 9.9.0 ok
sf 1.1.3 3.13.3 9.9.0 ok
terra 1.9.50 3.13.3 9.9.0 ok
wk 0.9.5 - - (no PROJ dependency) n/a
cli_version <- function(cmd, args = "--version") {
  exe <- Sys.which(cmd)
  if (!nzchar(exe)) return("not found")
  out <- tryCatch(system2(exe, args, stdout = TRUE, stderr = TRUE), error = function(e) conditionMessage(e))
  out[1]
}
cli <- c(gdalinfo = cli_version("gdalinfo"),
         gdal = cli_version("gdal"),
         proj = cli_version("proj", character()))  # bare `proj` prints "Rel. x.y.z" to stderr
knitr::kable(data.frame(tool = names(cli), version = unname(cli)), caption = "Command-line tools on PATH")
Command-line tools on PATH
tool version
gdalinfo GDAL 3.13.3 “Iowa City”, released 2026/08/13
gdal GDAL 3.13.3 “Iowa City”, released 2026/08/13
proj Rel. 9.9.0, September 15th, 2026
capable <- provenance$package[provenance$spilhaus == "ok" & provenance$package %in% names(engine_forward)]
RASTER_OK <- has("gdalraster") && is.null(load_error("gdalraster"))
if (length(capable) == 0) {
  cat("No installed package can transform to +proj=spilhaus on this machine.",
      "The table above is the whole result; the document stops here.\n")
  knitr::knit_exit()
}
ENGINE <- capable[1]
cat("Coordinate transforms below use:", ENGINE, "\n")
Coordinate transforms below use: gdalraster 
if (!RASTER_OK) cat("gdalraster is not loadable here, so the raster sections will be skipped.\n")

## forward/inverse constructors for any proj string, from the first capable package
make_forward <- function(proj_string) function(lonlat) engine_forward[[ENGINE]](lonlat, proj_string)
make_inverse <- function(proj_string) function(xy) engine_inverse[[ENGINE]](xy, proj_string)

## wrap: NaN where undefined, never error
safe <- function(f) function(m) {
  m <- matrix(as.numeric(m), ncol = 2)
  out <- tryCatch(f(m), error = function(e) matrix(NA_real_, nrow(m), 2))
  out <- matrix(as.numeric(out), ncol = 2)
  bad <- !is.finite(out[, 1]) | !is.finite(out[, 2]) | abs(out[, 1]) > 1e15 | abs(out[, 2]) > 1e15
  out[bad, ] <- NA_real_
  out
}
## inverse one point at a time so singular corners fail individually
pointwise <- function(f) function(m) {
  m <- matrix(as.numeric(m), ncol = 2)
  t(apply(m, 1, function(p) f(matrix(p, ncol = 2))))
}
forward <- safe(make_forward(SPILHAUS))
inverse <- pointwise(safe(make_inverse(SPILHAUS)))

2. The projection’s domain

PROJ does not publish the square’s half-width, so forward-project a dense lon/lat grid and take the extent; then inverse-project the square’s boundary to trace the cut on the globe.

square_half <- function(fwd, n = 721) {
  g <- as.matrix(expand.grid(lon = seq(-180, 180, length.out = n), lat = seq(-90, 90, length.out = (n + 1) %/% 2)))
  xy <- fwd(g)
  ceiling(max(abs(xy), na.rm = TRUE) / 1000) * 1000
}
HALF <- square_half(forward)

n <- 300; t <- seq(-1, 1, length.out = n); edge <- 0.996 * HALF
boundary <- rbind(cbind(t * edge, -edge), cbind(edge, t * edge), cbind(rev(t) * edge, edge), cbind(-edge, rev(t) * edge))
cut <- inverse(boundary)
corners <- rbind(`corner A (Asia)` = c(115, 30), `corner B (South America)` = c(-65, -30))

cat(sprintf("Half-width %s m; %d of %d boundary samples inverted (misses cluster at the singular corners)\n",
            format(HALF, big.mark = ","), sum(complete.cases(cut)), nrow(cut)))
Half-width 11,803,000 m; 1172 of 1200 boundary samples inverted (misses cluster at the singular corners)
op <- par(mar = c(3, 3, 2, 1))
plot(NA, xlim = c(-180, 180), ylim = c(-90, 90), asp = 1, xlab = "", ylab = "",
     main = sprintf("Where the Spilhaus cut falls on the globe (half-width %.3f Mm)", HALF / 1e6))
grid(); points(cut, pch = 16, cex = 0.4, col = "#b8342c")
points(corners, cex = 1.6, lwd = 1.5); text(corners, rownames(corners), pos = 4, cex = 0.8)

par(op)

3. Raster: bathymetry over /vsicurl

gdalraster::warp() is GDAL’s warper with the same option vocabulary as gdalwarp, so the target extent and size are stated explicitly rather than estimated. The result is written to /vsimem/ and read back as a matrix for ximage; nothing touches disk and only the tiles GDAL needs come over the wire.

warp_square <- function(proj_string, half, npix, url = RASTER_URL, out = "/vsimem/spilhaus.tif") {
  t0 <- Sys.time()
  gdalraster::warp(url, out, t_srs = proj_string,
                   cl_arg = c("-te", -half, -half, half, half, "-ts", npix, npix,
                              "-r", "average", "-dstalpha", "-overwrite", "-q"))
  ds <- new(gdalraster::GDALRaster, out)
  on.exit(ds$close())
  v <- ds$read(band = 1, xoff = 0, yoff = 0, xsize = npix, ysize = npix, out_xsize = npix, out_ysize = npix)
  a <- ds$read(band = ds$getRasterCount(), xoff = 0, yoff = 0, xsize = npix, ysize = npix, out_xsize = npix, out_ysize = npix)
  m <- matrix(v, nrow = npix, ncol = npix, byrow = TRUE)   # row 1 = top of the image
  m[matrix(a, npix, npix, byrow = TRUE) == 0] <- NA
  list(z = m, res_m = 2 * half / npix, seconds = round(as.numeric(difftime(Sys.time(), t0, units = "secs")), 1),
       bands = ds$getRasterCount())
}
w <- warp_square(SPILHAUS, HALF, NPIX)
cat(sprintf("Warped %s to a %d x %d Spilhaus grid (%.1f km/px) in %s s\n",
            basename(RASTER_URL), NPIX, NPIX, w$res_m / 1000, w$seconds))
Warped GEBCO_2024.tif to a 1600 x 1600 Spilhaus grid (14.8 km/px) in 21.1 s
bathy_cols <- grDevices::colorRampPalette(c("#08143f", "#123c7a", "#2a6fb5", "#6aa9d8", "#b9d9ea", "#e3f1f6"))(64)
render_raster <- function(z, half, add = FALSE) {
  ocean <- z; ocean[!is.na(z) & z >= 0] <- NA
  land  <- z; land[is.na(z) | z < 0] <- NA
  ximage::ximage(pmax(ocean, -7000), extent = c(-half, half, -half, half), asp = 1, col = bathy_cols,
                 zlim = c(-7000, 0), axes = FALSE, xlab = "", ylab = "", add = add)
  ximage::ximage(matrix(as.numeric(!is.na(land)), nrow(land)), extent = c(-half, half, -half, half),
                 zlim = c(0, 1), col = c(NA, "#d9d2c5"), add = TRUE)
}
op <- par(mar = c(0, 0, 2, 0), bg = "#d9d2c5")
render_raster(w$z, HALF)
Error in `loadNamespace()`:
! there is no package called 'ximage'
title("Raster only (bathymetry), warped by GDAL via gdalraster", cex.main = 0.9)
Error in `title()`:
! plot.new has not been called yet
par(op)

4. Vector: Natural Earth ocean polygons, as vertices

Read remotely with gdalraster’s GDALVector, geometry returned as WKB, and handed to wk. From there the polygons are just a coordinate table with ring ids: transform the vertices, form segments between consecutive vertices of the same ring, and drop the segments that are absurdly long in projected space (those straddle the cut) or that are artefacts of the lon/lat representation (both ends on the antimeridian or at a pole). Coastlines are drawn as segments, not filled polygons.

lyr <- new(gdalraster::GDALVector, OCEAN_URL)
lyr$returnGeomAs <- "WKB"
feat <- lyr$fetch(-1)
lyr$close()
geom_col <- which(vapply(feat, is.list, logical(1)))[1]  # the WKB column, whatever it is called
geom <- wk::wkb(feat[[geom_col]])
cat(sprintf("Ocean layer: %d feature(s), %s\n", length(geom), wk::wk_crs(geom)))

coast_segments <- function(geom, fwd, half, densify_deg = 0.5) {
  ## densify in lon/lat so the projected curves are smooth near the corners
  ## (wk has no densify; do it on the coordinate table)
  co <- wk::wk_coords(geom)
  key <- paste(co$feature_id, co$part_id, co$ring_id)
  dens <- do.call(rbind, lapply(split(co, key), function(r) {
    if (nrow(r) < 2) return(NULL)
    steps <- pmax(1L, ceiling(sqrt(diff(r$x)^2 + diff(r$y)^2) / densify_deg))
    x <- unlist(mapply(function(a, b, k) seq(a, b, length.out = k + 1)[-(k + 1)], r$x[-nrow(r)], r$x[-1], steps))
    y <- unlist(mapply(function(a, b, k) seq(a, b, length.out = k + 1)[-(k + 1)], r$y[-nrow(r)], r$y[-1], steps))
    data.frame(ring = r$ring_id[1] + 1e6 * r$feature_id[1], x = c(x, r$x[nrow(r)]), y = c(y, r$y[nrow(r)]))
  }))
  xy <- fwd(cbind(dens$x, dens$y))
  same_ring <- dens$ring[-1] == dens$ring[-nrow(dens)]
  step <- sqrt(diff(xy[, 1])^2 + diff(xy[, 2])^2)
  synthetic <- (abs(dens$x[-1]) > 179.99 & abs(dens$x[-nrow(dens)]) > 179.99) |
               (abs(dens$y[-1]) > 89.99 & abs(dens$y[-nrow(dens)]) > 89.99)
  ok <- same_ring & is.finite(step) & step < half / 8 & !synthetic
  i <- which(ok)
  cbind(x0 = xy[i, 1], y0 = xy[i, 2], x1 = xy[i + 1, 1], y1 = xy[i + 1, 2])
}
segs <- coast_segments(geom, forward, HALF)
cat(sprintf("%d coastline segments after projecting with %s and dropping cut-crossing segments\n", nrow(segs), ENGINE))
11992 coastline segments after projecting with gdalraster and dropping cut-crossing segments

5. Points: landmarks, transformed directly

landmarks <- rbind(
  Hobart = c(147.33, -42.88), Tokyo = c(139.69, 35.69), Reykjavik = c(-21.94, 64.15),
  `Cape of Good Hope` = c(18.47, -34.36), `Bering Strait` = c(-169.0, 65.8),
  `Drake Passage` = c(-63.0, -59.0), `Point Nemo` = c(-123.39, -48.88),
  `Challenger Deep` = c(142.20, 11.37), `Puerto Rico Trench` = c(-66.5, 19.7),
  `Mid-Atlantic Ridge (Azores)` = c(-27.0, 38.0), `Macquarie Island` = c(158.94, -54.62),
  `Mawson Station` = c(62.87, -67.60), `Casey Station` = c(110.53, -66.28))
lm_xy <- forward(landmarks)
knitr::kable(data.frame(name = rownames(landmarks), lon = landmarks[, 1], lat = landmarks[, 2],
                        x = round(lm_xy[, 1]), y = round(lm_xy[, 2]), row.names = NULL),
             caption = "Landmarks in Spilhaus metres")
Landmarks in Spilhaus metres
name lon lat x y
Hobart 147.33 -42.88 2583897 -1613781
Tokyo 139.69 35.69 9587907 -52546
Reykjavik -21.94 64.15 -5405098 6621233
Cape of Good Hope 18.47 -34.36 -2153244 421049
Bering Strait -169.00 65.80 -4817477 11742840
Drake Passage -63.00 -59.00 -2017452 -3916755
Point Nemo -123.39 -48.88 1016488 -5091561
Challenger Deep 142.20 11.37 6396508 -83830
Puerto Rico Trench -66.50 19.70 -9511346 3546491
Mid-Atlantic Ridge (Azores) -27.00 38.00 -6037764 4511522
Macquarie Island 158.94 -54.62 2012829 -2209928
Mawson Station 62.87 -67.60 -168347 -1008217
Casey Station 110.53 -66.28 829890 -1311409

6. Composite

composite <- function(z, half, segs, lm_xy, main) {
  op <- par(mar = c(0, 0, 3, 0), bg = "white")
  render_raster(z, half)
  segments(segs[, 1], segs[, 2], segs[, 3], segs[, 4], col = "#3a3a3a", lwd = 0.6)
  points(lm_xy, pch = 21, bg = "#f2b134", col = "black", cex = 1.1)
  text(lm_xy, rownames(landmarks), pos = 4, cex = 0.75, offset = 0.4)
  title(main, cex.main = 1)
  par(op)
}
proj_used <- provenance$proj[provenance$package == ENGINE]
composite(w$z, HALF, segs, lm_xy,
          sprintf("The world ocean in Spilhaus projection\nraster warped by GDAL (gdalraster); coastlines and points transformed by %s (PROJ %s)", ENGINE, proj_used))
Error in `loadNamespace()`:
! there is no package called 'ximage'

7. Naming the thing: proj-string, WKT, and the missing authority code

Three renderings of “the same” projection. The bare proj-string exports to WKT with the method named and no parameters: the numbers live in PROJ’s source. Spell the parameters out and the WKT carries them. ESRI:54099 is the only authority code you will find and it is a different, sqrt(2)-larger square, which PROJ expresses as +k_0=1.41421356237. There is no EPSG code.

to_wkt <- function(x) {
  f <- if (RASTER_OK) function(x) gdalraster::srs_to_wkt(x, pretty = TRUE)
       else if (has("sf")) function(x) sf::st_as_text(sf::st_crs(x), pretty = TRUE)
       else if (has("terra")) function(x) terra::crs(x)
       else function(x) stop("no package here can render WKT")
  tryCatch(f(x), error = function(e) paste("(", conditionMessage(e), ")"))
}
hobart <- function(x) tryCatch({ p <- safe(make_forward(x))(cbind(147.33, -42.88)); sprintf("%s, %s", format(round(p[1]), big.mark = ","), format(round(p[2]), big.mark = ",")) }, error = function(e) "transform failed")
renderings <- data.frame(
  rendering = c("bare proj-string", "explicit proj-string", "ESRI:54099", "alternative centre (section 8)"),
  input = c(SPILHAUS, SPILHAUS_EXPLICIT, "ESRI:54099", SPILHAUS_ALT))
renderings$hobart_xy <- vapply(renderings$input, hobart, "")
knitr::kable(renderings, caption = "Same projection, several names. hobart_xy is Hobart's projected coordinate under each.", row.names = FALSE)
Same projection, several names. hobart_xy is Hobart’s projected coordinate under each.
rendering input hobart_xy
bare proj-string +proj=spilhaus 2,583,897, -1,613,781
explicit proj-string +proj=spilhaus +lon_0=66.94970198 +lat_0=-49.56371678 +azi=40.17823482 +rot=45 +k_0=1 +x_0=0 +y_0=0 +R=6378137 2,577,441, -1,612,650
ESRI:54099 ESRI:54099 3,654,183, -2,282,231
alternative centre (section 8) +proj=spilhaus +lon_0=-30 +lat_0=-20 +azi=20 +rot=45 1,793,670, -9,342,479
fence <- strrep("`", 3)
for (i in seq_len(nrow(renderings))) {
  cat("\n#### WKT2 for", renderings$rendering[i], "\n\n", fence, "\n", to_wkt(renderings$input[i]), "\n", fence, "\n", sep = "")
}

WKT2 forbare proj-string

PROJCS["unknown",
    GEOGCS["unknown",
        DATUM["WGS_1984",
            SPHEROID["WGS 84",6378137,298.257223563,
                AUTHORITY["EPSG","7030"]],
            AUTHORITY["EPSG","6326"]],
        PRIMEM["Greenwich",0,
            AUTHORITY["EPSG","8901"]],
        UNIT["degree",0.0174532925199433,
            AUTHORITY["EPSG","9122"]]],
    PROJECTION["custom_proj4"],
    UNIT["metre",1,
        AUTHORITY["EPSG","9001"]],
    AXIS["Easting",EAST],
    AXIS["Northing",NORTH],
    EXTENSION["PROJ4","+proj=spilhaus +datum=WGS84 +wktext +no_defs"]]

WKT2 forexplicit proj-string

PROJCS["unknown",
    GEOGCS["unknown",
        DATUM["unknown",
            SPHEROID["unknown",6378137,0]],
        PRIMEM["Greenwich",0,
            AUTHORITY["EPSG","8901"]],
        UNIT["degree",0.0174532925199433,
            AUTHORITY["EPSG","9122"]]],
    PROJECTION["custom_proj4"],
    UNIT["metre",1,
        AUTHORITY["EPSG","9001"]],
    AXIS["Easting",EAST],
    AXIS["Northing",NORTH],
    EXTENSION["PROJ4","+proj=spilhaus +lon_0=66.94970198 +lat_0=-49.56371678 +azi=40.17823482 +rot=45 +k_0=1 +x_0=0 +y_0=0 +R=6378137"]]

WKT2 forESRI:54099

PROJCS["WGS_1984_Spilhaus_Ocean_Map_in_Square",
    GEOGCS["WGS 84",
        DATUM["WGS_1984",
            SPHEROID["WGS 84",6378137,298.257223563,
                AUTHORITY["EPSG","7030"]],
            AUTHORITY["EPSG","6326"]],
        PRIMEM["Greenwich",0],
        UNIT["Degree",0.0174532925199433]],
    PROJECTION["Adams_Square_II"],
    PARAMETER["False_Easting",0],
    PARAMETER["False_Northing",0],
    PARAMETER["Scale_Factor",1],
    PARAMETER["Azimuth",40.17823482],
    PARAMETER["Longitude_Of_Center",66.94970198],
    PARAMETER["Latitude_Of_Center",-49.56371678],
    PARAMETER["XY_Plane_Rotation",45],
    UNIT["metre",1,
        AUTHORITY["EPSG","9001"]],
    AXIS["Easting",EAST],
    AXIS["Northing",NORTH],
    AUTHORITY["ESRI","54099"]]

WKT2 foralternative centre (section 8)

PROJCS["unknown",
    GEOGCS["unknown",
        DATUM["WGS_1984",
            SPHEROID["WGS 84",6378137,298.257223563,
                AUTHORITY["EPSG","7030"]],
            AUTHORITY["EPSG","6326"]],
        PRIMEM["Greenwich",0,
            AUTHORITY["EPSG","8901"]],
        UNIT["degree",0.0174532925199433,
            AUTHORITY["EPSG","9122"]]],
    PROJECTION["custom_proj4"],
    UNIT["metre",1,
        AUTHORITY["EPSG","9001"]],
    AXIS["Easting",EAST],
    AXIS["Northing",NORTH],
    EXTENSION["PROJ4","+proj=spilhaus +lon_0=-30 +lat_0=-20 +azi=20 +rot=45"]]

8. Another member of the family

(Skipped when the raster section was.)

Same method, different parameters: a naive recentring on the Atlantic. The square’s edges now slice the Pacific. Spilhaus’s contribution was the parameter choice, not the projection.

forward_alt <- safe(make_forward(SPILHAUS_ALT))
HALF_ALT <- square_half(forward_alt)
w_alt <- warp_square(SPILHAUS_ALT, HALF_ALT, max(400L, NPIX %/% 2L), out = "/vsimem/spilhaus_alt.tif")
segs_alt <- coast_segments(geom, forward_alt, HALF_ALT)
composite(w_alt$z, HALF_ALT, segs_alt, forward_alt(landmarks),
          sprintf("%s\n(half-width %.3f Mm vs %.3f Mm for the default)", SPILHAUS_ALT, HALF_ALT / 1e6, HALF / 1e6))
Error in `loadNamespace()`:
! there is no package called 'ximage'

Appendix: the same thing in one line each

Convenience layers hide which PROJ did the work. Each of these either works or does not, purely according to the table in section 1.

if (requireNamespace("sf", quietly = TRUE)) {
  x <- sf::st_transform(sf::st_sfc(sf::st_point(c(147.33, -42.88)), crs = "OGC:CRS84"), SPILHAUS)
  print(sf::st_coordinates(x)); cat("sf used PROJ", sf::sf_extSoftVersion()[["PROJ"]], "\n")
}
           X        Y
[1,] 2583897 -1613781
sf used PROJ 9.9.0 
if (requireNamespace("terra", quietly = TRUE)) {
  x <- terra::project(terra::vect(cbind(147.33, -42.88), crs = "OGC:CRS84"), SPILHAUS)
  print(terra::crds(x)); cat("terra used PROJ", terra::gdal(lib = "proj"), "\n")
}
           x        y
[1,] 2583897 -1613781
terra used PROJ 9.9.0 

What to take from this

  • On Linux the R packages usually agree with each other because they share one libproj; the question is whether the system PROJ is 9.6 or later. A stock Ubuntu LTS says no. The matrix in this repo’s Actions shows what each runner image actually has.
  • On macOS and Windows the CRAN binaries carry their own PROJ, and those versions move on CRAN’s schedule.
  • The primitives route (gdalraster for GDAL, wk for vertices, ximage for pixels) makes the dependency visible: every transform in this document is a call you can see, made by a package you can name.
  • A proj-string is a recipe with hidden defaults. Cite the WKT with the parameters in it, and do not assume the one authority code you can find describes the map you meant.