If you already use s2 (directly, or through sf, where it is the default for longitude and latitude data) you may wonder where bigcurve fits. The short answer: they agree on what a line between two points on the Earth is, and they answer different questions about it.
In every figure below s2 is teal, bigcurve is black, and the planar reading of the same coordinates is a grey dashed line. One line runs through the whole guide: along latitude 60, from longitude 0 to 100.
| s2 | bigcurve | |
|---|---|---|
| Job | geometry on the sphere: predicates, areas, unions, distances | drawing: just enough vertices for one map |
| Edges | great circles | great circles |
| Works in | 3D unit vectors, no projection | the plane of the target map |
| Tolerance measured | on the sphere (metres converted to an angle) | on the map, in the map’s units |
| Projections for tessellating | plate carree, Mercator, orthographic | anything PROJ can do |
| Output | s2 geography, or projected coordinates | longitude, latitude, so any tool can project it |
| Input | s2 geography | matrices, segment meshes, wkpool topology (so any wk-readable data) |
| Planar edges to geodesic | yes, on import (planar = TRUE) |
no |
Give a computer two points in longitude and latitude and ask for the line between them. There are two common answers.
s2 takes the second answer for every edge, and so does bigcurve. The planar middle of our line is (50, 60). The great circle middle is a long way north of it:
s2_as_text(s2_interpolate_normalized(line, 0.5))
#> [1] "POINT (49.99999999999999 69.63942512488693)"
## how far is the planar middle from the true line, in metres?
s2_distance(s2_lnglat(50, 60), line)
#> [1] 1071857More than a thousand kilometres. Neither answer is wrong: they are two different shapes that happen to share their vertices. What matters is knowing which one your data means. s2 makes the geodesic reading the default; bigcurve assumes it.
Yes. bigcurve inserts its new vertices exactly on s2’s arc. Densify the line and ask s2 how far each new vertex is from it:
d <- densify(the_line, "+proj=laea +lon_0=50 +lat_0=60", tolerance = 1000)
nrow(d)
#> [1] 13
max(s2_distance(s2_lnglat(d[, 1], d[, 2]), line))
#> [1] 1.749256e-09A few nanometres, which is floating point noise. The added vertices change nothing about the shape on the sphere; they only make it explicit for a flat drawing.
s2 does all its work on the sphere itself. Internally every point is a unit vector in three dimensions, and there is no map projection anywhere in sight. That is why s2 has no trouble at the poles or at the antimeridian: on a sphere there is no edge of the map to fall off.
bigcurve does its work in the plane of the map you are going
to draw. For each edge it projects the great circle midpoint
with PROJ and asks how far it lands from the straight chord between the
projected endpoints. If it is further than tolerance (in
the map’s own units, usually metres) the edge is split and both halves
are checked again. This is the adaptive resampling that Mike Bostock
wrote for d3-geo.
So the same edge gets different numbers of vertices in different projections, because the curve is a different shape on each map:
s2 is not blind to this problem. Its C++ library has an edge tessellator that turns geodesic edges into chains of straight edges in a projection, and the R package exposes it when it writes data out, for plate carree (longitude, latitude), Mercator and orthographic maps. Here is our line tessellated for Mercator by both packages, each asked for 1 km:
merc <- "+proj=merc +R=6378137"
tol <- 1000
## s2, in its own Mercator, then back to longitude and latitude
m <- wk::wk_handle(line, wk::wkb_writer(),
s2_projection = s2_projection_mercator(),
s2_tessellate_tol = tol / s2_earth_radius_meters())
v_s2 <- rproj_xy(as.matrix(wk::wk_coords(wk::wkb(m))[, c("x", "y")]),
"OGC:CRS84", source = merc)
## bigcurve, for the same Mercator
v_bc <- densify(the_line, merc, tolerance = tol)
c(s2 = nrow(v_s2), bigcurve = nrow(v_bc))
#> s2 bigcurve
#> 57 65In Mercator the two come out close, in count and in placement: both are solving the same problem in the same spirit. The difference is in what “1 km” means.
s2 measures its tolerance on the sphere; bigcurve measures it on the map. On a map that stretches distances, as Mercator does by a factor of two at latitude 60, those are not the same thing. And s2 can only aim at its three projections: for any other map the best it can do is tessellate in longitude and latitude and hope that is fine enough.
The figure measures, for every drawn chord, how far it strays on the map from the true projected line.
On the left, s2’s 1 km on the sphere becomes up to 1.7 km on the Mercator map near the ends of the line, while bigcurve stays under 1 km everywhere. On the right, s2’s longitude, latitude tessellation happens to be far finer than this map needs: 63 vertices for errors of tens of metres, where 17 keep bigcurve under 1 km. On another map, or another line, the same tessellation could just as easily be too coarse. bigcurve’s tolerance is always “how far may the drawn line stray on this map”.
The “planar edges to geodesic” row of the table deserves a sentence
too. If your data really are planar in longitude and latitude (a
coastline digitised on a plate carree map, say), s2 can densify on the
way in (s2_geog_from_wkb(..., planar = TRUE)) so
that its geodesic edges follow the planar ones. bigcurve never does
this. It always assumes the edges you hand it are great circles.
Because both packages agree on what an edge is, a natural pipeline is s2 for the analysis and bigcurve for the drawing. Here is a “box” written with four corners, from longitude 100 to 160 and latitude -35 to -20. We cut Australia with it in s2, then densify what the cut produced for a map in plain longitude and latitude:
box <- s2_make_polygon(c(100, 160, 160, 100), c(-35, -35, -20, -20))
aus <- s2_data_countries("Australia")
cut <- s2_intersection(aus, box)
pool <- wkpool::establish_topology(wk::as_wkb(cut))
out <- densify(pool, "+proj=eqc")
c(before = nrow(wkpool::pool_vertices(pool)),
after = nrow(wkpool::pool_vertices(out)))
#> before after
#> 110 118Read as s2 reads it, the box’s north side is not latitude -20: it is a great circle that dips to -22.8 in the middle, and that is the line s2 cut along. The coastline is already finely detailed and is left alone; the new vertices go only along the long sides the box contributed. Drawing the grey reading while analysing the black one is how a map ends up disagreeing with its own analysis.
To draw the result in any other projection, turn the pool back into
geometry and project it. The fast general path is wk and PROJ: build the
transform once with PROJ::proj_trans_create() and stream
the geometry through wk::wk_transform(). Any other tool
that projects (sf, terra) works too, since the densified vertices are
ordinary longitude, latitude points. Remember to densify for the map you
are drawing:
prj <- "+proj=laea +lon_0=130 +lat_0=-30"
geom <- wkpool::cycles_to_wkb(wkpool::merge_coincident(densify(pool, prj)))
drawn <- wk::wk_transform(geom, PROJ::proj_trans_create(wk::wk_crs(geom), prj))
dc <- wk::wk_coords(drawn)
op <- par(mar = rep(0.5, 4))
plot(range(dc$x), range(dc$y), type = "n", asp = 1,
axes = FALSE, xlab = "", ylab = "")
plot(drawn, col = "grey85", add = TRUE)s2_hemisphere() first; bigcurve leaves unprojectable edges
alone and never errors.wkpool::merge_coincident(period_x = 360).densify(), and it does not change the shape.Use s2 to decide what the shape is; use bigcurve to draw that shape, honestly, in whatever projection your map needs.