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
+14 -1
View File
@@ -70,8 +70,21 @@ basemap:
provider: Esri.WorldGrayCanvas provider: Esri.WorldGrayCanvas
zoom: 14 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: labels:
show: true show: false # off by default; enable via --labels
styles: [metro, s_tog] # which station types to label styles: [metro, s_tog] # which station types to label
min_fontsize: 5 min_fontsize: 5
max_fontsize: 9 max_fontsize: 9
+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 - station labels (metro + S-tog) via adjustText
Usage: 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 --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 --bus-subset A,C,S # only trunk buses
uv run python render.py --modes metro,s_tog # rail-only 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("--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("--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-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() return p.parse_args()
@@ -155,33 +158,74 @@ def plot_lines(ax, lines, styling):
capstyle="round", joinstyle="round") 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: if stations is None or stations.empty:
return return
label_styles = set(styling["labels"]["styles"]) cfg = styling.get("stations", {})
fmin = styling["labels"]["min_fontsize"] if not cfg.get("show", True):
fmax = styling["labels"]["max_fontsize"] 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): def is_metro(r):
return r.get("subway") == "yes" or r.get("station") == "subway" return classify_station(r) == "metro"
def is_stog(r): def is_stog(r):
return r.get("light_rail") == "yes" or r.get("station") == "light_rail" return classify_station(r) == "s_tog"
pts = [] pts = []
for _, r in stations.iterrows(): for _, r in stations.iterrows():
is_m = is_metro(r) s = classify_station(r)
is_s = is_stog(r) if s not in label_styles:
if "metro" in label_styles and is_m:
pass
elif "s_tog" in label_styles and is_s:
pass
else:
continue continue
name = r.get("name") name = r.get("name")
if not isinstance(name, str) or not name.strip(): if not isinstance(name, str) or not name.strip():
continue 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: if not pts:
return return
@@ -248,9 +292,18 @@ def main():
# lines # lines
plot_lines(ax, lines, styling) plot_lines(ax, lines, styling)
# labels # active styles (which mode layers are actually drawn)
if not args.no_labels and styling["labels"]["show"]: active_styles = set(lines["style"].unique())
label_stations(ax, stations, styling, area)
# 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 # title + attribution
ax.text(0.5, 0.985, "Copenhagen — Public Transit", ax.text(0.5, 0.985, "Copenhagen — Public Transit",
@@ -258,7 +311,7 @@ def main():
fontsize=fig_w * 1.1, fontweight="bold", color="#222", fontsize=fig_w * 1.1, fontweight="bold", color="#222",
bbox=dict(boxstyle="round,pad=0.3", fc="white", ec="none", alpha=0.7)) bbox=dict(boxstyle="round,pad=0.3", fc="white", ec="none", alpha=0.7))
ax.text(0.01, 0.01, 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", transform=ax.transAxes, ha="left", va="bottom",
fontsize=max(5, fig_w * 0.5), color="#666") fontsize=max(5, fig_w * 0.5), color="#666")