Skip to content

layout: mercator_layout emits -inf / NaN positions for |lat| >= 90 with no validation (and the two poles are asymmetric) #1967

Description

@lmeyerov

Found by the 2026-08 layouts/viz amplification probe. Repros on master @ 42b4f7f7d, numpy 2.2.6 / pandas 2.3.3 / py3.12.

mercator_layout() applies the Mercator formula with no domain validation and writes whatever falls out — -inf, +23810, or NaN — straight into the bound x/y columns.

graphistry/layout/mercator.py:107-111:

107:        lat_rad = np.radians(lat_deg)
108:        lon_rad = np.radians(lon_deg)
110:        x_vals = R * lon_rad
111:        y_vals = R * np.log(np.tan(np.pi / 4 + lat_rad / 2))

Mercator is undefined at the poles and outside [-90, 90]; there is no clamp, no range check, and no post-assert (contrast circle.py:362-363, which does assert non-NaN positions).

Repro

import pandas as pd, graphistry
e = pd.DataFrame({'s':[0,1,2], 'd':[1,2,0]})

def run(label, lat, lon):
    n = pd.DataFrame({'id':[0,1,2], 'lat':lat, 'lon':lon})
    out = (graphistry.edges(e,'s','d').nodes(n,'id')
             .bind(point_latitude='lat', point_longitude='lon')
             .mercator_layout())
    print(label, out._nodes['y'].tolist())

run('poles      ', [90.0, -90.0, 0.0], [0.,0.,0.])
run('lat=200    ', [200.0, 0., 0.],    [0.,0.,0.])
run('lat=NaN    ', [37.7, float('nan'), 51.5], [-122.4, -74.0, -0.1])

Observed

poles       [23810.77, -inf, -0.0]
lat=200     [nan, -0.0, -0.0]
lat=NaN     [453.71, nan, 671.02]

Expected: a typed decline naming the out-of-domain latitude, or the standard Web-Mercator clamp to ±85.051129°, which is what every mapping library does. Whatever the choice, -inf should never reach a position column.

Note the pole case is also asymmetric: lat=+90 gives a large finite 23810.77 (floating-point tan(π/2) is finite), lat=-90 gives -inf. The two poles are not mirror images, which is a numerical artifact rather than a design decision.

Why inf/NaN in a position column is not benign

Out-of-range latitude is not exotic — it is what you get from a lat/lon column swap, from projected coordinates mistaken for degrees, or from a sentinel value like 999.

Secondary (bare-crash): non-numeric lat/lon dies deep in numpy

n = pd.DataFrame({'id':[0,1,2], 'lat':['37.7','40.7','51.5'], 'lon':['-122.4','-74.0','-0.1']})
graphistry.edges(e,'s','d').nodes(n,'id').bind(point_latitude='lat', point_longitude='lon').mercator_layout()

Observed: TypeError: loop of ufunc does not support argument 0 of type str which has no callable radians method
Expected: a typed error at the binding check (mercator.py:70-71 already validates that the columns exist; it does not validate that they are numeric).

Secondary (dead fallback): the advertised cupy→numpy fallback cannot work

mercator.py:82-87 warns "cuDF DataFrame detected but cupy is not available. Falling back to NumPy (CPU)", then the fallback at :103-104 does g._nodes[lat_col].values — which on a cuDF frame returns a cupy array, and np.radians on a cupy array raises TypeError: Implicit conversion to a NumPy array is not allowed. The documented fallback path is unreachable-as-intended. INDICATIVE — stated from inspection; not executed, since cupy in this environment cannot load libnvrtc.so.12 and the cuDF-without-cupy combination could not be constructed.

(The polars misrouting at mercator.py:73 is filed separately in the layout engine-parity issue.)

Category

silent-wrong (non-finite and NaN positions accepted and emitted, no warning) + bare-crash on non-numeric input.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions