Why Are the Lines on My Flow Map in the Wrong Place?

On this page

Problem statement

A flow line is supposed to connect two places. On some maps the lines miss the map entirely: they huddle in a corner or vanish. On others they connect the right endpoints but take a path that makes no geographic sense, such as a Seattle–Miami line that runs hundreds of kilometres from the shortest route. Both come from the coordinate system of the lines. Either it does not match the basemap, or the line's shape was built in one system and drawn in another. The second is subtle, because there are several reasonable "straight lines" between two places, and over a continent some of them diverge by hundreds of kilometres.

This page reproduces both problems for two long US flows, Los Angeles to New York and Seattle to Miami. It measures how far the different "straight" lines are from the true shortest path, the great circle.

Quick answer

Put lines and basemap in the same projected CRS before plotting. For long flows, densify along the great circle first, then project:

def great_circle(lon1, lat1, lon2, lat2, n=64):
    pts = GEOD.npts(lon1, lat1, lon2, lat2, n)                       # intermediate points on the ellipsoid
    return LineString([(lon1, lat1), *pts, (lon2, lat2)])

lines = gpd.GeoSeries([great_circle(*A, *B) for A, B in PAIRS.values()], crs=4326).to_crs(5070)
print(f"{len(lines)} great-circle lines with {len(lines.iloc[0].coords)} vertices each, now in {lines.crs.to_string()}, "
      f"inside the basemap bounds: {lines.total_bounds[0] >= STATES.total_bounds[0] - 1e5}")
2 great-circle lines with 66 vertices each, now in EPSG:5070, inside the basemap bounds: True
Map of the contiguous United States with two flows, Los Angeles to New York and Seattle to Miami, each drawn three ways: the great circle and the straight line in Albers nearly coincide, while the line straight in longitude and latitude bows south by hundreds of kilometres. Map of the contiguous United States with two flows, Los Angeles to New York and Seattle to Miami, each drawn three ways: the great circle and the straight line in Albers nearly coincide, while the line straight in longitude and latitude bows south by hundreds of kilometres.
Three lines that each look straight in some coordinate system. In CONUS Albers a straight line stays within 15 km of the great circle; the line that is straight in longitude and latitude does not.

One CRS for lines and basemap, a deliberate straight line

1. Check that lines and basemap share a CRS

The most common failure: the basemap is projected (metres), the lines are in degrees. Both plot without error, but at completely different scales:

raw = gpd.GeoSeries([LineString([A, B]) for A, B in PAIRS.values()], crs=4326)
print(f"basemap x range: {STATES.total_bounds[0]:,.0f} to {STATES.total_bounds[2]:,.0f} (metres, EPSG:5070)")
print(f"lines x range:   {raw.total_bounds[0]:.1f} to {raw.total_bounds[2]:.1f} (degrees, EPSG:4326)")
print("same CRS:", raw.crs == STATES.crs)
basemap x range: -2,356,114 to 2,258,154 (metres, EPSG:5070)
lines x range:   -122.3 to -74.0 (degrees, EPSG:4326)
same CRS: False

Plotted together, the lines occupy a space about 50 units wide near the origin of a map millions of metres across. They are drawn, just invisibly small and in the wrong place. Reproject one layer to the other's CRS.

2. Know which "straight line" you are drawing

Between two points there are three common lines. A two-vertex line, reprojected, is straight in the projected map. A line densified in longitude and latitude, then projected, follows a path of steadily changing longitude and latitude. A great circle is the shortest path on the Earth. Measure how far the first two stray from the third, at their midpoints:

def midpoint_offset_km(line_a, line_b):
    a = line_a.interpolate(0.5, normalized=True); b = line_b.interpolate(0.5, normalized=True)
    return a.distance(b) / 1000

for name, (A, B) in PAIRS.items():
    gc = gpd.GeoSeries([great_circle(*A, *B)], crs=4326).to_crs(5070).iloc[0]
    proj_straight = gpd.GeoSeries([LineString([A, B])], crs=4326).to_crs(5070).iloc[0]
    lonlat_dense = gpd.GeoSeries([LineString(np.linspace(A, B, 64))], crs=4326).to_crs(5070).iloc[0]
    print(f"{name:22s}: straight in EPSG:5070 is {midpoint_offset_km(proj_straight, gc):4.0f} km from the great circle at mid-route; "
          f"straight in lon/lat is {midpoint_offset_km(lonlat_dense, gc):4.0f} km off")
Los Angeles -> New York: straight in EPSG:5070 is   15 km from the great circle at mid-route; straight in lon/lat is  252 km off
Seattle -> Miami      : straight in EPSG:5070 is    9 km from the great circle at mid-route; straight in lon/lat is  330 km off
Left: a map of the United States in Albers metres with a small red circle in the Gulf of Mexico marking where flow lines in degrees end up; right: the same lines zoomed to an area 60 by 30 units across, where they are visible. Left: a map of the United States in Albers metres with a small red circle in the Gulf of Mexico marking where flow lines in degrees end up; right: the same lines zoomed to an area 60 by 30 units across, where they are visible.
What happens when lines in degrees meet a basemap in metres. The lines are drawn, but as a speck near the projection's origin.

3. Choose deliberately

The line straight in longitude and latitude is the one to avoid: 252 km off the great circle for Los Angeles–New York and 330 km for Seattle–Miami. A straight line in a conic projection such as CONUS Albers happens to stay within 15 km of the great circle, so for US flows drawn in EPSG:5070 two vertices are enough. In Web Mercator or for longer flows that no longer holds, and densifying along the great circle is the safe default. For short flows within a city all three lines are indistinguishable. Curving lines for legibility (as in a flow map with arcs) is a design choice; make it after the geography is right.

Line lengths measured on the ellipsoid

The data used on this page

City coordinates (longitude, latitude) for Los Angeles, New York, Seattle and Miami city halls, approximate to 0.01°; state outlines from the 2023 cartographic boundary files, projected to CONUS Albers (EPSG:5070). Great circles are computed on the WGS 84 ellipsoid with pyproj.Geod.

from pathlib import Path

import geopandas as gpd
import numpy as np
import requests
from pyproj import Geod
from shapely.geometry import LineString

Path("data").mkdir(exist_ok=True)
if not Path("data/cb_2023_us_state_20m.zip").exists():
    Path("data/cb_2023_us_state_20m.zip").write_bytes(requests.get("https://www2.census.gov/geo/tiger/GENZ2023/shp/cb_2023_us_state_20m.zip", timeout=300).content)
STATES = gpd.read_file("data/cb_2023_us_state_20m.zip").query("STUSPS not in ['AK', 'HI', 'PR']").to_crs(5070)
GEOD = Geod(ellps="WGS84")
PAIRS = {"Los Angeles -> New York": ((-118.24, 34.05), (-74.01, 40.71)),
         "Seattle -> Miami": ((-122.33, 47.60), (-80.19, 25.77))}
for name, (A, B) in PAIRS.items():
    print(f"{name}: {GEOD.inv(*A, *B)[2] / 1000:,.0f} km on the ellipsoid")
Los Angeles -> New York: 3,944 km on the ellipsoid
Seattle -> Miami: 4,400 km on the ellipsoid

Example 1: length of each line, measured on the ellipsoid

for name, (A, B) in PAIRS.items():
    gc_len = GEOD.geometry_length(great_circle(*A, *B)) / 1000
    ll_len = GEOD.geometry_length(LineString(np.linspace(A, B, 64))) / 1000
    print(f"{name:22s}: great circle {gc_len:,.0f} km, lon/lat straight line {ll_len:,.0f} km (+{ll_len / gc_len - 1:.1%})")
Los Angeles -> New York: great circle 3,944 km, lon/lat straight line 3,984 km (+1.0%)
Seattle -> Miami      : great circle 4,400 km, lon/lat straight line 4,462 km (+1.4%)

Coordinates mean nothing without their system

Coordinates mean nothing without their reference system. A flow table usually stores longitude and latitude, and a basemap is usually projected, so the two must meet in one CRS before plotting. GeoPandas does not reproject on plot: it draws whatever numbers it is given. When the scales differ by a factor of a hundred thousand, the lines collapse into a speck that is easy to mistake for no lines at all.

Distance at mid-route between the great circle and two kinds of straight line, for two long US flows. how far from the shortest path?flowstraight in EPSG:5070straight in lon/latLos Angeles → New York(3,944 km)15 km252 kmSeattle → Miami (4,400 km)9 km330 kmGreat circles on the WGS 84 ellipsoid with pyproj.Geod spatialworkflow.io
How far each kind of straight line strays from the shortest path.

The shape problem is about what "straight" means. A segment with two vertices is straight in whatever coordinate system it is drawn in. Straight in longitude and latitude is a different path from straight in an Albers projection, and neither is the shortest path on the Earth. Over a few kilometres the differences vanish. Across the United States, a line straight in longitude and latitude runs up to 330 km from the great circle, while a straight line in Albers stays within 15 km. Densifying along the great circle before projecting fixes the geography once, for any projection.

The antimeridian, web maps and pydeck

Flow line placement problems and fixes. line placement problemsSymptomFixlines vanish into a speckreproject lines to the basemap CRSlines 300 km off the shortestroutedo not densify in lon/latlong flows in Web Mercatordensify along the great circlefirstline wraps around the worldsplit at the antimeridianGeoPandas never reprojects on plot spatialworkflow.io
Line placement problems and fixes.
  • Antimeridian: great circles across 180° need splitting, or the line wraps around the whole map.
  • Web maps: Leaflet and MapLibre draw straight lines in Web Mercator; densify great circles before sending them.
  • pydeck: ArcLayer draws screen-space arcs; GreatCircleLayer follows the great circle.
  • Short flows (within a city) can stay as two-vertex lines in a local projected CRS.
  • Length: measure line lengths on the ellipsoid (Geod.geometry_length) or in an equal-distance projection, not in degrees.

FAQ

Why do my flow lines not show up on the map?

They are probably in a different CRS from the basemap, usually degrees against metres. Reproject the lines with .to_crs(basemap.crs).

How do I draw great-circle lines in Python?

Use pyproj.Geod.npts to compute intermediate points between the endpoints, build a LineString, then project it like any other geometry.

Is a straight line on a projected map the shortest route?

No; it is straight only in that projection. The shortest route on the Earth is the great circle, which usually looks curved on a map.

When does the difference matter?

For flows longer than a few hundred kilometres. Within a city or region the different lines are visually identical.