diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a6690c8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +# Generated/regenerable data — keep directory structure via .gitkeep only +copenhagen/data/raw/** +copenhagen/data/processed/** +!copenhagen/data/raw/.gitkeep +!copenhagen/data/processed/.gitkeep + +# Generated map outputs (regenerable via render.py / export_google_mymaps.py) +copenhagen/output/*.png +copenhagen/output/*.svg +copenhagen/output/*.pdf +copenhagen/output/*.kml + +# Python +.venv/ +__pycache__/ +*.pyc + +# OS +.DS_Store diff --git a/copenhagen/config/area.json b/copenhagen/config/area.json new file mode 100644 index 0000000..1f05ace --- /dev/null +++ b/copenhagen/config/area.json @@ -0,0 +1,26 @@ +{ + "description": "City Pass area approximation: Copenhagen Kommune + Frederiksberg Kommune + Amager island (dissolved).", + "source": "OpenStreetMap API relation/{id}/full.json", + "relations": [ + {"name": "Kobenhavns Kommune", "id": 2192363}, + {"name": "Frederiksberg Kommune", "id": 2186660}, + {"name": "Amager", "id": 5175924} + ], + "waterways": [ + { + "name": "Kobenhavns Havn + Nordhavn", + "comment": "The harbour channel and Nordhavn basin belong to the City Pass zone in any practical sense: the metro tunnels under it (M1/M2/M4), harbour ferries 991/992 sail it, and the fade-out should not start mid-harbour. The kommune boundary relations exclude all water, so this polygon is patched in.", + "ring": [ + [12.532, 55.640], + [12.545, 55.666], + [12.565, 55.680], + [12.585, 55.715], + [12.612, 55.717], + [12.632, 55.685], + [12.632, 55.660], + [12.600, 55.636], + [12.560, 55.634] + ] + } + ] +} diff --git a/copenhagen/config/coastline.kml b/copenhagen/config/coastline.kml new file mode 100644 index 0000000..133cf61 --- /dev/null +++ b/copenhagen/config/coastline.kml @@ -0,0 +1,251 @@ + + + + Coastline (from Hide+Seek Copenhagen City Pass map) + + + normal + #line-01579B-3131-nodesc-normal + + + highlight + #line-01579B-3131-nodesc-highlight + + + + + + Coastline + + Coastline + #line-01579B-3131-nodesc + + 1 + + 12.5843171,55.7397957,0 + 12.5827293,55.7360025,0 + 12.5824718,55.7315324,0 + 12.5817422,55.731484,0 + 12.5813989,55.7304449,0 + 12.5821284,55.7290433,0 + 12.5866346,55.7285359,0 + 12.5878509,55.7274296,0 + 12.5895354,55.7273934,0 + 12.5903811,55.7271523,0 + 12.6023887,55.7256926,0 + 12.6158183,55.7206703,0 + 12.620303,55.7236674,0 + 12.6216977,55.7241629,0 + 12.6243907,55.7247369,0 + 12.6262897,55.7249967,0 + 12.6284247,55.7251719,0 + 12.6303022,55.7251779,0 + 12.6324909,55.7250873,0 + 12.6324695,55.7249665,0 + 12.637909,55.7230934,0 + 12.6386434,55.7218282,0 + 12.6177346,55.7122447,0 + 12.6173913,55.7123233,0 + 12.6116943,55.709694,0 + 12.6113267,55.7091432,0 + 12.6115091,55.7090676,0 + 12.6128441,55.7077879,0 + 12.612709,55.7073944,0 + 12.612709,55.7065058,0 + 12.61295,55.7056923,0 + 12.6132772,55.7049427,0 + 12.6128646,55.7039017,0 + 12.6146831,55.7036991,0 + 12.6153002,55.7030916,0 + 12.6146212,55.7020159,0 + 12.6161983,55.7016803,0 + 12.6164274,55.6978865,0 + 12.619534,55.6980419,0 + 12.6289389,55.6973909,0 + 12.6295397,55.6974605,0 + 12.6305757,55.6946383,0 + 12.6328654,55.6905452,0 + 12.6359244,55.6877876,0 + 12.6341066,55.6871202,0 + 12.6367352,55.6835607,0 + 12.6369351,55.6835927,0 + 12.6370405,55.6835331,0 + 12.6387568,55.6845909,0 + 12.6386816,55.6846722,0 + 12.6388157,55.6847159,0 + 12.6392421,55.6843356,0 + 12.6390959,55.684283,0 + 12.6388532,55.6844572,0 + 12.637179,55.6834458,0 + 12.6372853,55.6833646,0 + 12.6370415,55.6832048,0 + 12.6391502,55.6804415,0 + 12.6397198,55.6804556,0 + 12.6407945,55.6788023,0 + 12.6446818,55.6791733,0 + 12.6481294,55.6760777,0 + 12.6477002,55.6758901,0 + 12.6482367,55.6754545,0 + 12.6482152,55.6750794,0 + 12.6465406,55.6743208,0 + 12.6448562,55.6719189,0 + 12.6458325,55.6714712,0 + 12.6459398,55.6711989,0 + 12.6457537,55.6708966,0 + 12.6449598,55.6705669,0 + 12.6441852,55.6693045,0 + 12.6439062,55.6691804,0 + 12.6432464,55.6691078,0 + 12.6420252,55.6671961,0 + 12.6419078,55.6671067,0 + 12.6416769,55.6670523,0 + 12.639605,55.6655071,0 + 12.6396426,55.6652772,0 + 12.639571,55.6651256,0 + 12.6388107,55.6643574,0 + 12.6389868,55.6640928,0 + 12.6392577,55.6638084,0 + 12.6395473,55.6635557,0 + 12.6396974,55.6632324,0 + 12.6397753,55.6629964,0 + 12.6398423,55.6628557,0 + 12.6403183,55.6622263,0 + 12.6404072,55.6620556,0 + 12.6406298,55.6617788,0 + 12.6408639,55.661419,0 + 12.6409444,55.6613494,0 + 12.641049,55.6611951,0 + 12.641113,55.6611449,0 + 12.6415267,55.6606773,0 + 12.641626,55.6606054,0 + 12.6417306,55.6604957,0 + 12.641803,55.6604564,0 + 12.6422784,55.6599927,0 + 12.6423696,55.6599458,0 + 12.6427853,55.6595902,0 + 12.642856,55.6594981,0 + 12.6431243,55.659318,0 + 12.6436914,55.6588662,0 + 12.6438497,55.6587679,0 + 12.6444571,55.658322,0 + 12.6446935,55.658102,0 + 12.6448129,55.6580369,0 + 12.6450637,55.6578107,0 + 12.6452367,55.657732,0 + 12.6454375,55.6575592,0 + 12.6456441,55.6574465,0 + 12.6457118,55.657381,0 + 12.6459499,55.6572384,0 + 12.6464378,55.6568159,0 + 12.6469394,55.6565511,0 + 12.6473741,55.656235,0 + 12.6476763,55.6560435,0 + 12.6478747,55.6559391,0 + 12.6480178,55.6558298,0 + 12.6483299,55.6556358,0 + 12.6484803,55.6555661,0 + 12.648821,55.6554397,0 + 12.6492219,55.655188,0 + 12.649856,55.6547974,0 + 12.6500907,55.6547195,0 + 12.6504703,55.654637,0 + 12.6520371,55.6546291,0 + 12.6520451,55.6545564,0 + 12.6505229,55.6545288,0 + 12.6500079,55.6543775,0 + 12.6497504,55.6541565,0 + 12.6493642,55.6540082,0 + 12.6488855,55.6534695,0 + 12.6485422,55.6528339,0 + 12.6482847,55.6525433,0 + 12.6480272,55.652053,0 + 12.6481384,55.6512885,0 + 12.647838,55.6509979,0 + 12.6479453,55.6507679,0 + 12.6480847,55.6506529,0 + 12.6482242,55.6503804,0 + 12.6481906,55.649885,0 + 12.6482577,55.649549,0 + 12.6485414,55.6491014,0 + 12.6486309,55.6489064,0 + 12.6488052,55.6486401,0 + 12.6489045,55.6485477,0 + 12.6490386,55.648516,0 + 12.6491539,55.6484191,0 + 12.6502279,55.6483093,0 + 12.6504773,55.6482442,0 + 12.6506651,55.648161,0 + 12.6507885,55.6480263,0 + 12.6508232,55.6478915,0 + 12.6507293,55.6476554,0 + 12.6506341,55.6475652,0 + 12.650433,55.6474623,0 + 12.6502318,55.6474138,0 + 12.6499394,55.6474002,0 + 12.6485447,55.6475591,0 + 12.6483221,55.6473064,0 + 12.6480517,55.6467941,0 + 12.6479123,55.6463279,0 + 12.6478908,55.6459889,0 + 12.6481054,55.6455166,0 + 12.6484007,55.6451585,0 + 12.6485601,55.6447979,0 + 12.6488874,55.6444679,0 + 12.6500944,55.6444286,0 + 12.6505128,55.6443408,0 + 12.6507434,55.6441894,0 + 12.651591,55.6432419,0 + 12.6525459,55.6434296,0 + 12.6531499,55.6433718,0 + 12.6534396,55.643172,0 + 12.6549523,55.6415493,0 + 12.6548772,55.6413071,0 + 12.6533607,55.640507,0 + 12.6547246,55.6390978,0 + 12.6564626,55.6388799,0 + 12.6574282,55.6382682,0 + 12.6566614,55.6368416,0 + 12.6569832,55.6364147,0 + 12.657974,55.6358075,0 + 12.6578238,55.6355471,0 + 12.6568636,55.6353685,0 + 12.6570406,55.6350324,0 + 12.6568199,55.6344235,0 + 12.6570774,55.6340874,0 + 12.657882,55.6340419,0 + 12.6596845,55.6338481,0 + 12.661562,55.6338179,0 + 12.6637722,55.6339874,0 + 12.6649201,55.6340419,0 + 12.6671088,55.6340359,0 + 12.6694657,55.6338899,0 + 12.6714935,55.6336659,0 + 12.6735105,55.6333267,0 + 12.6766638,55.6325319,0 + 12.677919,55.6320049,0 + 12.6793996,55.6310904,0 + 12.6805918,55.6297217,0 + 12.6809995,55.6286073,0 + 12.6811927,55.6248094,0 + + + + + + \ No newline at end of file diff --git a/copenhagen/config/modes.yaml b/copenhagen/config/modes.yaml new file mode 100644 index 0000000..b076eca --- /dev/null +++ b/copenhagen/config/modes.yaml @@ -0,0 +1,56 @@ +# Mapping of map styles to GTFS agency / route_type filters. +# +# Used by prepare.py to classify GTFS routes (lines layer) and the routes +# serving each stop pole (stops/stations layers). Style keys are referenced +# by styling.yaml (palette, zorder, widths). +# +# `agencies` matches GTFS agency_name from agency.txt. +# `route_types` optionally restricts GTFS route_type values, including the +# extended codes used in this feed (109 = S-tog suburban rail, 700/715 = +# Movia bus service types). Without `route_types`, all of the agency's +# routes match (e.g. Movia including harbour ferries 991/992). +# +# ORDER MATTERS: the styles listed here are also the priority order when a +# pole is served by several modes (e.g. S-tog and regional trains share +# platforms at hub stations — the higher style wins). +# Hard exclusions: specific routes to drop entirely, keyed by (agency, +# short name) — the pairing rule used everywhere else in this feed. +# 083 (Snälltåget night train, Malmö-Stockholm): its in-area shapes are +# sparse crow-fly stubs that draw as straight cuts across the map; the +# long-distance train is not relevant for the city game, so skip it. +# Bus whitelist: only these buses are included — there are far more city +# bus routes than the game scale needs. Match on bus category (per +# styling.yaml bus_filters) or on specific refs. Remove this section to +# include all buses; with empty lists no buses are included. +bus_whitelist: + categories: [A] # all "A" city buses (1A, 2A, 4A, 6A, 9A, ...) + refs: ["21", "30", "31", "32", "33", "34", "35", "36", "37", "38", "39", "68", "77"] + +exclude: + - agency: Snälltåget AB + ref: "083" + +# South bound: drop every stop/station south of this latitude (inclusive), +# and clip transit lines at this boundary. Used to cut the map short of +# Skyttehøj (Amager Landevej) and everything south of it on Amager. +south_bound_lat: 55.628 + +modes: + metro: + agencies: [Metroselskabet] + s_tog: + agencies: [DSB S-tog] + route_types: [109, 2] + light_rail: + agencies: [Hovedstadens Letbane] + regional: + agencies: [DSB, Lokaltog A/S, Skånetrafiken, Snälltåget AB, DSB Vores Tog] + route_types: [2] + ferry: + # harbour buses 991/992 (route_type 4); their shapes cross the harbour + # water and are intentionally NOT clipped to the land area polygon + agencies: [Movia] + route_types: [4] + bus: + agencies: [Movia] + route_types: [3, 700, 715] diff --git a/copenhagen/config/styling.yaml b/copenhagen/config/styling.yaml new file mode 100644 index 0000000..b43c4a6 --- /dev/null +++ b/copenhagen/config/styling.yaml @@ -0,0 +1,99 @@ +# Styling for the Copenhagen transit map. Referenced by render.py and prepare.py. +# Colours keyed by `style` (from modes.yaml) then route ref. +# zorder is draw order bottom->top (higher drawn later / on top). + +zorder: + bus: 1 + ferry: 2 + s_tog: 3 + light_rail: 4 + regional: 5 + metro: 6 + +line_width: # body width in points (outline = body + outline_width) + bus: 0.8 + ferry: 1.4 + s_tog: 2.0 + light_rail: 2.0 + regional: 1.8 + metro: 2.6 + +outline_width: 1.2 # added to line_width for the dark casing +outline_color: "#2b2b2b" + +alpha: + bus: 0.45 + ferry: 1.0 + s_tog: 0.95 + light_rail: 0.95 + regional: 0.9 + metro: 1.0 + +# Line colours. The Rejseplanen GTFS feed leaves route_color empty for the +# Copenhagen operators, so in practice everything resolves to this palette. +# Metro / S-tog values mirror the official line colours; regional trains and +# the light rail have no operator colour, so the palette supplies one. +palette: + metro: + M1: "#008d41" + M2: "#ffc600" + M3: "#ff0a0a" + M4: "#009cd3" + s_tog: + A: "#00a4eb" + B: "#50ae30" + Bx: "#adce6d" + C: "#f68b1f" + E: "#7670b3" + F: "#fcc019" + H: "#e63511" + regional: + default: "#7a1b3e" + light_rail: + default: "#e89b3a" + bus: + A: "#d6264a" + C: "#16a085" + S: "#2a6fb5" + default: "#c8a80e" + ferry: + default: "#2aa8e0" # harbour-bus blue + +bus_filters: + # ref-based bus categorisation (regex applied to route ref) + categories: + A: "^\\d+A$" + C: "^\\d+C$" + S: "^\\d+S$" + regular: "^\\d+$" + +mask: + fade_distance_m: 2500 # shapeburst fade width outside the area + max_alpha: 0.85 + color: white + +basemap: + provider: Esri.WorldGrayCanvas + zoom: 14 + +stations: + show: true # draw station markers (symbols without text) + styles: [metro, s_tog, regional, bus] + # NOTE: stops/stations arrive pre-collapsed from prepare.py (name-keyed, + # one point per stop/station) — no clustering configuration lives here. + marker: + shape: circle # circle | square + fill: white + edge: "#2b2b2b" + size: # diameter in points + metro: 3.5 + s_tog: 3.0 + regional: 2.5 + bus: 1.5 + linewidth: 0.8 + +labels: + show: false # off by default; enable via --labels + styles: [metro, s_tog] # which station types to label + min_fontsize: 5 + max_fontsize: 9 diff --git a/copenhagen/data/processed/.gitkeep b/copenhagen/data/processed/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/copenhagen/data/raw/.gitkeep b/copenhagen/data/raw/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/copenhagen/scripts/_common.py b/copenhagen/scripts/_common.py new file mode 100644 index 0000000..6541fcf --- /dev/null +++ b/copenhagen/scripts/_common.py @@ -0,0 +1,13 @@ +"""Shared paths and constants for the Copenhagen map scripts.""" +from pathlib import Path + +HERE = Path(__file__).resolve().parent +CPH = HERE.parent +CONFIG = CPH / "config" +DATA = CPH / "data" +RAW = DATA / "raw" +GTFS_RAW = RAW / "gtfs" +PROCESSED = DATA / "processed" +OUTPUT = CPH / "output" + +UA = "jetlag-maps/0.1 (https://github.com/marvin/jetlag-maps)" diff --git a/copenhagen/scripts/build_area.py b/copenhagen/scripts/build_area.py new file mode 100644 index 0000000..3a54379 --- /dev/null +++ b/copenhagen/scripts/build_area.py @@ -0,0 +1,168 @@ +#!/usr/bin/env python3 +"""Build the City Pass area polygon from three OSM boundary relations. + +Fetches relation/{id}/full.json from the OSM API for each relation in +config/area.json, assembles member ways into rings (respecting outer/inner +roles), builds polygons, dissolves the union, and patches in any +config/area.json "waterways" polygons (the harbour is excluded from the +kommune boundaries but belongs to the City Pass zone in practice). + +Outputs (in data/processed): + area.geojson (EPSG:4326, human-readable + portable) + area.gpkg (EPSG:4326, for geopandas/scripts) +""" +import json +import sys +from pathlib import Path + +import geopandas as gpd +import requests +from shapely.geometry import MultiPolygon, Polygon +from shapely.ops import unary_union + +from _common import CONFIG, PROCESSED, UA + +OSM_API = "https://api.openstreetmap.org/api/0.6" + + +def fetch_relation(rel_id): + url = f"{OSM_API}/relation/{rel_id}/full.json" + r = requests.get(url, headers={"User-Agent": UA}, timeout=120) + r.raise_for_status() + return r.json()["elements"] + + +def stitch_rings(ways): + """Stitch a list of node-coordinate polylines into closed rings. + + `ways` is a list of lists of (lon, lat). Returns a list of closed rings + (each starting point == ending point). + """ + eps = 1e-9 + + def close(a, b): + return abs(a[0] - b[0]) < eps and abs(a[1] - b[1]) < eps + + pool = [w[:] for w in ways if w] + rings = [] + while pool: + cur = pool.pop(0) + changed = True + while changed and not close(cur[0], cur[-1]): + changed = False + for i, w in enumerate(pool): + if close(cur[-1], w[0]): + cur = cur + w[1:] + pool.pop(i) + changed = True + break + if close(cur[-1], w[-1]): + cur = cur + w[::-1][1:] + pool.pop(i) + changed = True + break + rings.append(cur) + return [r for r in rings if close(r[0], r[-1]) and len(r) >= 4] + + +def build_polygon(elements): + nodes = {} + ways = {} + relation = None + for e in elements: + t = e["type"] + if t == "node": + nodes[e["id"]] = (e["lon"], e["lat"]) + elif t == "way": + ways[e["id"]] = e["nodes"] + elif t == "relation": + relation = e + if relation is None: + raise ValueError("no relation in element set") + + outer, inner = [], [] + for m in relation["members"]: + if m["type"] != "way" or m["ref"] not in ways: + continue + seq = [nodes[n] for n in ways[m["ref"]] if n in nodes] + if len(seq) < 2: + continue + (inner if m.get("role") == "inner" else outer).append(seq) + + outer_rings = stitch_rings(outer) + inner_rings = stitch_rings(inner) + + polys = [] + for oring in outer_rings: + op = Polygon(oring) + if not op.is_valid: + op = op.buffer(0) + holes = [] + for iring in inner_rings: + ip = Polygon(iring) + if not ip.is_valid: + ip = ip.buffer(0) + if op.contains(ip.representative_point()): + holes.append(list(ip.exterior.coords)) + try: + polys.append(Polygon(oring, holes=holes)) + except Exception: + polys.append(Polygon(oring)) + if not polys: + raise ValueError("could not assemble any outer rings") + if len(polys) == 1: + return polys[0] + return MultiPolygon(polys) + + +def main(): + area_cfg = json.loads((CONFIG / "area.json").read_text()) + geoms = [] + for rel in area_cfg["relations"]: + rid = rel["id"] + print(f"fetching relation {rid} ({rel['name']})...", flush=True) + elements = fetch_relation(rid) + geom = build_polygon(elements) + if not geom.is_valid: + geom = geom.buffer(0) + geoms.append(geom) + print(f" -> {geom.geom_type}, area={geom.area:.5f} deg^2", flush=True) + + dissolved = unary_union(geoms) + if not dissolved.is_valid: + dissolved = dissolved.buffer(0) + + # patch waterways into the zone: kommune boundaries exclude water, but + # the harbour is practically part of the City Pass area (metro tunnels + # beneath it, harbour ferries sail it). See area.json -> waterways. + for w in area_cfg.get("waterways", []): + wp = Polygon(w["ring"]) + print(f"adding waterway: {w['name']}", flush=True) + dissolved = unary_union([dissolved, wp]) + + # Small outward buffer (100 m) to close boundary slivers: the three + # relation outlines don't abut perfectly, leaving metre-wide cracks that + # otherwise fragment lines clipped to the area (bridge nicks included). + dissolved = ( + gpd.GeoSeries([dissolved], crs="EPSG:4326") + .to_crs("EPSG:25832").buffer(100) + .to_crs("EPSG:4326").iloc[0] + ) + + PROCESSED.mkdir(parents=True, exist_ok=True) + gdf = gpd.GeoDataFrame( + {"name": ["City Pass area"]}, geometry=[dissolved], crs="EPSG:4326" + ) + gdf.to_file(PROCESSED / "area.geojson", driver="GeoJSON") + gdf.to_file(PROCESSED / "area.gpkg", driver="GPKG", layer="area") + bounds = dissolved.bounds + print( + f"dissolved: {dissolved.geom_type} | bounds " + f"({bounds[0]:.4f},{bounds[1]:.4f})-({bounds[2]:.4f},{bounds[3]:.4f})", + flush=True, + ) + print(f"wrote {PROCESSED/'area.geojson'} and {PROCESSED/'area.gpkg'}") + + +if __name__ == "__main__": + main() diff --git a/copenhagen/scripts/download_gtfs.py b/copenhagen/scripts/download_gtfs.py new file mode 100644 index 0000000..e8b3c0b --- /dev/null +++ b/copenhagen/scripts/download_gtfs.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python3 +"""Download the Rejseplanen GTFS feed (all of Denmark) and unzip into data/raw/gtfs/. + +Source: https://www.rejseplanen.info/labs — a static GTFS zip published by +Rejseplanen covering DSB, DSB S-tog, Metroselskabet, Movia, Lokaltog, +Hovedstadens Letbane, Skånetrafiken, etc. Nationwide feed; gtfs_to_geopackage.py +filters it down to the Copenhagen area. +""" +import urllib.request +import zipfile + +from _common import GTFS_RAW, UA + +GTFS_URL = "https://www.rejseplanen.info/labs/GTFS.zip" + + +def main(): + GTFS_RAW.mkdir(parents=True, exist_ok=True) + zip_path = GTFS_RAW / "feed.zip" + print(f"downloading {GTFS_URL} -> {zip_path} ...", flush=True) + req = urllib.request.Request(GTFS_URL, headers={"User-Agent": UA}) + with urllib.request.urlopen(req) as r, zip_path.open("wb") as f: + while chunk := r.read(1 << 20): + f.write(chunk) + with zipfile.ZipFile(zip_path) as z: + z.extractall(GTFS_RAW) + zip_path.unlink(missing_ok=True) + print(f"extracted GTFS txt files into {GTFS_RAW}") + + +if __name__ == "__main__": + main() diff --git a/copenhagen/scripts/export_google_mymaps.py b/copenhagen/scripts/export_google_mymaps.py new file mode 100644 index 0000000..3ce8c05 --- /dev/null +++ b/copenhagen/scripts/export_google_mymaps.py @@ -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