Why Is My Flow Map an Unreadable Tangle of Lines?

On this page

Problem statement

You draw every origin–destination pair as a line and get a solid smudge. Thousands of lines cross the same pixels, the busy routes disappear under the quiet ones, and the map says only "there is a lot of movement here". This is overplotting, and adding more styling does not fix it. The fix is to draw less, by filtering, aggregating or letting transparency add up. Each of those throws something away, so the honest version says what share of the flows the picture still shows.

This page reproduces the tangle with a month of Citi Bike trips between 157 stations in Jersey City and Hoboken. It measures four ways out of it: the ink each covers and the trips each represents.

Quick answer

Do not draw every pair. Either keep the pairs that carry most trips, or aggregate the endpoints into larger zones first, and say what share of trips the map shows:

cells = pairs.assign(o=pairs.start_station_id.map(CELL), d=pairs.end_station_id.map(CELL))
cells = cells[cells.o != cells.d].groupby(["o", "d"], as_index=False).trips.sum()
keep = cells.nlargest(60, "trips")
print(f"{len(pairs):,} station pairs -> {len(cells):,} H3 cell pairs; top 60 cell pairs carry "
      f"{keep.trips.sum() / pairs.trips.sum():.0%} of trips between stations")
5,506 station pairs -> 777 H3 cell pairs; top 60 cell pairs carry 58% of trips between stations
Four maps of Jersey City and Hoboken: all 5,506 Citi Bike station pairs drawn as opaque lines form a solid red block; drawn with alpha 0.05 they form a density cloud; the top 60 station pairs show a few short heavy routes carrying 15% of trips; the top 60 pairs between H3 cells show a connected network carrying 58% of trips. Four maps of Jersey City and Hoboken: all 5,506 Citi Bike station pairs drawn as opaque lines form a solid red block; drawn with alpha 0.05 they form a density cloud; the top 60 station pairs show a few short heavy routes carrying 15% of trips; the top 60 pairs between H3 cells show a connected network carrying 58% of trips.
The same month of trips four ways. Sixty lines between H3 cells represent four times as many trips as sixty lines between individual stations.

Measure the tangle, try transparency, compare representations

1. Measure the tangle

Render each version at the same size and count how much of the frame the lines cover. A map whose lines cover most of the land carries no pattern:

def coverage(segments, widths, alpha=1.0):
    fig = plt.figure(figsize=(4, 5), dpi=100); ax = fig.add_axes([0, 0, 1, 1]); ax.set_axis_off()
    ax.add_collection(LineCollection(segments, linewidths=widths, colors="black", alpha=alpha))
    ax.set_xlim(*XLIM); ax.set_ylim(*YLIM); fig.canvas.draw()
    img = np.asarray(fig.canvas.buffer_rgba())[..., 0]; plt.close(fig)
    return (img < 128).mean()                                                 # share of pixels darker than mid-grey

seg = lambda df, xy: [[xy[a], xy[b]] for a, b in zip(df.iloc[:, 0], df.iloc[:, 1])]
all_lines = coverage(seg(pairs, XY), 0.6)
top60 = pairs.nlargest(60, "trips")
print(f"all {len(pairs):,} pairs: {all_lines:.0%} of the frame covered; top 60: {coverage(seg(top60, XY), 1.5):.0%}")
all 5,506 pairs: 17% of the frame covered; top 60: 1%

2. Try transparency

With a low alpha, overlapping lines add up, so busy corridors darken and single trips fade. It keeps every pair but shows density rather than individual flows:

faint = coverage(seg(pairs, XY), 0.6, alpha=0.05)
print(f"all pairs at alpha 0.05: {faint:.0%} of the frame darker than mid-grey")
all pairs at alpha 0.05: 7% of the frame darker than mid-grey

3. Compare what each version represents

Ink is half the story; the other half is how many trips the picture accounts for:

rows = [("all station pairs", len(pairs), 1.0),
        ("top 60 station pairs", 60, top60.trips.sum() / pairs.trips.sum()),
        ("top 60 H3 cell pairs", 60, keep.trips.sum() / pairs.trips.sum())]
for name, n, share in rows:
    print(f"{name:22s}: {n:>5,} lines, {share:.0%} of trips between stations")
all station pairs     : 5,506 lines, 100% of trips between stations
top 60 station pairs  :    60 lines, 15% of trips between stations
top 60 H3 cell pairs  :    60 lines, 58% of trips between stations
Share of Citi Bike trips between stations represented by three versions of a flow map, June 2024. same 60 lines, different coverageall 5,506 station pairs100%, 17% of frame inkedtop 60 station pairs15%top 60 H3 cell pairs58%050%100%Aggregating 156 stations into 37 cells concentrates the trips spatialworkflow.io
What each version represents.

Cell pairs drawn with their coverage

The data used on this page

Citi Bike system data, Jersey City and Hoboken trips for June 2024. Stations are grouped into H3 cells at resolution 8 (about 0.7 km² each) with the h3 library; coordinates are projected to New Jersey State Plane (EPSG:3424).

import zipfile
from pathlib import Path

import geopandas as gpd
import h3
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import requests
from matplotlib.collections import LineCollection

Path("data").mkdir(exist_ok=True)
F = Path("data/JC-202406-citibike-tripdata.csv.zip")
if not F.exists():
    F.write_bytes(requests.get("https://s3.amazonaws.com/tripdata/" + F.name, timeout=600).content)
with zipfile.ZipFile(F) as z:
    t = pd.read_csv(z.open("JC-202406-citibike-tripdata.csv"), dtype={"start_station_id": str, "end_station_id": str})
t = t.dropna(subset=["start_station_id", "end_station_id"])
t = t[t.start_station_id != t.end_station_id]
st = t.groupby("start_station_id")[["start_lat", "start_lng"]].median()
t = t[t.end_station_id.isin(st.index)]
pairs = t.groupby(["start_station_id", "end_station_id"]).size().rename("trips").reset_index()
p = gpd.GeoSeries(gpd.points_from_xy(st.start_lng, st.start_lat), crs=4326).to_crs(3424)
XY = dict(zip(st.index, zip(p.x, p.y)))
CELL = {s: h3.latlng_to_cell(r.start_lat, r.start_lng, 8) for s, r in st.iterrows()}
cell_pts = {c: np.mean([XY[s] for s in st.index if CELL[s] == c], axis=0) for c in set(CELL.values())}
x0, y0, x1, y1 = p.total_bounds
XLIM, YLIM = (x0 - 1000, x1 + 1000), (y0 - 1000, y1 + 1000)
print(f"{len(pairs):,} station pairs, {pairs.trips.sum():,} trips; {len(st)} stations in {len(cell_pts)} H3 cells")
5,506 station pairs, 104,718 trips; 156 stations in 37 H3 cells

Example 1: cell pairs drawn with their coverage

cell_cov = coverage(seg(keep[["o", "d"]], cell_pts), 1.5)
print(f"top 60 H3 cell pairs: {cell_cov:.0%} of the frame covered")
top 60 H3 cell pairs: 2% of the frame covered

Too many lines for the pixels available

Overplotting is a ratio problem: too many lines for the pixels available. A flow map at street scale has thousands of pairs, most of them a few trips each, and every line costs the same ink whatever it carries. Drawing them all shows that bikes go everywhere, which the reader already assumed. The pattern, where the heavy flows run, is in a small fraction of the pairs.

Three remedies for overplotted flow maps compared. what each remedy throws awaykeepslosesfilter to top pairs✓individual routes✕most trips (85%)transparency✓every pair!readable single routesaggregate to zones✓most trips (58%)!detail inside each zoneState what share of the flows the map shows spatialworkflow.io
Each remedy throws something different away.

The three remedies differ in what they discard. Filtering keeps a few individual pairs and drops the rest, so it should state the share of trips it shows. Transparency keeps everything and turns the picture into a density map, where single routes can no longer be read. Aggregation merges nearby stations so that many small flows become a few larger ones. It loses detail inside each zone but covers more of the trips with the same number of lines. Here the top 60 cell pairs account for more trips than the top 60 station pairs.

Draw order, zone choice and edge bundling

Overplotting problems and fixes. overplotting problemsSymptomFixa solid smudge of linesfilter or aggregate before drawingbusy routes buriedsort ascending, draw largest lastfiltered map looks completestate the share of trips shownlooks fine on screen, not in printcheck coverage at publication sizeCiti Bike Jersey City and Hoboken, June 2024 spatialworkflow.io
Overplotting problems and fixes.
  • Draw order still matters after filtering: sort by flow so the largest lines are on top.
  • Zone choice changes the picture; H3 cells are neutral, while neighbourhoods match how people describe places.
  • Edge bundling (merging lines that run in parallel) is another remedy; it distorts geometry and needs explaining.
  • Small multiples by time of day split one tangle into several readable maps.
  • Coverage depends on line width and image size; compare versions at the size they will be published.

FAQ

How do I fix an overcrowded flow map?

Draw fewer lines: keep the flows that carry most of the volume, aggregate endpoints into larger zones, or use transparency to turn the lines into a density picture.

What alpha should I use for many flow lines?

Low enough that a single line is faint and a busy corridor is dark, typically 0.02–0.1 for thousands of lines. Check the result at publication size.

Does filtering flows hide data?

Yes, by design. State what share of trips or movers the map shows, so the reader knows what is left out.

Is aggregation better than filtering?

It often represents more of the trips with the same number of lines, at the cost of detail within each zone.