Add bus whitelist, route exclusions, crow-fly shape filter, and KML export
- modes.yaml: bus_whitelist (A-buses + selected refs) and exclude list (Snälltåget 083 night train — sparse crow-fly stubs) - prepare.py: drop degenerate crow-fly shapes below 0.05 pts/km; apply bus whitelist and route exclusions from modes.yaml - export_google_mymaps.py: new script — master.gpkg -> KML for Google My Maps import (stops, routes, boundary, coastline layers) - coastline.kml: reference coastline layer for the KML export - .gitignore: also ignore generated output/*.kml
This commit is contained in:
@@ -0,0 +1,257 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Export the master GeoPackage to a Google My Maps-importable KML.
|
||||
|
||||
Reads (from data/processed): master.gpkg (lines, stops, stations), area.gpkg
|
||||
Read (from config): coastline.kml — the coastline layer extracted verbatim
|
||||
from the reference map
|
||||
(https://www.google.com/maps/d/u/0/kml?mid=17T6ZDYbGz72h_eteL-CxdkiaXcgDLEA&forcekml=1)
|
||||
|
||||
Writes: output/copenhagen-mymaps.kml
|
||||
|
||||
My Maps turns each top-level KML Folder into a layer on import. We emit:
|
||||
|
||||
Bus stops / Bus routes
|
||||
Train stations / Train routes (metro + S-tog + light rail + regional)
|
||||
Ferry stops / Ferry routes
|
||||
City Pass boundary (outline + faint fill)
|
||||
Coastline (verbatim from the reference map's layer)
|
||||
|
||||
Colours match the PNG map: routes use colour_final from prepare.py; stop
|
||||
icons use styling.yaml palette defaults (train stations match the reference
|
||||
map's red pin). Import manually in Google My Maps:
|
||||
Create map → Import → upload the .kml.
|
||||
"""
|
||||
import re
|
||||
import sys
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
import geopandas as gpd
|
||||
import yaml
|
||||
|
||||
from _common import CONFIG, OUTPUT, PROCESSED
|
||||
|
||||
KML = "http://www.opengis.net/kml/2.2"
|
||||
ET.register_namespace("", KML)
|
||||
|
||||
# My Maps stock pin (same as the reference map uses); tinted via IconStyle.
|
||||
ICON_HREF = "https://www.gstatic.com/mapspro/images/stock/503-wht-blank_maps.png"
|
||||
ICON_SCALE = 1.0
|
||||
|
||||
TRAIN_STATION_COLOR = "#C2185B" # reference map's station-pin red
|
||||
TRAIN_STYLES = ["metro", "s_tog", "light_rail", "regional"]
|
||||
|
||||
# route line widths in pixels, per style
|
||||
LINE_WIDTH = {
|
||||
"metro": 4.5,
|
||||
"s_tog": 4.25,
|
||||
"light_rail": 4.25,
|
||||
"regional": 3.5,
|
||||
"ferry": 3.75,
|
||||
"bus": 2.75,
|
||||
}
|
||||
|
||||
BOUNDARY_COLOR = "#006064"
|
||||
BOUNDARY_LINE_WIDTH = 3.0
|
||||
BOUNDARY_FILL_ALPHA = 0x54 # ~33 %
|
||||
|
||||
|
||||
class Styles:
|
||||
"""Registry of unique (kind, color, width) -> KML <Style> ids."""
|
||||
|
||||
def __init__(self):
|
||||
self._map = {}
|
||||
|
||||
def get(self, kind, color_hex, width=0.0):
|
||||
key = (kind, color_hex, width)
|
||||
if key not in self._map:
|
||||
self._map[key] = f"s{len(self._map):03d}"
|
||||
return self._map[key]
|
||||
|
||||
def elements(self):
|
||||
"""Yield <Style> elements for everything registered so far."""
|
||||
by_id = sorted(self._map.items(), key=lambda kv: kv[1])
|
||||
for (kind, color_hex, width), sid in by_id:
|
||||
st = ET.Element(f"{{{KML}}}Style", id=sid)
|
||||
if kind == "icon":
|
||||
el = ET.SubElement(st, f"{{{KML}}}IconStyle")
|
||||
el.append(_tex("color", kml_color(color_hex)))
|
||||
el.append(_tex("scale", str(ICON_SCALE)))
|
||||
icon = ET.SubElement(el, f"{{{KML}}}Icon")
|
||||
icon.append(_tex("href", ICON_HREF))
|
||||
else:
|
||||
el = ET.SubElement(st, f"{{{KML}}}LineStyle")
|
||||
el.append(_tex("color", kml_color(color_hex)))
|
||||
el.append(_tex("width", str(width)))
|
||||
if kind == "poly":
|
||||
poly = ET.SubElement(st, f"{{{KML}}}PolyStyle")
|
||||
poly.append(_tex("color", kml_color(color_hex, BOUNDARY_FILL_ALPHA)))
|
||||
balloon = ET.SubElement(st, f"{{{KML}}}BalloonStyle")
|
||||
balloon.append(_tex("text", "<h3>$[name]</h3>"))
|
||||
yield st
|
||||
|
||||
|
||||
def _tex(tag, value):
|
||||
el = ET.Element(f"{{{KML}}}{tag}")
|
||||
el.text = value
|
||||
return el
|
||||
|
||||
|
||||
def kml_color(hex_color, alpha=0xFF):
|
||||
"""#RRGGBB -> KML AABBGGRR."""
|
||||
h = hex_color.lstrip("#")
|
||||
return f"{alpha:02x}{h[4:6]}{h[2:4]}{h[0:2]}"
|
||||
|
||||
|
||||
def placemark(folder_el, name, sid, geom_el):
|
||||
pm = ET.SubElement(folder_el, f"{{{KML}}}Placemark")
|
||||
pm.append(_tex("name", str(name)))
|
||||
pm.append(_tex("styleUrl", f"#{sid}"))
|
||||
pm.append(geom_el)
|
||||
|
||||
|
||||
def point_el(geom):
|
||||
pt = ET.Element(f"{{{KML}}}Point")
|
||||
pt.append(_tex("coordinates", f"{geom.x:.7f},{geom.y:.7f}"))
|
||||
return pt
|
||||
|
||||
|
||||
def linestring_el(geom):
|
||||
ls = ET.Element(f"{{{KML}}}LineString")
|
||||
ls.append(_tex("tessellate", "1"))
|
||||
ls.append(_tex("coordinates", " ".join(f"{x:.7f},{y:.7f}" for x, y in geom.coords)))
|
||||
return ls
|
||||
|
||||
|
||||
def polygon_el(geom):
|
||||
pg = ET.Element(f"{{{KML}}}Polygon")
|
||||
pg.append(_tex("tessellate", "1"))
|
||||
outer = ET.SubElement(pg, f"{{{KML}}}outerBoundaryIs")
|
||||
ring = ET.SubElement(outer, f"{{{KML}}}LinearRing")
|
||||
ring.append(_tex("coordinates", " ".join(f"{x:.7f},{y:.7f}" for x, y in geom.exterior.coords)))
|
||||
for inner in geom.interiors:
|
||||
b = ET.SubElement(pg, f"{{{KML}}}innerBoundaryIs")
|
||||
ring = ET.SubElement(b, f"{{{KML}}}LinearRing")
|
||||
ring.append(_tex("coordinates", " ".join(f"{x:.7f},{y:.7f}" for x, y in inner.coords)))
|
||||
return pg
|
||||
|
||||
|
||||
def multi_el(geom, single):
|
||||
"""Wrap (Multi)Geometry into a KML element (MultiGeometry if needed)."""
|
||||
geoms = list(getattr(geom, "geoms", [geom]))
|
||||
if len(geoms) == 1:
|
||||
return single(geoms[0])
|
||||
mg = ET.Element(f"{{{KML}}}MultiGeometry")
|
||||
for g in geoms:
|
||||
mg.append(single(g))
|
||||
return mg
|
||||
|
||||
|
||||
def add_folder(document, name):
|
||||
f = ET.SubElement(document, f"{{{KML}}}Folder")
|
||||
f.append(_tex("name", name))
|
||||
return f
|
||||
|
||||
|
||||
def add_stops_folder(document, name, gdf, tint, styles):
|
||||
f = add_folder(document, name)
|
||||
sid = styles.get("icon", tint)
|
||||
for _, row in gdf.sort_values("name").iterrows():
|
||||
placemark(f, row["name"], sid, point_el(row.geometry))
|
||||
return len(gdf)
|
||||
|
||||
|
||||
def add_routes_folder(document, name, gdf, styles):
|
||||
f = add_folder(document, name)
|
||||
for _, row in gdf.iterrows():
|
||||
sid = styles.get("line", row["colour_final"], LINE_WIDTH[row["style"]])
|
||||
placemark(f, f'{row["ref"]} {row["name"]}', sid, multi_el(row.geometry, linestring_el))
|
||||
return len(gdf)
|
||||
|
||||
|
||||
def add_boundary_folder(document, area_geom, styles):
|
||||
f = add_folder(document, "City Pass boundary")
|
||||
sid = styles.get("poly", BOUNDARY_COLOR, BOUNDARY_LINE_WIDTH)
|
||||
placemark(f, "City Pass boundary", sid, multi_el(area_geom, polygon_el))
|
||||
return 1
|
||||
|
||||
|
||||
def append_coastline(document):
|
||||
"""Append the verbatim coastline layer stored in config/coastline.kml."""
|
||||
src = ET.parse(CONFIG / "coastline.kml").getroot().find(f"{{{KML}}}Document")
|
||||
n = 0
|
||||
for el in src:
|
||||
if el.tag == f"{{{KML}}}Folder":
|
||||
document.append(el)
|
||||
n = len(el.findall(f"{{{KML}}}Placemark"))
|
||||
elif el.tag in (f"{{{KML}}}Style", f"{{{KML}}}StyleMap"):
|
||||
document.append(el)
|
||||
return n
|
||||
|
||||
|
||||
def natural_key(ref):
|
||||
return tuple(int(t) if t.isdigit() else t for t in re.split(r"(\d+)", str(ref)))
|
||||
|
||||
|
||||
def main():
|
||||
lines = gpd.read_file(PROCESSED / "master.gpkg", layer="lines")
|
||||
stops = gpd.read_file(PROCESSED / "master.gpkg", layer="stops")
|
||||
stations = gpd.read_file(PROCESSED / "master.gpkg", layer="stations")
|
||||
area = gpd.read_file(PROCESSED / "area.gpkg").geometry.union_all()
|
||||
styling = yaml.safe_load((CONFIG / "styling.yaml").read_text())
|
||||
palette = styling["palette"]
|
||||
|
||||
kml = ET.Element(f"{{{KML}}}kml")
|
||||
document = ET.SubElement(kml, f"{{{KML}}}Document")
|
||||
document.append(_tex("name", "Kurragömma Köpenhamn"))
|
||||
|
||||
styles = Styles()
|
||||
counts = {}
|
||||
|
||||
counts["Bus stops"] = add_stops_folder(
|
||||
document, "Bus stops", stops[stops["style"] == "bus"],
|
||||
palette["bus"]["default"], styles,
|
||||
)
|
||||
bus_lines = lines[lines["style"] == "bus"].copy()
|
||||
bus_lines["_k"] = bus_lines["ref"].map(natural_key)
|
||||
counts["Bus routes"] = add_routes_folder(
|
||||
document, "Bus routes", bus_lines.sort_values("_k"), styles
|
||||
)
|
||||
|
||||
counts["Train stations"] = add_stops_folder(
|
||||
document, "Train stations", stations, TRAIN_STATION_COLOR, styles
|
||||
)
|
||||
train = lines[lines["style"].isin(TRAIN_STYLES)].copy()
|
||||
train["_order"] = train["style"].map(TRAIN_STYLES.index)
|
||||
train["_k"] = train["ref"].map(natural_key)
|
||||
counts["Train routes"] = add_routes_folder(
|
||||
document, "Train routes", train.sort_values(["_order", "_k"]), styles
|
||||
)
|
||||
|
||||
counts["Ferry stops"] = add_stops_folder(
|
||||
document, "Ferry stops", stops[stops["style"] == "ferry"],
|
||||
palette["ferry"]["default"], styles,
|
||||
)
|
||||
ferry_lines = lines[lines["style"] == "ferry"].sort_values("ref")
|
||||
counts["Ferry routes"] = add_routes_folder(document, "Ferry routes", ferry_lines, styles)
|
||||
|
||||
counts["City Pass boundary"] = add_boundary_folder(document, area, styles)
|
||||
counts["Coastline"] = append_coastline(document)
|
||||
|
||||
# <Style> elements belong before the first <Folder> (KML resolves style
|
||||
# ids regardless of order; this is conventional + nicer on the eyes).
|
||||
insert_at = list(document).index(document.find(f"{{{KML}}}name")) + 1
|
||||
for offset, st in enumerate(styles.elements()):
|
||||
document.insert(insert_at + offset, st)
|
||||
|
||||
out = OUTPUT / "copenhagen-mymaps.kml"
|
||||
tree = ET.ElementTree(kml)
|
||||
ET.indent(tree, space=" ")
|
||||
tree.write(out, encoding="utf-8", xml_declaration=True)
|
||||
|
||||
print(f"wrote {out}")
|
||||
for n, c in counts.items():
|
||||
print(f" {n}: {c} placemarks")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -39,10 +39,38 @@ STATION_STYLES = {"metro", "s_tog", "light_rail", "regional"}
|
||||
# within this distance. Routes have variants; without this check a stop can
|
||||
# end up further from the map than the variant we chose not to draw.
|
||||
STOP_LINE_MARGIN_M = 300
|
||||
# Shapes sparser than this (points per km) are "crow-fly" placeholders from
|
||||
# the feed (a handful of points for a several-hundred-km line) — they render
|
||||
# as straight cuts across the map. Real rail/bus geometry is >= 0.5 pts/km.
|
||||
MIN_SHAPE_POINTS_PER_KM = 0.05
|
||||
|
||||
|
||||
def load_modes():
|
||||
return yaml.safe_load((CONFIG / "modes.yaml").read_text())["modes"]
|
||||
return _load_modes_yaml().get("modes", {})
|
||||
|
||||
|
||||
def load_exclude():
|
||||
"""Hard route blacklist from modes.yaml: list of (agency, ref)."""
|
||||
return [(e["agency"], str(e["ref"]))
|
||||
for e in _load_modes_yaml().get("exclude", [])]
|
||||
|
||||
|
||||
def load_bus_whitelist():
|
||||
"""Bus whitelist from modes.yaml: dict with categories/refs sets.
|
||||
|
||||
Returns None when the section is absent (= include all buses).
|
||||
"""
|
||||
wl = _load_modes_yaml().get("bus_whitelist")
|
||||
if wl is None:
|
||||
return None
|
||||
return {
|
||||
"categories": set(wl.get("categories") or []),
|
||||
"refs": {str(r) for r in (wl.get("refs") or [])},
|
||||
}
|
||||
|
||||
|
||||
def _load_modes_yaml():
|
||||
return yaml.safe_load((CONFIG / "modes.yaml").read_text())
|
||||
|
||||
|
||||
def load_styling():
|
||||
@@ -66,7 +94,7 @@ def classify(agency, route_type, modes):
|
||||
return None
|
||||
|
||||
|
||||
def load_routes(modes):
|
||||
def load_routes(modes, exclude=()):
|
||||
"""route_colors.csv joined with agency names and classified by style."""
|
||||
agencies = pd.read_csv(GTFS_RAW / "agency.txt", dtype=str)
|
||||
agency_names = dict(zip(agencies["agency_id"], agencies["agency_name"]))
|
||||
@@ -77,6 +105,17 @@ def load_routes(modes):
|
||||
classify(a, t, modes)
|
||||
for a, t in zip(routes["agency_name"], routes["route_type"])
|
||||
]
|
||||
if exclude:
|
||||
mask = [
|
||||
(a, str(r)) in set(exclude)
|
||||
for a, r in zip(routes["agency_name"], routes["route_short_name"])
|
||||
]
|
||||
n = sum(mask)
|
||||
if n:
|
||||
routes.loc[mask, "style"] = None
|
||||
print(f"excluded by modes.yaml: {n} route(s) "
|
||||
f"({sorted(set(zip(routes.loc[mask, 'agency_name'], routes.loc[mask, 'route_short_name'])) )})",
|
||||
flush=True)
|
||||
return routes
|
||||
|
||||
|
||||
@@ -99,6 +138,15 @@ def resolve_colour(style, ref, bus_category, gtfs_colour, palette):
|
||||
return p.get(ref) or p.get("default")
|
||||
|
||||
|
||||
def shape_points_per_km(shapes_25832):
|
||||
"""Point density per shape. Geometries are projected (metres)."""
|
||||
def n_pts(geom):
|
||||
geoms = getattr(geom, "geoms", [geom])
|
||||
return sum(len(g.coords) for g in geoms)
|
||||
pts = shapes_25832.geometry.map(n_pts)
|
||||
return pts / (shapes_25832.geometry.length / 1000.0).clip(lower=1e-6)
|
||||
|
||||
|
||||
def build_lines(routes, styling, area_geom, trips, st, area_stop_ids):
|
||||
"""One feature per (style, ref, direction).
|
||||
|
||||
@@ -108,6 +156,20 @@ def build_lines(routes, styling, area_geom, trips, st, area_stop_ids):
|
||||
"""
|
||||
shapes = gpd.read_file(PROCESSED / "gtfs_shapes.gpkg")
|
||||
|
||||
# The feed contains a few "crow-fly" placeholder shapes for long-distance
|
||||
# trains (e.g. Snälltåget, 6-8 points for ~700 km). Those draw as
|
||||
# straight lines across the map and can shadow the proper rail-geometry
|
||||
# shape in coverage comparison. Drop below a point-density floor;
|
||||
# real shapes are >= 0.5 pts/km, placeholders are ~0.01 pts/km, and even
|
||||
# the 0.44 km ferry 993 (a handful of points over 440 m) stays well above.
|
||||
shapes = shapes.to_crs(LENGTH_CRS)
|
||||
pts_km = shape_points_per_km(shapes)
|
||||
degenerate = pts_km < MIN_SHAPE_POINTS_PER_KM
|
||||
if degenerate.any():
|
||||
print(f"dropping {int(degenerate.sum())} degenerate (crow-fly) "
|
||||
f"shapes (<{MIN_SHAPE_POINTS_PER_KM:g} pts/km)", flush=True)
|
||||
shapes = shapes[~degenerate].to_crs("EPSG:4326")
|
||||
|
||||
shape_rows = trips.dropna(subset=["shape_id"]).drop_duplicates("shape_id")
|
||||
shape_direction = dict(zip(shape_rows["shape_id"], shape_rows["direction_id"]))
|
||||
|
||||
@@ -269,7 +331,26 @@ def main():
|
||||
styling = load_styling()
|
||||
area_geom = load_area()
|
||||
|
||||
routes = load_routes(modes)
|
||||
routes = load_routes(modes, load_exclude())
|
||||
|
||||
bus_wl = load_bus_whitelist()
|
||||
if bus_wl is not None:
|
||||
patterns = styling["bus_filters"]["categories"]
|
||||
is_bus = routes["style"] == "bus"
|
||||
keep = routes["route_short_name"].map(
|
||||
lambda r: str(r) in bus_wl["refs"]
|
||||
or categorise_bus(r, patterns) in bus_wl["categories"]
|
||||
)
|
||||
routes.loc[is_bus & ~keep, "style"] = None
|
||||
kept = sorted(
|
||||
routes.loc[is_bus & keep, "route_short_name"].unique(),
|
||||
key=lambda s: [int(t) if t.isdigit() else t
|
||||
for t in re.split(r"(\d+)", str(s))],
|
||||
)
|
||||
print(f"bus whitelist: kept {len(kept)} of "
|
||||
f"{int(is_bus.sum())} bus refs: {', '.join(kept)}",
|
||||
flush=True)
|
||||
|
||||
print(f"routes classified: {routes['style'].notna().sum()} of "
|
||||
f"{len(routes)} map to a style", flush=True)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user