From eb3cf7b7d9bad1b8e00553b411bf3cc6fed0c4da Mon Sep 17 00:00:00 2001 From: marvin Date: Sun, 13 Sep 2026 23:15:29 +0200 Subject: [PATCH] Cluster same-name stops within 50m into single markers Same-name stops within 50m (opposite sides of a road, different platforms/entrances) are now merged into one marker at the cluster centroid. Uses scipy hierarchical clustering (single linkage, distance criterion). Stop counts after clustering: bus: 1610 -> 892 markers metro: 91 -> 46 markers s_tog: 76 -> 34 markers regional: 27 -> 10 markers Threshold configurable via styling.yaml stations.stop_cluster_m. --- copenhagen/config/styling.yaml | 1 + copenhagen/scripts/render.py | 54 ++++++++++++++++++++++++++++++---- 2 files changed, 50 insertions(+), 5 deletions(-) diff --git a/copenhagen/config/styling.yaml b/copenhagen/config/styling.yaml index b79a479..e318b5c 100644 --- a/copenhagen/config/styling.yaml +++ b/copenhagen/config/styling.yaml @@ -73,6 +73,7 @@ basemap: stations: show: true # draw station markers (symbols without text) styles: [metro, s_tog, regional, bus] + stop_cluster_m: 50 # merge same-name stops within this distance (opposite sides of road) marker: shape: circle # circle | square fill: white diff --git a/copenhagen/scripts/render.py b/copenhagen/scripts/render.py index 9292ccf..79dd3ff 100644 --- a/copenhagen/scripts/render.py +++ b/copenhagen/scripts/render.py @@ -169,6 +169,26 @@ def classify_station(r): return None +def cluster_stops(coords, threshold_m): + """Cluster (x, y) coordinates within threshold_m; return list of centroids.""" + from scipy.cluster.hierarchy import fcluster, linkage + from scipy.spatial.distance import pdist + + coords = np.asarray(coords) + if len(coords) <= 1: + return coords.tolist() if len(coords) else [] + + dists = pdist(coords) + links = linkage(dists, method="single") + labels = fcluster(links, t=threshold_m, criterion="distance") + + centroids = [] + for label in set(labels): + members = coords[labels == label] + centroids.append(members.mean(axis=0).tolist()) + return centroids + + def plot_stations(ax, stations, styling, active_styles): if stations is None or stations.empty: return @@ -176,6 +196,7 @@ def plot_stations(ax, stations, styling, active_styles): if not cfg.get("show", True): return marker_styles = set(cfg.get("styles", ["metro", "s_tog", "regional"])) + cluster_m = cfg.get("stop_cluster_m", 50) mk = cfg.get("marker", {}) sizes = mk.get("size", {}) shape = mk.get("shape", "circle") @@ -184,19 +205,30 @@ def plot_stations(ax, stations, styling, active_styles): lw = mk.get("linewidth", 0.8) marker = "o" if shape == "circle" else "s" + # group coordinates by style, then by name for clustering pts_by_style = {} + names_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)) + coord = (r.geometry.x, r.geometry.y) + name = r.get("name") if isinstance(r.get("name"), str) else None + pts_by_style.setdefault(s, []).append(coord) + names_by_style.setdefault(s, {}).setdefault(name, []).append(coord) zord = styling["zorder"] top_z = max(zord.values()) + 1 # all markers above all lines for s in sorted(pts_by_style, key=lambda k: zord.get(k, 0)): - pts = pts_by_style[s] + # cluster same-name stations to one marker; keep unnamed as-is + centroids = [] + for name, coords in names_by_style[s].items(): + if name and len(coords) > 1: + centroids.extend(cluster_stops(coords, cluster_m)) + else: + centroids.extend(coords) sz = sizes.get(s, 3.0) - ax.scatter([p[0] for p in pts], [p[1] for p in pts], + ax.scatter([p[0] for p in centroids], [p[1] for p in centroids], s=sz ** 2, marker=marker, c=fill, edgecolors=edge, linewidths=lw, zorder=top_z, alpha=1.0) @@ -212,6 +244,7 @@ def plot_bus_stops(ax, stops, styling, active_styles): if bus.empty: return + cluster_m = cfg.get("stop_cluster_m", 50) mk = cfg.get("marker", {}) sizes = mk.get("size", {}) sz = sizes.get("bus", 1.5) @@ -221,8 +254,19 @@ def plot_bus_stops(ax, stops, styling, active_styles): shape = mk.get("shape", "circle") marker = "o" if shape == "circle" else "s" top_z = max(styling["zorder"].values()) + 1 - ax.scatter(bus.geometry.x, bus.geometry.y, s=sz ** 2, marker=marker, - c=fill, edgecolors=edge, linewidths=lw, zorder=top_z, alpha=0.8) + + # cluster same-name bus stops within threshold (opposite sides of road) + centroids = [] + for name, grp in bus.groupby("name"): + coords = [(r.geometry.x, r.geometry.y) for _, r in grp.iterrows()] + if name and len(coords) > 1: + centroids.extend(cluster_stops(coords, cluster_m)) + else: + centroids.extend(coords) + + ax.scatter([p[0] for p in centroids], [p[1] for p in centroids], + s=sz ** 2, marker=marker, c=fill, edgecolors=edge, + linewidths=lw, zorder=top_z, alpha=0.8) def label_stations(ax, stations, styling, active_styles):