Make station labels opt-in; add station markers (symbols without text)

- labels: off by default (was on). Enable via --labels flag or
  styling.yaml labels.show: true. Previously --no-labels opted out.
- stations: new markers layer -- small white-filled circles with dark
  border at each station, sized by mode (metro 3.5pt, s_tog 3.0pt,
  regional 2.5pt). On by default; disable via --no-stops.
- markers respect --modes filter (only mark stations for drawn layers)
- styling.yaml: add stations section (shape/fill/edge/size/linewidth)
- classify_station() helper deduplicates the metro/s_tog/regional logic
- fix attribution to Esri (was CARTO)
This commit is contained in:
marvin
2026-09-13 22:37:50 +02:00
parent 0ec288bfd0
commit 154b9f9fb5
2 changed files with 87 additions and 21 deletions
+73 -20
View File
@@ -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.<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")
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")