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