From 4b85e93e6c705d3bcaf6775e8fb48ce2f1671465 Mon Sep 17 00:00:00 2001 From: marvin Date: Sun, 13 Sep 2026 01:27:55 +0200 Subject: [PATCH] Copenhagen Phase 3: render.py -> PNG/SVG/PDF map - render.py: parameterized CLI (--modes, --no-buses, --bus-subset, --max-bus-routes, --dpi, --size, --format, --out, --no-basemap, --no-labels) - basemap: OSM standard tiles via contextily (EPSG:3857) - two-tone lines: dark casing + colour body per route, z-order bus->s_tog->light_rail->regional->metro - shapeburst fade mask outside City Pass area (distance-transform alpha) - station labels for metro + S-tog via adjustText - title + attribution - styling.yaml: switch basemap to OpenStreetMap.Mapnik (CartoDB now needs API key) - .gitignore: exclude generated outputs (regenerable via render.py) - verified: full map, --no-buses, --bus-subset, --modes, SVG all work --- .gitignore | 5 + copenhagen/config/styling.yaml | 2 +- copenhagen/scripts/render.py | 281 +++++++++++++++++++++++++++++++++ 3 files changed, 287 insertions(+), 1 deletion(-) create mode 100644 copenhagen/scripts/render.py diff --git a/.gitignore b/.gitignore index e23be07..7294790 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,11 @@ copenhagen/data/processed/** !copenhagen/data/raw/.gitkeep !copenhagen/data/processed/.gitkeep +# Generated map outputs (regenerable via render.py) +copenhagen/output/*.png +copenhagen/output/*.svg +copenhagen/output/*.pdf + # Python .venv/ __pycache__/ diff --git a/copenhagen/config/styling.yaml b/copenhagen/config/styling.yaml index 0f95db4..ea51f50 100644 --- a/copenhagen/config/styling.yaml +++ b/copenhagen/config/styling.yaml @@ -67,7 +67,7 @@ mask: color: white basemap: - provider: CartoDB.PositronNoLabels + provider: OpenStreetMap.Mapnik zoom: 14 labels: diff --git a/copenhagen/scripts/render.py b/copenhagen/scripts/render.py new file mode 100644 index 0000000..5ef3560 --- /dev/null +++ b/copenhagen/scripts/render.py @@ -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.)") + 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()