diff --git a/copenhagen/config/styling.yaml b/copenhagen/config/styling.yaml index 35e2372..1eaa982 100644 --- a/copenhagen/config/styling.yaml +++ b/copenhagen/config/styling.yaml @@ -70,8 +70,21 @@ basemap: provider: Esri.WorldGrayCanvas zoom: 14 +stations: + show: true # draw station markers (symbols without text) + styles: [metro, s_tog, regional] + marker: + shape: circle # circle | square + fill: white + edge: "#2b2b2b" + size: # diameter in points + metro: 3.5 + s_tog: 3.0 + regional: 2.5 + linewidth: 0.8 + labels: - show: true + show: false # off by default; enable via --labels styles: [metro, s_tog] # which station types to label min_fontsize: 5 max_fontsize: 9 diff --git a/copenhagen/scripts/render.py b/copenhagen/scripts/render.py index b18300f..7cfc050 100644 --- a/copenhagen/scripts/render.py +++ b/copenhagen/scripts/render.py @@ -10,7 +10,9 @@ config/styling.yaml, applies CLI filters, and composes a printable map: - station labels (metro + S-tog) via adjustText Usage: - uv run python render.py # default: all modes, PNG + uv run python render.py # default: all modes, PNG, markers only + uv run python render.py --labels # add station name labels + uv run python render.py --no-stops # no station markers 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 @@ -48,7 +50,8 @@ def load_args(): 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") + p.add_argument("--labels", action="store_true", help="draw station name labels (off by default)") + p.add_argument("--no-stops", action="store_true", help="skip station markers") return p.parse_args() @@ -155,33 +158,74 @@ def plot_lines(ax, lines, styling): capstyle="round", joinstyle="round") -def label_stations(ax, stations, styling, area_3857): +def classify_station(r): + """Return the style key for a station row, or None.""" + if r.get("subway") == "yes" or r.get("station") == "subway": + return "metro" + if r.get("light_rail") == "yes" or r.get("station") == "light_rail": + return "s_tog" + if r.get("railway") == "station" or r.get("train") == "yes": + return "regional" + return None + + +def plot_stations(ax, stations, styling, active_styles): if stations is None or stations.empty: return - label_styles = set(styling["labels"]["styles"]) - fmin = styling["labels"]["min_fontsize"] - fmax = styling["labels"]["max_fontsize"] + cfg = styling.get("stations", {}) + if not cfg.get("show", True): + return + marker_styles = set(cfg.get("styles", ["metro", "s_tog", "regional"])) + mk = cfg.get("marker", {}) + sizes = mk.get("size", {}) + shape = mk.get("shape", "circle") + fill = mk.get("fill", "white") + edge = mk.get("edge", "#2b2b2b") + lw = mk.get("linewidth", 0.8) + marker = "o" if shape == "circle" else "s" + + pts_by_style = {} + for _, r in stations.iterrows(): + s = classify_station(r) + if s is None or s not in marker_styles or s not in active_styles: + continue + pts_by_style.setdefault(s, []).append((r.geometry.x, r.geometry.y)) + + zord = styling["zorder"] + for s in sorted(pts_by_style, key=lambda k: zord.get(k, 0)): + pts = pts_by_style[s] + sz = sizes.get(s, 3.0) + z = zord.get(s, 5) + 0.5 + ax.scatter([p[0] for p in pts], [p[1] for p in pts], + s=sz ** 2, marker=marker, c=fill, edgecolors=edge, + linewidths=lw, zorder=z, alpha=1.0) + + +def label_stations(ax, stations, styling, active_styles): + if stations is None or stations.empty: + return + cfg = styling.get("labels", {}) + if not cfg.get("show", False): + return + label_styles = set(cfg.get("styles", ["metro", "s_tog"])) & active_styles + fmin = cfg.get("min_fontsize", 5) + fmax = cfg.get("max_fontsize", 9) def is_metro(r): - return r.get("subway") == "yes" or r.get("station") == "subway" + return classify_station(r) == "metro" def is_stog(r): - return r.get("light_rail") == "yes" or r.get("station") == "light_rail" + return classify_station(r) == "s_tog" 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: + s = classify_station(r) + if s not in label_styles: 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)) + pts.append((r.geometry.x, r.geometry.y, name, s == "metro")) if not pts: return @@ -248,9 +292,18 @@ def main(): # lines plot_lines(ax, lines, styling) - # labels - if not args.no_labels and styling["labels"]["show"]: - label_stations(ax, stations, styling, area) + # active styles (which mode layers are actually drawn) + active_styles = set(lines["style"].unique()) + + # station markers (symbols, no text) + if not args.no_stops: + plot_stations(ax, stations, styling, active_styles) + + # station labels (opt-in via --labels) + if args.labels: + styling["labels"]["show"] = True + if styling.get("labels", {}).get("show", False): + label_stations(ax, stations, styling, active_styles) # title + attribution ax.text(0.5, 0.985, "Copenhagen — Public Transit", @@ -258,7 +311,7 @@ def main(): 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", + "Data: © OpenStreetMap contributors (ODbL) · Base: Esri, HERE", transform=ax.transAxes, ha="left", va="bottom", fontsize=max(5, fig_w * 0.5), color="#666")