Add bus whitelist, route exclusions, crow-fly shape filter, and KML export #2
@@ -73,6 +73,7 @@ basemap:
|
|||||||
stations:
|
stations:
|
||||||
show: true # draw station markers (symbols without text)
|
show: true # draw station markers (symbols without text)
|
||||||
styles: [metro, s_tog, regional, bus]
|
styles: [metro, s_tog, regional, bus]
|
||||||
|
stop_cluster_m: 50 # merge same-name stops within this distance (opposite sides of road)
|
||||||
marker:
|
marker:
|
||||||
shape: circle # circle | square
|
shape: circle # circle | square
|
||||||
fill: white
|
fill: white
|
||||||
|
|||||||
@@ -169,6 +169,26 @@ def classify_station(r):
|
|||||||
return None
|
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):
|
def plot_stations(ax, stations, styling, active_styles):
|
||||||
if stations is None or stations.empty:
|
if stations is None or stations.empty:
|
||||||
return
|
return
|
||||||
@@ -176,6 +196,7 @@ def plot_stations(ax, stations, styling, active_styles):
|
|||||||
if not cfg.get("show", True):
|
if not cfg.get("show", True):
|
||||||
return
|
return
|
||||||
marker_styles = set(cfg.get("styles", ["metro", "s_tog", "regional"]))
|
marker_styles = set(cfg.get("styles", ["metro", "s_tog", "regional"]))
|
||||||
|
cluster_m = cfg.get("stop_cluster_m", 50)
|
||||||
mk = cfg.get("marker", {})
|
mk = cfg.get("marker", {})
|
||||||
sizes = mk.get("size", {})
|
sizes = mk.get("size", {})
|
||||||
shape = mk.get("shape", "circle")
|
shape = mk.get("shape", "circle")
|
||||||
@@ -184,19 +205,30 @@ def plot_stations(ax, stations, styling, active_styles):
|
|||||||
lw = mk.get("linewidth", 0.8)
|
lw = mk.get("linewidth", 0.8)
|
||||||
marker = "o" if shape == "circle" else "s"
|
marker = "o" if shape == "circle" else "s"
|
||||||
|
|
||||||
|
# group coordinates by style, then by name for clustering
|
||||||
pts_by_style = {}
|
pts_by_style = {}
|
||||||
|
names_by_style = {}
|
||||||
for _, r in stations.iterrows():
|
for _, r in stations.iterrows():
|
||||||
s = classify_station(r)
|
s = classify_station(r)
|
||||||
if s is None or s not in marker_styles or s not in active_styles:
|
if s is None or s not in marker_styles or s not in active_styles:
|
||||||
continue
|
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"]
|
zord = styling["zorder"]
|
||||||
top_z = max(zord.values()) + 1 # all markers above all lines
|
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)):
|
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)
|
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,
|
s=sz ** 2, marker=marker, c=fill, edgecolors=edge,
|
||||||
linewidths=lw, zorder=top_z, alpha=1.0)
|
linewidths=lw, zorder=top_z, alpha=1.0)
|
||||||
|
|
||||||
@@ -212,6 +244,7 @@ def plot_bus_stops(ax, stops, styling, active_styles):
|
|||||||
if bus.empty:
|
if bus.empty:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
cluster_m = cfg.get("stop_cluster_m", 50)
|
||||||
mk = cfg.get("marker", {})
|
mk = cfg.get("marker", {})
|
||||||
sizes = mk.get("size", {})
|
sizes = mk.get("size", {})
|
||||||
sz = sizes.get("bus", 1.5)
|
sz = sizes.get("bus", 1.5)
|
||||||
@@ -221,8 +254,19 @@ def plot_bus_stops(ax, stops, styling, active_styles):
|
|||||||
shape = mk.get("shape", "circle")
|
shape = mk.get("shape", "circle")
|
||||||
marker = "o" if shape == "circle" else "s"
|
marker = "o" if shape == "circle" else "s"
|
||||||
top_z = max(styling["zorder"].values()) + 1
|
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):
|
def label_stations(ax, stations, styling, active_styles):
|
||||||
|
|||||||
Reference in New Issue
Block a user