[gui] Add Equal Earth map projection - #208
Conversation
Add EqualEarthProjection to libseiscomp_gui as a third built-in map projection, selectable via scheme.map.projection = EqualEarth. Equal Earth (Savric, Patterson & Jenny, 2019, IJGIS 33(3):454-465, doi:10.1080/13658816.2018.1504949) is an equal-area pseudocylindrical projection with an overall shape similar to the Robinson projection. Unlike Gall-Peters it does not distort continent shapes at high latitude, and unlike the Robinson projection it preserves relative area, which matters for global seismicity maps. The forward transform is equation (1) of the paper; the inverse solves the northing polynomial for the parametric latitude with Newton-Raphson (the x-denominator and the derivative are the same polynomial, factored into one helper). The sphere is projected directly, as SeisComP feeds spherical coordinates. Output is normalised by the pole northing so the vertical extent matches RectangularProjection and the zoom level stays consistent when switching projections. The projection is not rectangular: render() rasterises the tile cache into the ARGB32 canvas per scan-line (theta solved once per row, longitude then linear), leaving pixels outside the outline transparent; project() / unproject() reject points off the outline; project(QPainterPath) handles the antimeridian seam, the wrapped world copies, curved-meridian edge subdivision and pole-enclosing polygons; updateBoundingBox() is self-contained. Verified against PROJ (+proj=eqearth +R=1) and the invariants published in the paper (straight parallels, longitude-linear equator, bilateral symmetry, aspect ratio 1:2.05458, pole line = 0.59247 x equator). Also extend the scheme.map.projection description in global_gui.xml.
|
Interesting addition, thanks. I will check it. Regarding the copyright, as you have signed the CLA already, all is good. You are the author and that is fine. I will just add the additional paragraph of the alternative usage. |
|
I have tested it and it is looking nice. Similar to the Kavrayskiy projection that we developed. What I don't like is the vertical movement that you allow beyond the top and bottom borders. That is inconsistent with the other projections. And you should compile in release mode to get the best render performance. |
Limit the vertical view offset in render() so the map can no longer be scrolled past its top or bottom border, matching the behaviour of the other projections. When the viewport is taller than the graticule the view is re-centred vertically; otherwise the centre latitude is clamped to the largest value that keeps both borders on the map. Addresses review feedback on PR SeisComP#208.
|
Thanks for testing. Fixed the vertical panning in 3474ccf: Re: release mode — agreed, that's how I benchmarked it; |
The per-pixel path in render() did a divide plus an fmod() (longitude wrap) for every pixel. Longitude is linear in the pixel column, and so is the texture U coordinate, so compute the visible column range from the |lon| <= pi outline once per scan-line and step U across it with a single add per pixel, letting getTexel()'s fractional masking handle the wrap - the same technique RectangularProjection uses. Output is unchanged (max 1 ulp difference in the masked U value, far below one texel). The per-pixel inner-loop cost drops by ~16x in a microbenchmark, bringing the fill rate in line with the other projections.
|
Also tightened the render inner loop in 6b6bfcb: the per-pixel path was |
Would you mind testing the plugin again @gempa-jabe |
|
Performance is much better and panning is fixed and aligns with other projections. Very nice. |
The vertical clamp added in 3474ccf only touched _visibleCenter, so Projection::center() - which Canvas::translate() reads back after every drag step to accumulate the next delta - kept growing past the clamp. Dragging back then did nothing until that excess had been undone. Move the clamp into clampVerticalCenter(), apply it to _center as well as _visibleCenter, and call it from centerOn() (not just render()) so the value the canvas reads while dragging always matches what is drawn.
|
Good catch — fixed in 5d3c71a. The clamp in 3474ccf only adjusted Now the clamp lives in |
Polygon and grid layers were drawn wherever project() placed a vertex, so BNA / GeoJSON / FEP polygons and the graticule spilled outside the rounded Equal Earth outline (report on PR SeisComP#208). The outline is exactly |lambda| <= pi and |lat| <= 90 in projection space, so clip the geometry there: - projectContinuous() clamps the running longitude offset to +/- pi (and the latitude to +/- 90). A polygon vertex that runs past the antimeridian lands on the rim instead of floating outside; the wrapped part is still drawn by the neighbouring world copy at the opposite rim. - the world-copy visibility test now requires a real overlap with the [-180, 180] window, so a polygon just outside it no longer collapses onto the rim as a sliver. - drawLonCircle() is overridden: a parallel is one horizontal rim-to-rim segment, replacing the base class longitude sweep that projects each sample independently and jumps across the map at the antimeridian. Verified with unproject(): every point on every polygon path, and the graticule, stays within the outline.
|
Fixed in 8b43532. I went with clipping the geometry rather than a painter clip region — a bit more fiddly, but it keeps the render throughput. The outline is exactly |lambda| <= pi and |lat| <= 90 in projection space, so projectContinuous() now snaps a vertex that runs past the antimeridian onto the rim, and the part that wrapped around is drawn by the neighbouring world copy at the other rim. I also tightened the world-copy visibility test so a polygon just outside the window doesn't collapse onto the rim, and overrode drawLonCircle() to draw each parallel as one rim-to-rim segment instead of the base-class longitude sweep that jumped across the map. Checked with unproject() that no polygon-path point and no grid line ends up outside the lens, across seam-straddling polygons, dateline coastlines and off-centre views. |
I think with the last update it looks a lot better. What do you think, @gempa-jabe ? |
|
Yes, it is looking much better now. Now we will be checking the code itself. |
gempa-jabe
left a comment
There was a problem hiding this comment.
The code looks pretty clean and nicely formatted. Just a few remarks.
| // Public projection interface | ||
| // ---------------------------------------------------------------------- | ||
| public: | ||
| virtual bool isRectangular() const; |
There was a problem hiding this comment.
Please declare all virtual interface methods with override keyword.
| inline void eeForward(double lambda, double phi, double &x, double &y) { | ||
| double s = M_COEF * std::sin(phi); | ||
| // Guard the asin() domain against round-off (|sqrt(3)/2 * sin| <= 0.8661). | ||
| if ( s > 1.0 ) s = 1.0; |
There was a problem hiding this comment.
Please enclose all branches with brackets.
if ( s > 1.0 ) {
s = 1.0;
}|
|
||
|
|
||
| // Wrap a longitude in degrees into (-180, 180]. | ||
| inline double wrapLonDeg(double lon) { |
There was a problem hiding this comment.
We have already Seiscomp::Geo::GeoCoordinate::normalizeLon for that.
gempa-stephan
left a comment
There was a problem hiding this comment.
Please use C++17 for new code and use clang-tidy to find issues. In my review I marked some of the findings. This file contains all of them:
| #include <vector> | ||
|
|
||
| #ifndef M_PI | ||
| #define M_PI 3.14159265358979323846 |
There was a problem hiding this comment.
Should be a const double like the polynomial coefficients below
| #endif | ||
|
|
||
|
|
||
| namespace Seiscomp { |
There was a problem hiding this comment.
Use nested namespaces.
| const double THETA_MAX = M_PI / 3.0; | ||
|
|
||
| // Degree <-> radian helpers (seiscomp/math/math.h #defines deg2rad/rad2deg). | ||
| inline double eeD2R(double d) { return d * (M_PI / 180.0); } |
| inline void eeForward(double lambda, double phi, double &x, double &y) { | ||
| double s = M_COEF * std::sin(phi); | ||
| // Guard the asin() domain against round-off (|sqrt(3)/2 * sin| <= 0.8661). | ||
| if ( s > 1.0 ) s = 1.0; |
There was a problem hiding this comment.
Use braces around statements.
| int nlat = (lat * 100000) + (lat < 0 ? -0.5 : +0.5); | ||
|
|
||
| if ( nlat % 10 ) | ||
| return QString("%1%2").arg(fabs(lat), 0, 'f', 5).arg(lat < 0 ? " S" : lat > 0 ? " N" : ""); |
There was a problem hiding this comment.
No else if after return.
| if ( lambda > M_PI ) lambda -= 2.0 * M_PI; | ||
| else if ( lambda < -M_PI ) lambda += 2.0 * M_PI; | ||
|
|
||
| double x, y; |
There was a problem hiding this comment.
Multiple declarations in one line.
| // the polar area fills instead of leaving an open ribbon. | ||
| bool EqualEarthProjection::project(QPainterPath &screenPath, size_t n, | ||
| const Geo::GeoCoordinate *poly, bool closed, | ||
| uint minPixelDist, ClipHint) const { |
There was a problem hiding this comment.
All parameters should be named.
| double prevLon = poly[0].lon; | ||
| double latMin = poly[0].lat; | ||
| double latMax = poly[0].lat; | ||
| gc.push_back(QPointF(runLon, poly[0].lat)); |
There was a problem hiding this comment.
emplace_back: gc.emplace_back(runLon, poly[0].lat);
| for ( ; ix < xl; ++ix ) scan[ix] = transparent; | ||
|
|
||
| if ( xl <= xr ) { | ||
| const double fh = double(Coord::fraction_half_max); |
| else | ||
| cache->getTexel(c, u, v, level); | ||
|
|
||
| scan[ix] = c | 0xff000000u; // force opaque inside the map |
Style / convention pass following review by @gempa-jabe and @gempa-stephan: - replace the M_PI #define with a const double - nested namespace (Seiscomp::Gui::Map) - brace every conditional / loop body - drop the else-if chain after return in lat2String - initialise members in the header, empty constructor init list - one declaration per line - name the unused ClipHint parameter - declare all overrides with the override keyword - emplace_back instead of push_back(QPointF(...)) - functional-style casts -> static_cast - 0xff000000U - reuse Geo::GeoCoordinate::normalizeLon instead of a local wrapLonDeg No behaviour change: compiles clean with -Wall -Wextra -Wshadow and the offscreen checks (round trip, polygon clipping, panning dead zone) still pass.
|
Thanks both. Pushed 42d14cc addressing the review:
No behaviour change — still compiles clean with @gempa-stephan I couldn't open the attached clang-tidy.txt (needs a session), so I applied the points from the inline comments across the whole file. If there are more findings in the report, let me know which lines or I can run clang-tidy locally against your config. |
gempa-jabe
left a comment
There was a problem hiding this comment.
My change requests are done.
|
Resolving the clipping issue is really clipping taken to the max. If you do it geometry-wise then you will probably end up with two passes, one for the outline (which can be non-contiguous) and one for the filling. You would need to trace the outline of the projection when a segment "leaves" the projection until the point when another segment "enters" the projection again. That is actually far from being implemented quick and easy. |
The per-row texture V coordinate is computed as (1 - lat/90) * fraction_half_max, which reaches fraction_max exactly at the south rim. getTexel() masks off bit 32 of V, so that bottom scan line wrapped back onto the north edge of the texture - visible as a strip of swapped pixels along the poles. Clamp V to [0, fraction_max), the same guard MercatorProjection::render() applies to its bottom row.
|
Pushed c03b94a for the swapped pole pixels. The row's texture V coordinate is On the half-visible clipping: I had a go, but it's the outline-tracing job you described, @gempa-jabe - walking the projection border between the exit and re-entry points, separately for the stroke and the fill. That's more than a small change for a case where the polygon is mostly off-screen anyway. I'd rather do it as a follow-up than hold this up, if that's alright. The geometry clip already keeps the fills off the border in the normal cases. |
I agree, just that we know that there is still a small glitch with that projection in combination with rendering polygons. |
|
I'll let @gempa-stephan do the final checks with your code and then it can be merged. |
I was able to reproduce the behaviour visually. Will look into it. Good catch. I haven't tested it with scesv or dragging the map while looking at the event with station annotations active. |
Every geographic point projects somewhere on this map, so project() never gets a chance to reject a point the way the base lineTo() expects. It also never notices an antimeridian crossing: project() wraps longitude into (-180, 180] around the centre, so a segment continuing past the rim reappears on the opposite rim, and the base class connected the two with a chord straight across the whole map - the long horizontal lines reported for station-to-origin and back-azimuth lines (drawn via Canvas::drawLine(), e.g. OriginLocatorMap). Add moveTo()/lineTo() overrides that detect the crossing the same way RectangularProjection::lineTo() does - comparing the screen-space step direction against the geographic longitude step direction - and, on a mismatch, draw up to the rim the path leaves through and continue from the mirrored point on the opposite rim (the outline is left-right symmetric about the centre for any given latitude). No change to any already-covered behaviour; the render()/project() paths are untouched.
|
Pushed 1b84697 for the station/back-azimuth line drawing. The projection never had its own
I confirmed locally that dragging the map across a station/origin line no longer breaks it. |









Summary
Adds
EqualEarthProjectiontolibseiscomp_guias a third built-in mapprojection, selectable with
scheme.map.projection = EqualEarthand viathe interactive projection menu.
Equal Earth (Šavrič, Patterson & Jenny, 2019, IJGIS 33(3):454–465,
doi:10.1080/13658816.2018.1504949)
is an equal-area pseudocylindrical projection shaped like the Robinson
projection. Unlike Gall-Peters it doesn't distort continent shapes at high
latitude; unlike Robinson it preserves relative area — useful for global
seismicity maps where area comparisons matter.
Design
Follows the existing
rectangular/mercatorpattern: two files underlibs/seiscomp/gui/map/projections/, added to thatCMakeLists.txt,registered with
REGISTER_PROJECTION_INTERFACE(EqualEarthProjection, "EqualEarth").polynomial for the parametric latitude with Newton–Raphson; the
x-denominator and the derivative are the same polynomial, factored into
one helper. Sphere projected directly (SeisComP feeds spherical coords —
the paper's recommendation for spherical input).
behaviour match
RectangularProjection— switching projections keeps thesame scale.
render()rasterises the tile cache into the ARGB32canvas per scan-line (θ solved once per row, longitude then linear),
leaving off-outline pixels transparent; supports mercator-projected tile
stores.
project()/unproject()reject points off the outline.project(QPainterPath&, …)handles the antimeridian seam, wrapped worldcopies, curved-meridian edge subdivision and pole-enclosing polygons.
updateBoundingBox()is self-contained.scheme.map.projectiondescription inglobal_gui.xml.Compiles clean with
-Wall -Wextra -Wshadow.Verification
Checked against PROJ (
+proj=eqearth +R=1, e.g.90°E,45°N → 1.159854, 0.860231) and the invariants published in the paper: straight parallels,longitude-linear equator, bilateral symmetry, aspect ratio 1:2.05458, pole
line = 0.59247 × equator, forward/inverse round-trip to ~1e-8 rad.
A standalone build of the same code (as a loadable plugin) with an
offscreen regression suite — the paper checks above plus the polygon
antimeridian / pole / curved-edge behaviour — is at
https://github.com/comoglu/seiscomp-equalearth for reference.
Notes
Mercator). If you'd rather it ship as an optional/bundled plugin, or want
a different structure, I'm happy to adapt.
switch to the standard gempa header per the CLA. I'll sign the CLA.
.rstchange — thescheme.map.projectiontable inglobal_gui.rstis generated from the XML.