Add bus whitelist, route exclusions, crow-fly shape filter, and KML export #2
@@ -8,7 +8,8 @@ fallback + exponential backoff) and writes:
|
|||||||
|
|
||||||
Lines: one MultiLineString per route relation (concatenation of member way
|
Lines: one MultiLineString per route relation (concatenation of member way
|
||||||
geometries), carrying ref/name/network/colour/operator/route/mode tags.
|
geometries), carrying ref/name/network/colour/operator/route/mode tags.
|
||||||
Stops: one Point per stop/platform node member, carrying ref/name/mode.
|
Stops: one Point per platform node member, carrying ref/name/mode/route_refs
|
||||||
|
(route refs of routes using this stop, for disambiguation).
|
||||||
"""
|
"""
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
@@ -28,7 +29,11 @@ MIRRORS = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
PLATFORM_ROLES = {"platform", "platform_entry", "platform_exit"}
|
PLATFORM_ROLES = {"platform", "platform_entry", "platform_exit"}
|
||||||
STOP_ROLES = {"stop", "stop_entry", "stop_exit", "platform", "platform_entry", "platform_exit"}
|
STOP_ROLES = {"stop", "stop_entry", "stop_exit", "stop_entry_only", "stop_exit_only"}
|
||||||
|
# bus stops: use platform nodes (where passengers wait)
|
||||||
|
# rail stops: use stop nodes (platforms are often areas, stop nodes are reliable points)
|
||||||
|
BUS_STOP_ROLES = PLATFORM_ROLES
|
||||||
|
RAIL_STOP_ROLES = STOP_ROLES | PLATFORM_ROLES
|
||||||
|
|
||||||
|
|
||||||
def build_query(mode_cfg, bbox):
|
def build_query(mode_cfg, bbox):
|
||||||
@@ -72,19 +77,25 @@ def overpass(ql):
|
|||||||
|
|
||||||
|
|
||||||
def parse(data, mode):
|
def parse(data, mode):
|
||||||
|
is_bus = mode == "bus"
|
||||||
|
stop_roles = BUS_STOP_ROLES if is_bus else RAIL_STOP_ROLES
|
||||||
|
|
||||||
nodes = {}
|
nodes = {}
|
||||||
for e in data["elements"]:
|
for e in data["elements"]:
|
||||||
if e["type"] == "node":
|
if e["type"] == "node":
|
||||||
nodes[e["id"]] = e
|
nodes[e["id"]] = e
|
||||||
|
|
||||||
lines, stops = [], []
|
lines = []
|
||||||
stop_seen = set()
|
# collect route_refs per stop node: {node_id: set(route_refs)}
|
||||||
|
node_routes = {}
|
||||||
for e in data["elements"]:
|
for e in data["elements"]:
|
||||||
if e["type"] != "relation" or "tags" not in e:
|
if e["type"] != "relation" or "tags" not in e:
|
||||||
continue
|
continue
|
||||||
tags = e["tags"]
|
tags = e["tags"]
|
||||||
if tags.get("route") is None:
|
if tags.get("route") is None:
|
||||||
continue
|
continue
|
||||||
|
route_ref = tags.get("ref")
|
||||||
|
# collect line geometries
|
||||||
segs = []
|
segs = []
|
||||||
for m in e["members"]:
|
for m in e["members"]:
|
||||||
if m["type"] == "way" and "geometry" in m and m.get("role") not in PLATFORM_ROLES:
|
if m["type"] == "way" and "geometry" in m and m.get("role") not in PLATFORM_ROLES:
|
||||||
@@ -96,7 +107,7 @@ def parse(data, mode):
|
|||||||
continue
|
continue
|
||||||
lines.append({
|
lines.append({
|
||||||
"mode": mode,
|
"mode": mode,
|
||||||
"ref": tags.get("ref"),
|
"ref": route_ref,
|
||||||
"name": tags.get("name"),
|
"name": tags.get("name"),
|
||||||
"network": tags.get("network"),
|
"network": tags.get("network"),
|
||||||
"colour": tags.get("colour"),
|
"colour": tags.get("colour"),
|
||||||
@@ -104,11 +115,22 @@ def parse(data, mode):
|
|||||||
"route": tags.get("route"),
|
"route": tags.get("route"),
|
||||||
"geometry": geom,
|
"geometry": geom,
|
||||||
})
|
})
|
||||||
|
# record route_ref on each stop member node
|
||||||
for m in e["members"]:
|
for m in e["members"]:
|
||||||
if m["type"] != "node" or m.get("role") not in STOP_ROLES:
|
if m["type"] != "node" or m.get("role") not in stop_roles:
|
||||||
continue
|
continue
|
||||||
nid = m["ref"]
|
nid = m["ref"]
|
||||||
if nid not in nodes or nid in stop_seen:
|
if nid not in nodes:
|
||||||
|
continue
|
||||||
|
node_routes.setdefault(nid, set())
|
||||||
|
if route_ref:
|
||||||
|
node_routes[nid].add(route_ref)
|
||||||
|
|
||||||
|
# build stops from collected nodes
|
||||||
|
stops = []
|
||||||
|
stop_seen = set()
|
||||||
|
for nid, routes in node_routes.items():
|
||||||
|
if nid in stop_seen:
|
||||||
continue
|
continue
|
||||||
nd = nodes[nid]
|
nd = nodes[nid]
|
||||||
if "lat" not in nd or "lon" not in nd:
|
if "lat" not in nd or "lon" not in nd:
|
||||||
@@ -121,6 +143,7 @@ def parse(data, mode):
|
|||||||
"ref": ntags.get("ref"),
|
"ref": ntags.get("ref"),
|
||||||
"public_transport": ntags.get("public_transport"),
|
"public_transport": ntags.get("public_transport"),
|
||||||
"railway": ntags.get("railway"),
|
"railway": ntags.get("railway"),
|
||||||
|
"route_refs": ";".join(sorted(routes)),
|
||||||
"geometry": Point(nd["lon"], nd["lat"]),
|
"geometry": Point(nd["lon"], nd["lat"]),
|
||||||
})
|
})
|
||||||
return lines, stops
|
return lines, stops
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ def main():
|
|||||||
if stops is not None and not stops.empty:
|
if stops is not None and not stops.empty:
|
||||||
stops = stops[~stops.geometry.isna() & stops.geometry.is_valid]
|
stops = stops[~stops.geometry.isna() & stops.geometry.is_valid]
|
||||||
stops_clipped = gpd.clip(stops, area_geom)
|
stops_clipped = gpd.clip(stops, area_geom)
|
||||||
keep = ["mode", "style", "name", "ref", "public_transport", "railway", "geometry"]
|
keep = ["mode", "style", "name", "ref", "public_transport", "railway", "route_refs", "geometry"]
|
||||||
for c in keep:
|
for c in keep:
|
||||||
if c not in stops_clipped.columns:
|
if c not in stops_clipped.columns:
|
||||||
stops_clipped[c] = None
|
stops_clipped[c] = None
|
||||||
|
|||||||
@@ -262,11 +262,70 @@ def rail_station_names(stations):
|
|||||||
return names
|
return names
|
||||||
|
|
||||||
|
|
||||||
|
def group_by_route_membership(rows, cluster_m):
|
||||||
|
"""Group stop rows by (name, shared route_ref) into distinct stops.
|
||||||
|
|
||||||
|
Two same-name stops that share at least one route_ref are the same stop.
|
||||||
|
Stops on disjoint routes are different stops. Within each group,
|
||||||
|
platform duplicates are merged by distance clustering.
|
||||||
|
Returns a list of (x, y) centroids.
|
||||||
|
"""
|
||||||
|
if rows is None or rows.empty:
|
||||||
|
return []
|
||||||
|
|
||||||
|
coords = [(r.geometry.x, r.geometry.y) for _, r in rows.iterrows()]
|
||||||
|
route_sets = []
|
||||||
|
for _, r in rows.iterrows():
|
||||||
|
rr = r.get("route_refs")
|
||||||
|
if isinstance(rr, str) and rr:
|
||||||
|
route_sets.append(set(rr.split(";")))
|
||||||
|
else:
|
||||||
|
route_sets.append(set())
|
||||||
|
|
||||||
|
n = len(coords)
|
||||||
|
# union-find: stops sharing a route are connected
|
||||||
|
parent = list(range(n))
|
||||||
|
|
||||||
|
def find(x):
|
||||||
|
while parent[x] != x:
|
||||||
|
parent[x] = parent[parent[x]]
|
||||||
|
x = parent[x]
|
||||||
|
return x
|
||||||
|
|
||||||
|
def union(a, b):
|
||||||
|
ra, rb = find(a), find(b)
|
||||||
|
if ra != rb:
|
||||||
|
parent[ra] = rb
|
||||||
|
|
||||||
|
for i in range(n):
|
||||||
|
for j in range(i + 1, n):
|
||||||
|
if route_sets[i] & route_sets[j]:
|
||||||
|
union(i, j)
|
||||||
|
|
||||||
|
# group indices by connected component
|
||||||
|
components = {}
|
||||||
|
for i in range(n):
|
||||||
|
root = find(i)
|
||||||
|
components.setdefault(root, []).append(i)
|
||||||
|
|
||||||
|
centroids = []
|
||||||
|
for indices in components.values():
|
||||||
|
comp_coords = [coords[i] for i in indices]
|
||||||
|
if len(comp_coords) == 1:
|
||||||
|
centroids.append(comp_coords[0])
|
||||||
|
else:
|
||||||
|
# stops sharing a route are the same stop; merge to centroid
|
||||||
|
import numpy as np
|
||||||
|
centroids.append(tuple(np.mean(comp_coords, axis=0)))
|
||||||
|
return centroids
|
||||||
|
|
||||||
|
|
||||||
def plot_bus_stops(ax, stops, styling, active_styles, rail_names=None):
|
def plot_bus_stops(ax, stops, styling, active_styles, rail_names=None):
|
||||||
"""Draw small markers for bus stops from the stops layer.
|
"""Draw small markers for bus stops from the stops layer.
|
||||||
|
|
||||||
Bus stops whose name matches a rail station are skipped — the rail
|
Bus stops whose name matches a rail station are skipped — the rail
|
||||||
station marker represents that stop.
|
station marker represents that stop. Stops are grouped by shared
|
||||||
|
route membership: same-name stops on disjoint routes are distinct.
|
||||||
"""
|
"""
|
||||||
if stops is None or stops.empty or "bus" not in active_styles:
|
if stops is None or stops.empty or "bus" not in active_styles:
|
||||||
return
|
return
|
||||||
@@ -292,19 +351,14 @@ def plot_bus_stops(ax, stops, styling, active_styles, rail_names=None):
|
|||||||
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
|
||||||
|
|
||||||
# cluster same-name bus stops within threshold (opposite sides of road)
|
# group by name, then by route membership; skip rail station names
|
||||||
# skip names that match a rail station (merged into station marker)
|
|
||||||
centroids = []
|
centroids = []
|
||||||
skipped = 0
|
skipped = 0
|
||||||
for name, grp in bus.groupby("name"):
|
for name, grp in bus.groupby("name"):
|
||||||
if name and name in rail_names:
|
if name and name in rail_names:
|
||||||
skipped += len(grp)
|
skipped += len(grp)
|
||||||
continue
|
continue
|
||||||
coords = [(r.geometry.x, r.geometry.y) for _, r in grp.iterrows()]
|
centroids.extend(group_by_route_membership(grp, cluster_m))
|
||||||
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],
|
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,
|
||||||
|
|||||||
Reference in New Issue
Block a user