Add bus whitelist, route exclusions, crow-fly shape filter, and KML export #2

Open
marvin wants to merge 16 commits from add-kml-export-and-bus-filtering into master
3 changed files with 287 additions and 1 deletions
Showing only changes of commit d38c280a4e - Show all commits
+5
View File
@@ -4,6 +4,11 @@ copenhagen/data/processed/**
!copenhagen/data/raw/.gitkeep !copenhagen/data/raw/.gitkeep
!copenhagen/data/processed/.gitkeep !copenhagen/data/processed/.gitkeep
# Generated map outputs (regenerable via render.py)
copenhagen/output/*.png
copenhagen/output/*.svg
copenhagen/output/*.pdf
# Python # Python
.venv/ .venv/
__pycache__/ __pycache__/
+1 -1
View File
@@ -67,7 +67,7 @@ mask:
color: white color: white
basemap: basemap:
provider: CartoDB.PositronNoLabels provider: OpenStreetMap.Mapnik
zoom: 14 zoom: 14
labels: labels:
+281
View File
@@ -0,0 +1,281 @@
#!/usr/bin/env python3
"""Render the Copenhagen transit map to PNG/SVG/PDF.
Reads data/processed/master.gpkg (layers: lines, stations) + area.gpkg +
config/styling.yaml, applies CLI filters, and composes a printable map:
- Carto Positron (no labels) basemap via contextily (EPSG:3857)
- two-tone lines: dark casing + colour body, per route
- z-order: bus (bottom) -> s_tog -> light_rail -> regional -> metro (top)
- shapeburst fade mask outside the City Pass area
- station labels (metro + S-tog) via adjustText
Usage:
uv run python render.py # default: all modes, PNG
uv run python render.py --no-buses --format svg --out map.svg
uv run python render.py --bus-subset A,C,S # only trunk buses
uv run python render.py --modes metro,s_tog # rail-only
"""
import argparse
from pathlib import Path
import geopandas as gpd
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
import yaml
from affine import Affine
from adjustText import adjust_text
from scipy.ndimage import distance_transform_edt
from _common import CONFIG, OUTPUT, PROCESSED
TARGET_CRS = "EPSG:3857"
def load_args():
p = argparse.ArgumentParser(description="Render the Copenhagen transit map.")
p.add_argument("--modes", default=None,
help="comma-separated styles to include (metro,s_tog,light_rail,regional,bus)")
p.add_argument("--no-buses", action="store_true", help="omit bus layer")
p.add_argument("--bus-subset", default=None,
help="comma-separated bus categories to keep (A,C,S,regular)")
p.add_argument("--max-bus-routes", type=int, default=None,
help="cap on number of distinct bus refs drawn")
p.add_argument("--dpi", type=int, default=200)
p.add_argument("--size", default="16",
help="figure size in inches: 'W' (height auto) or 'WxH'")
p.add_argument("--format", default="png", help="output format (png/svg/pdf)")
p.add_argument("--out", default=None, help="output path (default: output/copenhagen.<fmt>)")
p.add_argument("--no-basemap", action="store_true", help="skip basemap tiles (offline/faster)")
p.add_argument("--no-labels", action="store_true", help="skip station labels")
return p.parse_args()
def parse_size(spec, aspect):
if "x" in spec:
w, h = spec.split("x")
return float(w), float(h)
w = float(spec)
return w, w / aspect
def filter_lines(lines, args):
keep = set(lines["style"].unique())
if args.modes:
keep &= {m.strip() for m in args.modes.split(",")}
if args.no_buses:
keep.discard("bus")
out = lines[lines["style"].isin(keep)].copy()
if "bus" in keep and out["style"].eq("bus").any():
bus = out[out["style"].eq("bus")]
other = out[~out["style"].eq("bus")]
if args.bus_subset:
cats = {c.strip() for c in args.bus_subset.split(",")}
bus = bus[bus["bus_category"].isin(cats)]
if args.max_bus_routes is not None:
refs = list(dict.fromkeys(bus["ref"].dropna()))
keep_refs = set(refs[:args.max_bus_routes])
bus = bus[bus["ref"].isin(keep_refs)]
out = gpd.GeoDataFrame(pd_concat([other, bus]), crs=out.crs)
return out
def pd_concat(frames):
import pandas as pd
return pd.concat(frames, ignore_index=True)
def add_basemap(ax, styling, zoom=None):
if zoom is None:
zoom = styling["basemap"]["zoom"]
try:
import contextily as cx
provider = styling["basemap"]["provider"]
src = cx.providers
for part in provider.split("."):
src = getattr(src, part)
cx.add_basemap(ax, crs=TARGET_CRS, source=src, zoom=zoom,
attribution=False, zorder=0)
return True
except Exception as e:
print(f" [basemap] unavailable ({e}); using plain background", flush=True)
ax.set_facecolor("#f2f2f0")
return False
def shapeburst_mask(ax, area_3857, extent, styling, res=1600):
fade_m = styling["mask"]["fade_distance_m"]
max_alpha = styling["mask"]["max_alpha"]
color = styling["mask"]["color"]
x0, x1, y0, y1 = extent
nx = res
ny = max(1, int(round(res * (y1 - y0) / (x1 - x0))))
pxw = (x1 - x0) / nx
pxh = (y1 - y0) / ny
transform = Affine.translation(x0, y1) * Affine.scale(pxw, -pxh)
from rasterio.features import rasterize
geom = area_3857.geometry.union_all()
mask = rasterize([(geom, 1)], out_shape=(ny, nx), transform=transform,
fill=0, dtype=np.uint8, all_touched=False).astype(bool)
dist_px = distance_transform_edt(~mask)
dist_m = dist_px * pxw
alpha = np.where(mask, 0.0, np.clip(dist_m / fade_m, 0.0, 1.0)) * max_alpha
cmap = matplotlib.colors.to_rgba(color)
rgba = np.zeros((ny, nx, 4))
rgba[..., :3] = cmap[:3]
rgba[..., 3] = alpha
ax.imshow(rgba, extent=(x0, x1, y0, y1), origin="upper",
aspect="equal", interpolation="bilinear", zorder=0.5)
def plot_lines(ax, lines, styling):
zord = styling["zorder"]
widths = styling["line_width"]
alphas = styling["alpha"]
outline_w = styling["outline_width"]
outline_c = styling["outline_color"]
for style in sorted(zord, key=lambda k: zord[k]):
grp = lines[lines["style"] == style]
if grp.empty:
continue
w = widths.get(style, 1.0)
z = zord[style]
# dark casing
grp.plot(ax=ax, color=outline_c, linewidth=w + outline_w,
zorder=z, alpha=1.0, capstyle="round", joinstyle="round")
# colour body
body_colors = grp["colour_final"].tolist()
grp.plot(ax=ax, color=body_colors, linewidth=w,
zorder=z + 0.1, alpha=alphas.get(style, 1.0),
capstyle="round", joinstyle="round")
def label_stations(ax, stations, styling, area_3857):
if stations is None or stations.empty:
return
label_styles = set(styling["labels"]["styles"])
fmin = styling["labels"]["min_fontsize"]
fmax = styling["labels"]["max_fontsize"]
def is_metro(r):
return r.get("subway") == "yes" or r.get("station") == "subway"
def is_stog(r):
return r.get("light_rail") == "yes" or r.get("station") == "light_rail"
pts = []
for _, r in stations.iterrows():
is_m = is_metro(r)
is_s = is_stog(r)
if "metro" in label_styles and is_m:
pass
elif "s_tog" in label_styles and is_s:
pass
else:
continue
name = r.get("name")
if not isinstance(name, str) or not name.strip():
continue
pts.append((r.geometry.x, r.geometry.y, name, is_m))
if not pts:
return
# dedupe by name (keep first location)
seen = {}
uniq = []
for x, y, name, is_m in pts:
if name in seen:
continue
seen[name] = True
uniq.append((x, y, name, is_m))
texts = []
for x, y, name, is_m in uniq:
fs = fmax if is_m else fmin
bbox = dict(boxstyle="round,pad=0.15", fc="white", ec="none", alpha=0.7)
t = ax.text(x, y, name, fontsize=fs, color="#111111",
zorder=10, ha="center", va="center", bbox=bbox)
texts.append(t)
try:
adjust_text(texts, ax=ax, expand_points=(1.4, 1.6),
force_text=(0.4, 0.6), lim=300,
arrowprops=dict(arrowstyle="-", color="#888", lw=0.4))
except Exception as e:
print(f" [labels] adjustText failed ({e}); labels may overlap", flush=True)
def main():
args = load_args()
styling = yaml.safe_load((CONFIG / "styling.yaml").read_text())
area = gpd.read_file(PROCESSED / "area.gpkg").to_crs(TARGET_CRS)
lines = gpd.read_file(PROCESSED / "master.gpkg", layer="lines").to_crs(TARGET_CRS)
try:
stations = gpd.read_file(PROCESSED / "master.gpkg", layer="stations").to_crs(TARGET_CRS)
except Exception:
stations = None
lines = filter_lines(lines, args)
print(f"rendering {len(lines)} line features", flush=True)
bounds = area.total_bounds # (minx, miny, maxx, maxy)
dx = bounds[2] - bounds[0]
dy = bounds[3] - bounds[1]
aspect = dx / dy
pad = 0.12
extent = (bounds[0] - pad * dx, bounds[2] + pad * dx,
bounds[1] - pad * dy, bounds[3] + pad * dy)
fig_w, fig_h = parse_size(args.size, aspect)
fig, ax = plt.subplots(figsize=(fig_w, fig_h), dpi=args.dpi)
ax.set_xlim(extent[0], extent[1])
ax.set_ylim(extent[2], extent[3])
ax.set_aspect("equal")
ax.axis("off")
# basemap
if not args.no_basemap:
add_basemap(ax, styling)
# fade mask outside area
shapeburst_mask(ax, area, extent, styling)
# lines
plot_lines(ax, lines, styling)
# labels
if not args.no_labels and styling["labels"]["show"]:
label_stations(ax, stations, styling, area)
# title + attribution
ax.text(0.5, 0.985, "Copenhagen — Public Transit",
transform=ax.transAxes, ha="center", va="top",
fontsize=fig_w * 1.1, fontweight="bold", color="#222",
bbox=dict(boxstyle="round,pad=0.3", fc="white", ec="none", alpha=0.7))
ax.text(0.01, 0.01,
"Data: © OpenStreetMap contributors (ODbL) · Base: © CARTO",
transform=ax.transAxes, ha="left", va="bottom",
fontsize=max(5, fig_w * 0.5), color="#666")
out_dir = OUTPUT
out_dir.mkdir(parents=True, exist_ok=True)
if args.out:
out_path = Path(args.out)
if not out_path.is_absolute():
out_path = out_dir / out_path
else:
out_path = out_dir / f"copenhagen.{args.format}"
out_path.parent.mkdir(parents=True, exist_ok=True)
plt.savefig(out_path, dpi=args.dpi, bbox_inches="tight",
pad_inches=0.2, facecolor="white")
plt.close(fig)
print(f"wrote {out_path} ({args.format}, {fig_w}x{fig_h}in @ {args.dpi}dpi)")
if __name__ == "__main__":
main()