Skip to content

[gui] Add Equal Earth map projection - #208

Open
comoglu wants to merge 8 commits into
SeisComP:mainfrom
comoglu:gui-equal-earth-projection
Open

[gui] Add Equal Earth map projection#208
comoglu wants to merge 8 commits into
SeisComP:mainfrom
comoglu:gui-equal-earth-projection

Conversation

@comoglu

@comoglu comoglu commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds EqualEarthProjection to libseiscomp_gui as a third built-in map
projection, selectable with scheme.map.projection = EqualEarth and via
the 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 / mercator pattern: two files under
libs/seiscomp/gui/map/projections/, added to that CMakeLists.txt,
registered with REGISTER_PROJECTION_INTERFACE(EqualEarthProjection, "EqualEarth").

  • Forward = equation (1) of the paper. 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. Sphere projected directly (SeisComP feeds spherical coords —
    the paper's recommendation for spherical input).
  • Output normalised by the pole northing so the vertical extent and zoom
    behaviour match RectangularProjection — switching projections keeps the
    same scale.
  • Not rectangular: render() rasterises the tile cache into the ARGB32
    canvas 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 world
    copies, curved-meridian edge subdivision and pole-enclosing polygons.
    updateBoundingBox() is self-contained.
  • Also extends the scheme.map.projection description in global_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

  • I'm submitting this as a built-in projection (like Rectangular /
    Mercator). If you'd rather it ship as an optional/bundled plugin, or want
    a different structure, I'm happy to adapt.
  • The file header carries my copyright + the repo's AGPL-3.0 text; glad to
    switch to the standard gempa header per the CLA. I'll sign the CLA.
  • No documentation .rst change — the scheme.map.projection table in
    global_gui.rst is generated from the XML.

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.
@cla-bot cla-bot Bot added the cla-signed The CLA has been signed by all contributors label Sep 10, 2026
@gempa-jabe

Copy link
Copy Markdown
Contributor

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.

@gempa-jabe

Copy link
Copy Markdown
Contributor

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.
@comoglu

comoglu commented Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for testing.

Fixed the vertical panning in 3474ccf: render() now clamps the vertical
view offset so neither border can be scrolled inside the viewport — when
the viewport is taller than the graticule the view re-centres vertically,
otherwise the centre latitude is clamped to the largest value that keeps
both the top and bottom on the map. This mirrors what
RectangularProjection::render() does. Horizontal panning is unchanged
(the map wraps in longitude).

Re: release mode — agreed, that's how I benchmarked it; render() solves
the Newton iteration once per scan-line and the longitude is linear across
the row, so the per-pixel cost is a multiply, a compare and the texel
fetch, comparable to RectangularProjection.

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.
@comoglu

comoglu commented Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

Also tightened the render inner loop in 6b6bfcb: the per-pixel path was
doing a divide and an fmod() (the longitude wrap) for every pixel.
Longitude — and therefore the texture U coordinate — is linear in the
pixel column, so it now computes the visible column span from the outline
once per scan-line and steps U across it with one add per pixel, the same
way RectangularProjection does; getTexel()'s fractional masking
handles the wrap. Output is unchanged (≤ 1 ulp in the masked U value), and
the inner-loop cost drops ~16x in a microbenchmark, so the fill rate is
now in line with the other projections regardless of build type.

@comoglu

comoglu commented Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

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.

Would you mind testing the plugin again @gempa-jabe

@gempa-jabe

Copy link
Copy Markdown
Contributor

Performance is much better and panning is fixed and aligns with other projections. Very nice.
The only things I noticed is when you zoom into the map and then start vertical panning by dragging the mouse, it has some dead zone probably related to the panning fix.

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.
@comoglu

comoglu commented Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

Good catch — fixed in 5d3c71a.

The clamp in 3474ccf only adjusted _visibleCenter. Canvas::translate()
reads Projection::center() (i.e. _center) back after each drag step to
accumulate the next delta, so _center kept climbing past the clamp while
the drawn map stopped — dragging back then did nothing until that excess
was undone (the dead zone).

Now the clamp lives in clampVerticalCenter(), adjusts _center as well
as _visibleCenter, and runs from centerOn() too (not only render()),
so what the canvas reads back always matches what is drawn. Emulating the
Canvas::translate() loop: after over-panning up, the very first
down-drag step moves the map (one step ≈ 5°), no dead zone.

@gempa-jabe

Copy link
Copy Markdown
Contributor
image

See screenshot. Polygons do not clip at the borders. Either the geometry needs to be clipped or the painter requires a clipping region which needs to be updated at every zoom or pan operation. The latter is easier to render but maybe degrades pixel throughput.

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.
@comoglu

comoglu commented Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

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.

@comoglu

comoglu commented Sep 10, 2026

Copy link
Copy Markdown
Contributor Author
image See screenshot. Polygons do not clip at the borders. Either the geometry needs to be clipped or the painter requires a clipping region which needs to be updated at every zoom or pan operation. The latter is easier to render but maybe degrades pixel throughput.

I think with the last update it looks a lot better. What do you think, @gempa-jabe ?

@gempa-jabe

Copy link
Copy Markdown
Contributor

Yes, it is looking much better now. Now we will be checking the code itself.

@gempa-jabe gempa-jabe left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code looks pretty clean and nicely formatted. Just a few remarks.

// Public projection interface
// ----------------------------------------------------------------------
public:
virtual bool isRectangular() const;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have already Seiscomp::Geo::GeoCoordinate::normalizeLon for that.

@gempa-stephan gempa-stephan left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

clang-tidy.txt

#include <vector>

#ifndef M_PI
#define M_PI 3.14159265358979323846

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be a const double like the polynomial coefficients below

#endif


namespace Seiscomp {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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); }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use line breaks.

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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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" : "");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;

@gempa-stephan gempa-stephan Sep 10, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

const auto fh

else
cache->getTexel(c, u, v, level);

scan[ix] = c | 0xff000000u; // force opaque inside the map

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

0xff000000U

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.
@comoglu

comoglu commented Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

Thanks both. Pushed 42d14cc addressing the review:

  • M_PI #defineconst double
  • nested namespace
  • braces on every conditional / loop body
  • no else if after return in lat2String
  • members initialised in the header, empty ctor init list
  • one declaration per line
  • named the unused ClipHint parameter
  • override on all the overrides
  • emplace_back
  • functional casts → static_cast
  • 0xff000000U
  • dropped the local wrapLonDeg for Geo::GeoCoordinate::normalizeLon

No behaviour change — still compiles clean with -Wall -Wextra -Wshadow and the offscreen checks (round trip, polygon clipping, panning) pass.

@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 gempa-jabe left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My change requests are done.

@gempa-stephan

Copy link
Copy Markdown
Contributor

The clang-tidy issues are resolved. If there are some tokens left for today you may try to fix the minor clipping issue:

clip0 clip1

Ideally the outline should be clipped if the polygon is only half-visible.

Also it seems that some tile pixes of the poles are swapped:

poles

@gempa-jabe

Copy link
Copy Markdown
Contributor

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.
@comoglu

comoglu commented Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

Pushed c03b94a for the swapped pole pixels.

The row's texture V coordinate is (1 - lat/90) * fraction_half_max, and right at the bottom rim that comes out as exactly fraction_max. getTexel() clears bit 32 of V, so that last scan line wrapped back to the top of the texture and drew the north edge along the south rim. I clamp V to [0, fraction_max) now, which is the same guard MercatorProjection::render() uses for its bottom row. No change anywhere else.

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.

@gempa-jabe

Copy link
Copy Markdown
Contributor

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.

@gempa-jabe

Copy link
Copy Markdown
Contributor

I'll let @gempa-stephan do the final checks with your code and then it can be merged.

@gempa-stephan

Copy link
Copy Markdown
Contributor

Final tests revealed another drawing issues. This is scesv showing stations for an event in Chile. The station annotation and back-azimuth lines are rendered incorrectly.

equalearth_cx equalearth_cx_baz_sta_ano

@comoglu

comoglu commented Sep 11, 2026

Copy link
Copy Markdown
Contributor Author

Final tests revealed another drawing issues. This is scesv showing stations for an event in Chile. The station annotation and back-azimuth lines are rendered incorrectly.

equalearth_cx equalearth_cx_baz_sta_ano

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.
@comoglu

comoglu commented Sep 11, 2026

Copy link
Copy Markdown
Contributor Author

Pushed 1b84697 for the station/back-azimuth line drawing.

The projection never had its own moveTo()/lineTo(), so it fell back to the base class default: a straight drawLine(cursor, pp) between whatever two screen points project() returns. project() wraps longitude into (-180, 180] around the view centre, so a line that continues past the rim (a station-to-origin or back-azimuth line whose great circle crosses the antimeridian relative to the current centre) reappears projected on the opposite rim, and the base class connects the two with a chord straight across the whole map - that's the long horizontal lines in your screenshot.

RectangularProjection already handles exactly this in its own lineTo() (splits the line at the map edge, continues from the mirrored edge), so I did the same thing here: compare the screen-space step direction against the geographic 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.

I confirmed locally that dragging the map across a station/origin line no longer breaks it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cla-signed The CLA has been signed by all contributors

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants