16 Commits
Author SHA1 Message Date
marvin 07dc0b55a0 Mask out Skyttehøj and everything south of it (lat 55.628)
Add south_bound_lat to modes.yaml and a load_south_bound() helper in
prepare.py. When set, the area geometry is intersected with the north
half-plane above the cutoff, so all downstream clipping (lines, stops,
stations) cascades automatically.

Removed: 94 bus stops (including Skyttehøj), 1 station (Vestamager),
and line segments south of the boundary (Metro M1 tail, buses 32-36).
2026-09-18 00:32:02 +02:00
marvin b27421aaa1 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
2026-09-18 00:24:37 +02:00
marvin b7d51ca29f Source Copenhagen transit data from Rejseplanen GTFS
- Replace OSM Overpass transit data with the Rejseplanen GTFS feed
  (routes keyed by (agency, short name); styles from modes.yaml)
- Draw one shape per (style, ref, direction), choosing the shape that
  serves the most in-area stops so lines pass the stops we show
- Collapse stops by names (verified unambiguous); prune stops whose
  serving refs have no drawn line within 300 m
- Patch Københavns Havn + Nordhavn into the area polygon so ferry
  routes and sub-harbour metro tunnels survive clipping; 100 m buffer
  closes relation boundary slivers
- Drop OSM download pipeline; keep tiled basemap
2026-09-17 21:07:40 +02:00
marvin 4aca62ca1a Add orphan bus stops (platforms not in route relations)
Some bus stop platform nodes in OSM are not members of any route
relation (e.g. Borgergade). These were missed by the route-membership
download approach. Now download_osm.py:
- tracks bus platform node ids captured by route queries
- runs a supplementary query for all highway=bus_stop + platform nodes
- appends orphans (with empty route_refs) to the bus stops layer

render.py: orphan stops (empty route_refs) fall back to distance
clustering instead of route-membership grouping.

Results: 1164 route-member stops + 707 orphans = 1871 total
-> 1370 after clip -> 1049 markers after grouping
2026-09-14 00:22:15 +02:00
marvin 54708e6cb3 Use platform nodes + route membership for bus stop clustering
Replaces distance-based clustering with route-membership grouping:
- download_osm.py: collect route_refs per stop node; use platform roles
  for bus stops, stop roles for rail stops
- prepare.py: carry route_refs through to master.gpkg
- render.py: group_by_route_membership() uses union-find to cluster
  same-name stops sharing at least one route into one marker; stops on
  disjoint routes are kept separate. No distance threshold needed.

Results: 786 bus platforms -> 465 markers (was 1610 -> 781).
Veksøvej: 2 platforms sharing routes 2A+5C -> 1 marker.
Peter Bangs Vej: 7 platforms on routes 10/21/22/4A/7A -> 2 markers.
2026-09-14 00:00:28 +02:00
marvin 97a065e667 Fix Web Mercator distance distortion in stop clustering
Clustering was computing distances in EPSG:3857 (Web Mercator), which
inflates distances by ~78% at Copenhagen's latitude (55.7°N). This made
the 100m threshold actually merge stops within only 56m, and all reported
distances were inflated.

Now reprojects to EPSG:25832 (UTM 32N) for accurate meter distances,
clusters there, converts centroids back to EPSG:3857 for plotting.
2026-09-13 23:53:51 +02:00
marvin 8b2433ae93 Merge bus stops into rail stations when names match
Bus stops sharing a name with a rail station (metro/s_tog/regional) are
now skipped from bus stop markers — the rail station marker represents
that stop. 32 bus stops across 9 names (Fasanvej, Frederiksberg Allé,
Jyllingevej, Mozarts Plads, Peter Bangs Vej, Ryparken, Rådhuspladsen,
Sluseholmen, Vigerslev Allé) are absorbed.
2026-09-13 23:26:19 +02:00
marvin 8ec2eb8d9c Per-mode stop clustering thresholds
Rail stations get generous thresholds to merge all platform/entrance
duplicates; bus stays conservative:
  metro: 200m    -> 91 stops -> 44 markers (0 duplicates)
  s_tog: 400m   -> 76 stops -> 30 markers (0 duplicates)
  regional: 500m -> 27 stops ->  9 markers (0 duplicates)
  bus: 100m      -> 1610 stops -> 781 markers

stop_cluster_m is now a dict keyed by mode in styling.yaml.
2026-09-13 23:23:14 +02:00
marvin eb3cf7b7d9 Cluster same-name stops within 50m into single markers
Same-name stops within 50m (opposite sides of a road, different
platforms/entrances) are now merged into one marker at the cluster
centroid. Uses scipy hierarchical clustering (single linkage, distance
criterion).

Stop counts after clustering:
  bus:  1610 -> 892 markers
  metro:   91 ->  46 markers
  s_tog:   76 ->  34 markers
  regional: 27 ->  10 markers

Threshold configurable via styling.yaml stations.stop_cluster_m.
2026-09-13 23:15:29 +02:00
marvin e12b13cd8d Fix stop markers rendering below lines
All station and bus stop markers now use zorder = max(line zorders) + 1,
ensuring they always render on top of every transit line layer.
2026-09-13 22:50:41 +02:00
marvin a81b52a456 Add bus stop markers from stops layer
- plot_bus_stops(): loads bus stops from master.gpkg stops layer (1610
  points), draws small white-filled circles (1.5pt) at each location
- stops layer loaded in main() alongside stations
- 'bus' added to stations.styles and marker.size in styling.yaml
- bus stops respect --no-stops and --no-buses / --modes filters
- bus marker zorder sits just above bus lines
2026-09-13 22:42:05 +02:00
marvin 7dc9a15932 Make station labels opt-in; add station markers (symbols without text)
- labels: off by default (was on). Enable via --labels flag or
  styling.yaml labels.show: true. Previously --no-labels opted out.
- stations: new markers layer -- small white-filled circles with dark
  border at each station, sized by mode (metro 3.5pt, s_tog 3.0pt,
  regional 2.5pt). On by default; disable via --no-stops.
- markers respect --modes filter (only mark stations for drawn layers)
- styling.yaml: add stations section (shape/fill/edge/size/linewidth)
- classify_station() helper deduplicates the metro/s_tog/regional logic
- fix attribution to Esri (was CARTO)
2026-09-13 22:37:50 +02:00
marvin 169edee227 Switch basemap to ESRI WorldGrayCanvas (OSM tiles blocked)
OSM's tile server blocks apps without a valid identifying User-Agent
(contextily's default is a random UUID, which OSM rejects with an
'Access blocked' error tile). Switched to ESRI WorldGrayCanvas, a free
no-key light-gray basemap comparable to CartoDB Positron, and set a
proper User-Agent header (jetlag-maps/0.1) on contextily requests per
OSM tile usage policy.
2026-09-13 22:32:16 +02:00
marvin d38c280a4e Copenhagen Phase 3: render.py -> PNG/SVG/PDF map
- render.py: parameterized CLI (--modes, --no-buses, --bus-subset,
  --max-bus-routes, --dpi, --size, --format, --out, --no-basemap, --no-labels)
- basemap: OSM standard tiles via contextily (EPSG:3857)
- two-tone lines: dark casing + colour body per route, z-order
  bus->s_tog->light_rail->regional->metro
- shapeburst fade mask outside City Pass area (distance-transform alpha)
- station labels for metro + S-tog via adjustText
- title + attribution
- styling.yaml: switch basemap to OpenStreetMap.Mapnik (CartoDB now needs API key)
- .gitignore: exclude generated outputs (regenerable via render.py)
- verified: full map, --no-buses, --bus-subset, --modes, SVG all work
2026-09-13 01:27:55 +02:00
marvin 8e32ca2b1c Copenhagen Phase 2: prepare.py merges + clips + colours -> master.gpkg
- prepare.py: merge per-mode OSM lines, resolve colours
  (GTFS route_color -> OSM colour tag -> keyed palette, robust to pd.NA),
  categorise buses (A/C/S/regular via regex), clip lines+stops to area,
  write master.gpkg layers (lines/stops/stations)
- download_osm.py: add stations query (public_transport=station /
  railway=station nodes+areas, out center) -> stations.gpkg with proper
  human-readable names + subway/light_rail mode tags for labelling
- verified: 135 lines (8 metro/16 s-tog/28 regional/83 bus), 90 named
  stations, zero NaN colours
2026-09-13 01:19:34 +02:00
marvin 43c04a94f1 Copenhagen Phase 1: scaffolding + area + OSM download
- pyproject.toml with all deps (geopandas, contextily, partridge, etc.)
- config/: area.json (3 OSM boundary relations), modes.yaml (subway/s_tog/
  light_rail/regional/bus, S-tog scoped to route=light_rail network=Takst),
  styling.yaml (zorder/widths/colours/palette/mask/labels)
- build_area.py: fetch 3 relations via OSM API, stitch rings, dissolve to
  area.gpkg/area.geojson (City Pass approximation)
- download_osm.py: per-mode Overpass (mirror+backoff, out geom + recurse for
  stops), writes {mode}.gpkg (lines+stops) + query text
- download_gtfs.py / gtfs_to_geopackage.py: stubs (blocked on Rejseplanen)
- data/ gitignored (regenerable); output/ tracked
2026-09-13 01:15:25 +02:00
16 changed files with 3121 additions and 0 deletions
+19
View File
@@ -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
+26
View File
@@ -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]
]
}
]
}
+251
View File
@@ -0,0 +1,251 @@
<?xml version='1.0' encoding='utf-8'?>
<kml xmlns="http://www.opengis.net/kml/2.2">
<Document>
<name>Coastline (from Hide+Seek Copenhagen City Pass map)</name>
<StyleMap id="line-01579B-3131-nodesc">
<Pair>
<key>normal</key>
<styleUrl>#line-01579B-3131-nodesc-normal</styleUrl>
</Pair>
<Pair>
<key>highlight</key>
<styleUrl>#line-01579B-3131-nodesc-highlight</styleUrl>
</Pair>
</StyleMap>
<Style id="line-01579B-3131-nodesc-highlight">
<LineStyle>
<color>ff9b5701</color>
<width>4.6965</width>
</LineStyle>
<BalloonStyle>
<text>&lt;h3&gt;$[name]&lt;/h3&gt;</text>
</BalloonStyle>
</Style>
<Style id="line-01579B-3131-nodesc-normal">
<LineStyle>
<color>ff9b5701</color>
<width>3.131</width>
</LineStyle>
<BalloonStyle>
<text>&lt;h3&gt;$[name]&lt;/h3&gt;</text>
</BalloonStyle>
</Style>
<Folder>
<name>Coastline</name>
<Placemark>
<name>Coastline</name>
<styleUrl>#line-01579B-3131-nodesc</styleUrl>
<LineString>
<tessellate>1</tessellate>
<coordinates>
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
</coordinates>
</LineString>
</Placemark>
</Folder>
</Document>
</kml>
+56
View File
@@ -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]
+99
View File
@@ -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
View File
View File
+13
View File
@@ -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)"
+168
View File
@@ -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()
+32
View File
@@ -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()
+257
View File
@@ -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()
+151
View File
@@ -0,0 +1,151 @@
#!/usr/bin/env python3
"""Convert raw GTFS txt files into portable GeoPackage layers + a colour table.
Inputs (data/raw/gtfs): routes.txt, trips.txt, stops.txt, shapes.txt
Outputs (data/processed):
gtfs_shapes.gpkg (shapes.txt -> LineString per shape_id, EPSG:4326,
each shape tagged with its route_id)
gtfs_stops.gpkg (stops.txt -> Point per stop, EPSG:4326)
route_colors.csv (route_id, agency_id, route_short_name, route_long_name,
route_type, route_color, route_text_color)
The feed is nationwide, so everything is pre-filtered to the Copenhagen area:
stops by location, shapes to those intersecting the area polygon (or a
fallback bbox when data/processed/area.gpkg does not exist yet), and routes
to those having at least one surviving shape. prepare.py clips precisely to
the area later.
Note: this feed leaves route_color empty for the Copenhagen operators, so
prepare.py will mostly fall through to OSM colour tags / the styling palette.
stop_times.txt is not needed here and is intentionally not parsed (220 MB).
"""
import sys
import geopandas as gpd
import pandas as pd
from shapely.geometry import LineString, box
from shapely.geometry.base import BaseGeometry
from _common import GTFS_RAW, PROCESSED
# Generous Copenhagen bbox (covers København, Frederiksberg, Amager, and
# immediate surroundings: airport, Hellerup, Lyngby, Brøndby, ...).
CPH_BBOX = box(12.30, 55.55, 12.75, 55.85)
def load_area() -> BaseGeometry:
"""City Pass area polygon if build_area has run, else the fallback bbox."""
area_path = PROCESSED / "area.gpkg"
if area_path.exists():
return gpd.read_file(area_path).geometry.union_all()
print(f"no {area_path}; using fallback Copenhagen bbox", flush=True)
return CPH_BBOX
def build_stops(area: BaseGeometry) -> gpd.GeoDataFrame:
stops = pd.read_csv(
GTFS_RAW / "stops.txt",
usecols=["stop_id", "stop_name", "stop_lat", "stop_lon",
"location_type", "parent_station"],
dtype={"stop_id": str, "parent_station": str},
)
minx, miny, maxx, maxy = area.bounds
in_bbox = (
stops["stop_lat"].between(miny, maxy)
& stops["stop_lon"].between(minx, maxx)
)
stops = stops[in_bbox].copy()
g = gpd.GeoDataFrame(
stops,
geometry=gpd.points_from_xy(stops["stop_lon"], stops["stop_lat"]),
crs="EPSG:4326",
)
return g[g.intersects(area)].drop(columns=["stop_lat", "stop_lon"])
def build_shapes(area: BaseGeometry) -> gpd.GeoDataFrame:
pts = pd.read_csv(GTFS_RAW / "shapes.txt", dtype={"shape_id": str})
minx, miny, maxx, maxy = area.bounds
hits = pts[
pts["shape_pt_lat"].between(miny, maxy)
& pts["shape_pt_lon"].between(minx, maxx)
]
pts = pts[pts["shape_id"].isin(set(hits["shape_id"].unique()))]
pts = pts.sort_values(["shape_id", "shape_pt_sequence"])
lines = (
pts.groupby("shape_id")
.apply(
lambda g: LineString(zip(g["shape_pt_lon"], g["shape_pt_lat"])),
include_groups=False,
)
.rename("geometry")
.reset_index()
)
g = gpd.GeoDataFrame(lines, geometry="geometry", crs="EPSG:4326")
return g[g.intersects(area)]
def build_routes(
shapes: gpd.GeoDataFrame,
) -> tuple[gpd.GeoDataFrame, pd.DataFrame]:
"""Tag shapes with route_id and keep routes having >= 1 surviving shape."""
trips = pd.read_csv(
GTFS_RAW / "trips.txt",
usecols=["route_id", "shape_id"],
dtype=str,
).dropna(subset=["shape_id"])
shape_to_route = (
trips[trips["shape_id"].isin(set(shapes["shape_id"]))]
.drop_duplicates("shape_id")
.set_index("shape_id")["route_id"]
)
shapes = shapes.copy()
shapes["route_id"] = shapes["shape_id"].map(shape_to_route)
routes = pd.read_csv(GTFS_RAW / "routes.txt", dtype=str).fillna("")
routes = routes[
routes["route_id"].isin(set(shape_to_route.unique()))
].copy()
return shapes, routes[
[
"route_id", "agency_id", "route_short_name", "route_long_name",
"route_type", "route_color", "route_text_color",
]
]
def main():
if not (GTFS_RAW / "routes.txt").exists():
print(
"gtfs_to_geopackage: no GTFS data found in "
f"{GTFS_RAW}. Run download_gtfs.py first.",
file=sys.stderr,
)
sys.exit(2)
PROCESSED.mkdir(parents=True, exist_ok=True)
area = load_area()
stops = build_stops(area)
print(f"stops in area: {len(stops)}", flush=True)
shapes = build_shapes(area)
print(f"shapes in area: {len(shapes)}", flush=True)
shapes, routes = build_routes(shapes)
print(f"routes in area: {len(routes)}", flush=True)
stops.to_file(PROCESSED / "gtfs_stops.gpkg", driver="GPKG", layer="stops")
shapes.to_file(
PROCESSED / "gtfs_shapes.gpkg", driver="GPKG", layer="shapes"
)
routes.to_csv(PROCESSED / "route_colors.csv", index=False)
print(
"wrote gtfs_stops.gpkg, gtfs_shapes.gpkg, route_colors.csv "
f"into {PROCESSED}",
flush=True,
)
if __name__ == "__main__":
main()
+417
View File
@@ -0,0 +1,417 @@
#!/usr/bin/env python3
"""Merge GTFS-derived layers into a single master GeoPackage, enriched for rendering.
Reads (from data/processed):
gtfs_shapes.gpkg route-tagged line geometries (gtfs_to_geopackage.py)
gtfs_stops.gpkg raw stop poles (one row per physical pole)
route_colors.csv route metadata (agency_id, short/long name, type, colour)
area.gpkg City Pass area polygon (build_area.py, incl. waterways)
and from data/raw/gtfs: agency.txt, trips.txt, stop_times.txt.
Writes data/processed/master.gpkg with layers:
lines one feature per (style, ref, direction) — the shape that serves
the most in-area stops (so drawn lines pass the drawn stops)
stops bus/ferry stops, one point per stop name, pruned to stops
actually served by a drawn line (see STOP_LINE_MARGIN_M)
stations rail-family stations, one point per (name, style)
Stop names in the Rejseplanen feed are unambiguous per location (verified:
max spread within a name is ~350 m), so every merge is key-based on names —
no distance-based clustering anywhere.
Style classification comes from config/modes.yaml (agency + route_type);
file order is the priority when a pole is served by several modes.
Colours: GTFS route_color -> styling.yaml palette (the feed leaves
route_color empty for the Copenhagen operators, so the palette wins).
"""
import re
import sys
import geopandas as gpd
import pandas as pd
import yaml
from shapely.geometry import box
from _common import CONFIG, GTFS_RAW, PROCESSED
LENGTH_CRS = "EPSG:25832" # UTM 32N — metres, for length/centroid computations
STATION_STYLES = {"metro", "s_tog", "light_rail", "regional"}
# A stop is drawn only if a drawn line of one of its serving refs passes
# 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 _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_south_bound():
"""South latitude cutoff from modes.yaml, or None.
When set, stops/stations south of (and transit lines below) this
latitude are dropped. Used to trim the map at a boundary.
"""
return _load_modes_yaml().get("south_bound_lat")
def _load_modes_yaml():
return yaml.safe_load((CONFIG / "modes.yaml").read_text())
def load_styling():
return yaml.safe_load((CONFIG / "styling.yaml").read_text())
def load_area():
area = gpd.read_file(PROCESSED / "area.gpkg")
return area.geometry.union_all()
def classify(agency, route_type, modes):
"""Map a GTFS (agency_name, route_type) pair to a style key, or None."""
for style, cfg in modes.items():
if agency not in cfg.get("agencies", []):
continue
types = cfg.get("route_types")
if types and route_type not in types:
continue
return style
return None
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"]))
routes = pd.read_csv(PROCESSED / "route_colors.csv", dtype=str)
routes["route_type"] = pd.to_numeric(routes["route_type"], errors="coerce")
routes["agency_name"] = routes["agency_id"].map(agency_names)
routes["style"] = [
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
def categorise_bus(ref, patterns):
s = "" if ref is None else str(ref)
for cat, pat in patterns.items():
if re.search(pat, s):
return cat
return "regular"
def resolve_colour(style, ref, bus_category, gtfs_colour, palette):
# 1. GTFS route_color (mostly empty in this feed)
if isinstance(gtfs_colour, str) and gtfs_colour.strip():
return gtfs_colour
# 2. palette
p = palette.get(style, {})
if style == "bus":
return p.get(bus_category) or p.get("default")
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).
Geometry per group: the shape that serves the most in-area stops
(tie-break: longest). Picking by length alone can draw a variant that
skips stops shown on the map (terminal stubs, short-turn branches).
"""
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"]))
# coverage: distinct in-area stops visited per shape
trip_shape = dict(zip(trips["trip_id"], trips["shape_id"]))
sv = st.assign(shape_id=st["trip_id"].map(trip_shape))
sv = sv[sv["stop_id"].isin(area_stop_ids)]
coverage = sv.groupby("shape_id")["stop_id"].nunique()
use = routes.dropna(subset=["style"])[
["route_id", "route_short_name", "route_long_name",
"route_color", "style"]
]
g = shapes.merge(use, on="route_id", how="inner")
g["direction_id"] = g["shape_id"].map(shape_direction)
g = g.to_crs(LENGTH_CRS)
g["_len"] = g.geometry.length
g["_cov"] = g["shape_id"].map(coverage).fillna(0)
g = (
g.sort_values(["_cov", "_len"], ascending=False)
.drop_duplicates(["style", "route_short_name", "direction_id"])
.to_crs("EPSG:4326")
)
g["ref"] = g["route_short_name"]
g["name"] = [
ln if isinstance(ln, str) and ln.strip() else ref
for ln, ref in zip(g["route_long_name"], g["ref"])
]
patterns = styling["bus_filters"]["categories"]
g["bus_category"] = [
categorise_bus(ref, patterns) if style == "bus" else None
for ref, style in zip(g["ref"], g["style"])
]
palette = styling["palette"]
g["colour_final"] = [
resolve_colour(style, ref, cat, gtfs_c, palette)
for style, ref, cat, gtfs_c
in zip(g["style"], g["ref"], g["bus_category"], g["route_color"])
]
g = g[["ref", "name", "style", "bus_category", "colour_final", "geometry"]]
g = g[~g.geometry.isna() & g.geometry.is_valid]
# the area polygon includes patched-in waterways (area.json), so ferry
# shapes and sub-harbour metro tunnels survive the clip unfragmented
return gpd.clip(g, area_geom)
def build_pole_classes(routes, modes, trips, st, area_stop_ids):
"""Per-pole classification and serving refs, from stop_times.
Returns (pole_styles, pole_refs):
pole_styles[stop_id] = highest-priority style among classified serving
routes (modes.yaml order)
pole_refs[stop_id] = set of serving route short names (classified)
"""
classified = routes.dropna(subset=["style"])
route_style = dict(zip(classified["route_id"], classified["style"]))
route_ref = dict(
zip(classified["route_id"], classified["route_short_name"])
)
trip_route = dict(zip(trips["trip_id"], trips["route_id"]))
sv = st[st["stop_id"].isin(area_stop_ids)].copy()
sv["route_id"] = sv["trip_id"].map(trip_route)
sv = sv.dropna(subset=["route_id"])
sv["style"] = sv["route_id"].map(route_style)
sv = sv.dropna(subset=["style"])
order = {s: i for i, s in enumerate(modes)}
sv["_p"] = sv["style"].map(order)
best = sv.loc[sv.groupby("stop_id")["_p"].idxmin(), ["stop_id", "style"]]
pole_styles = dict(zip(best["stop_id"], best["style"]))
pole_refs = sv.groupby("stop_id")["route_id"].apply(
lambda ids: {route_ref[i] for i in ids}
).to_dict()
return pole_styles, pole_refs
def build_stops_and_stations(pole_styles, pole_refs, area_geom, lines):
poles = gpd.read_file(PROCESSED / "gtfs_stops.gpkg")
poles["style"] = poles["stop_id"].map(pole_styles)
poles["refs"] = poles["stop_id"].map(lambda s: pole_refs.get(s, set()))
poles = poles.dropna(subset=["style"])
# project for accurate centroids and distances
poles = poles.to_crs(LENGTH_CRS)
# stops (everything not rail-family: buses + harbour ferries), one row
# per name, union of serving refs across its poles
not_rail = poles[~poles["style"].isin(STATION_STYLES)]
stop_rows = []
for (name, style), grp in not_rail.groupby(["stop_name", "style"]):
refs = set().union(*grp["refs"]) if len(grp) else set()
stop_rows.append({
"name": name,
"style": style,
"n_poles": len(grp),
"refs": refs,
"geometry": grp.geometry.union_all().centroid,
})
# stations: one row per (name, style)
rail = poles[poles["style"].isin(STATION_STYLES)]
station_rows = []
for (name, style), grp in rail.groupby(["stop_name", "style"]):
station_rows.append({
"name": name,
"style": style,
"n_poles": len(grp),
"geometry": grp.geometry.union_all().centroid,
})
area_25832 = gpd.GeoSeries([area_geom], crs="EPSG:4326").to_crs(LENGTH_CRS).union_all()
stops = gpd.GeoDataFrame(stop_rows, crs=LENGTH_CRS)
# prune stops not served by any drawn line within STOP_LINE_MARGIN_M
if len(stops) and len(lines):
line_geom = (
lines.to_crs(LENGTH_CRS)
.groupby("ref")["geometry"]
.agg(lambda g: g.union_all())
.to_dict()
)
keep = []
for _, r in stops.iterrows():
dists = [
geom.distance(r.geometry)
for rf in r["refs"]
if (geom := line_geom.get(rf)) is not None
]
keep.append(bool(dists) and min(dists) <= STOP_LINE_MARGIN_M)
n_dropped = (~pd.Series(keep, index=stops.index)).sum()
if n_dropped:
print(f"pruned {n_dropped} stops not served by a drawn line "
f"(>{STOP_LINE_MARGIN_M} m from nearest)", flush=True)
stops = stops[keep].drop(columns=["refs"])
else:
stops = stops.drop(columns=["refs"])
stops = gpd.clip(stops, area_25832)
stations = gpd.clip(
gpd.GeoDataFrame(station_rows, crs=LENGTH_CRS), area_25832
)
return stops.to_crs("EPSG:4326"), stations.to_crs("EPSG:4326")
def main():
for dep in ("gtfs_shapes.gpkg", "gtfs_stops.gpkg", "route_colors.csv"):
if not (PROCESSED / dep).exists():
print(f"prepare: missing {PROCESSED / dep}"
"run gtfs_to_geopackage.py first.", file=sys.stderr)
sys.exit(2)
modes = load_modes()
styling = load_styling()
area_geom = load_area()
south_bound = load_south_bound()
if south_bound is not None:
north = box(-180, south_bound, 180, 90)
area_geom = area_geom.intersection(north)
print(f"south bound: clipping area at lat {south_bound}", flush=True)
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)
area_stop_ids = set(
gpd.read_file(PROCESSED / "gtfs_stops.gpkg")["stop_id"]
)
trips = pd.read_csv(
GTFS_RAW / "trips.txt",
usecols=["trip_id", "route_id", "shape_id", "direction_id"],
dtype=str,
)
st = pd.read_csv(
GTFS_RAW / "stop_times.txt",
usecols=["trip_id", "stop_id"],
dtype=str,
)
lines = build_lines(routes, styling, area_geom, trips, st, area_stop_ids)
print(f"lines: {len(lines)} features (one per style/ref/direction)",
flush=True)
pole_styles, pole_refs = build_pole_classes(
routes, modes, trips, st, area_stop_ids
)
stops, stations = build_stops_and_stations(
pole_styles, pole_refs, area_geom, lines
)
print(f"stops: {len(stops)} (bus + ferry); stations: {len(stations)}",
flush=True)
out = PROCESSED / "master.gpkg"
if out.exists():
out.unlink()
lines.to_file(out, driver="GPKG", layer="lines")
print(f"wrote lines layer: {len(lines)} features", flush=True)
stops.to_file(out, driver="GPKG", layer="stops")
print(f"wrote stops layer: {len(stops)} features", flush=True)
stations.to_file(out, driver="GPKG", layer="stations")
print(f"wrote stations layer: {len(stations)} features", flush=True)
print("\nsummary by style:")
for style, grp in lines.groupby("style"):
refs = grp["ref"].dropna().unique()
print(f" {style:10s}: {len(grp):4d} feats, {len(refs):3d} refs")
if __name__ == "__main__":
main()
+375
View File
@@ -0,0 +1,375 @@
#!/usr/bin/env python3
"""Render the Copenhagen transit map to PNG/SVG/PDF.
Reads data/processed/master.gpkg (layers: lines, stops, stations — all
GTFS-derived by prepare.py) + area.gpkg + config/styling.yaml, applies CLI
filters, and composes a printable map:
- Carto/Esri grey basemap via contextily (EPSG:3857)
- two-tone lines: dark casing + colour body, per route
- z-order: bus (bottom) -> s_tog -> light_rail -> regional -> metro (top)
- shapeburst fade mask outside the City Pass area
- station labels (metro + S-tog) via adjustText
All transit data comes from the Rejseplanen GTFS feed. Stop/station layers
are pre-collapsed (one point per stop name / station) by prepare.py, so
rendering is pure plotting — no clustering or merging here.
Usage:
uv run python render.py # default: all modes, PNG, markers only
uv run python render.py --labels # add station name labels
uv run python render.py --no-stops # no station markers
uv run python render.py --no-buses --format svg --out map.svg
uv run python render.py --bus-subset A,C,S # only trunk buses
uv run python render.py --modes metro,s_tog # rail-only
"""
import argparse
from pathlib import Path
import geopandas as gpd
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import yaml
from affine import Affine
from adjustText import adjust_text
from scipy.ndimage import distance_transform_edt
from _common import CONFIG, OUTPUT, PROCESSED, UA
TARGET_CRS = "EPSG:3857"
def load_args():
p = argparse.ArgumentParser(description="Render the Copenhagen transit map.")
p.add_argument("--modes", default=None,
help="comma-separated styles to include (metro,s_tog,light_rail,regional,bus)")
p.add_argument("--no-buses", action="store_true", help="omit bus layer")
p.add_argument("--bus-subset", default=None,
help="comma-separated bus categories to keep (A,C,S,regular)")
p.add_argument("--max-bus-routes", type=int, default=None,
help="cap on number of distinct bus refs drawn")
p.add_argument("--dpi", type=int, default=200)
p.add_argument("--size", default="16",
help="figure size in inches: 'W' (height auto) or 'WxH'")
p.add_argument("--format", default="png", help="output format (png/svg/pdf)")
p.add_argument("--out", default=None, help="output path (default: output/copenhagen.<fmt>)")
p.add_argument("--no-basemap", action="store_true", help="skip basemap tiles (offline/faster)")
p.add_argument("--labels", action="store_true", help="draw station name labels (off by default)")
p.add_argument("--no-stops", action="store_true", help="skip station markers")
return p.parse_args()
def parse_size(spec, aspect):
if "x" in spec:
w, h = spec.split("x")
return float(w), float(h)
w = float(spec)
return w, w / aspect
def filter_lines(lines, args):
keep = set(lines["style"].unique())
if args.modes:
keep &= {m.strip() for m in args.modes.split(",")}
if args.no_buses:
keep.discard("bus")
out = lines[lines["style"].isin(keep)].copy()
if "bus" in keep and out["style"].eq("bus").any():
bus = out[out["style"].eq("bus")]
other = out[~out["style"].eq("bus")]
if args.bus_subset:
cats = {c.strip() for c in args.bus_subset.split(",")}
bus = bus[bus["bus_category"].isin(cats)]
if args.max_bus_routes is not None:
refs = list(dict.fromkeys(bus["ref"].dropna()))
keep_refs = set(refs[:args.max_bus_routes])
bus = bus[bus["ref"].isin(keep_refs)]
out = gpd.GeoDataFrame(pd.concat([other, bus], ignore_index=True),
crs=out.crs)
return out
def add_basemap(ax, styling, zoom=None):
if zoom is None:
zoom = styling["basemap"]["zoom"]
try:
import contextily as cx
provider = styling["basemap"]["provider"]
src = cx.providers
for part in provider.split("."):
src = getattr(src, part)
cx.add_basemap(ax, crs=TARGET_CRS, source=src, zoom=zoom,
attribution=False, zorder=0,
headers={"User-Agent": UA})
return True
except Exception as e:
print(f" [basemap] unavailable ({e}); using plain background", flush=True)
ax.set_facecolor("#f2f2f0")
return False
def shapeburst_mask(ax, area_3857, extent, styling, res=1600):
fade_m = styling["mask"]["fade_distance_m"]
max_alpha = styling["mask"]["max_alpha"]
color = styling["mask"]["color"]
x0, x1, y0, y1 = extent
nx = res
ny = max(1, int(round(res * (y1 - y0) / (x1 - x0))))
pxw = (x1 - x0) / nx
pxh = (y1 - y0) / ny
transform = Affine.translation(x0, y1) * Affine.scale(pxw, -pxh)
from rasterio.features import rasterize
geom = area_3857.geometry.union_all()
mask = rasterize([(geom, 1)], out_shape=(ny, nx), transform=transform,
fill=0, dtype=np.uint8, all_touched=False).astype(bool)
dist_px = distance_transform_edt(~mask)
dist_m = dist_px * pxw
alpha = np.where(mask, 0.0, np.clip(dist_m / fade_m, 0.0, 1.0)) * max_alpha
cmap = matplotlib.colors.to_rgba(color)
rgba = np.zeros((ny, nx, 4))
rgba[..., :3] = cmap[:3]
rgba[..., 3] = alpha
ax.imshow(rgba, extent=(x0, x1, y0, y1), origin="upper",
aspect="equal", interpolation="bilinear", zorder=0.5)
def plot_lines(ax, lines, styling):
zord = styling["zorder"]
widths = styling["line_width"]
alphas = styling["alpha"]
outline_w = styling["outline_width"]
outline_c = styling["outline_color"]
for style in sorted(zord, key=lambda k: zord[k]):
grp = lines[lines["style"] == style]
if grp.empty:
continue
w = widths.get(style, 1.0)
z = zord[style]
# dark casing
grp.plot(ax=ax, color=outline_c, linewidth=w + outline_w,
zorder=z, alpha=1.0, capstyle="round", joinstyle="round")
# colour body
body_colors = grp["colour_final"].tolist()
grp.plot(ax=ax, color=body_colors, linewidth=w,
zorder=z + 0.1, alpha=alphas.get(style, 1.0),
capstyle="round", joinstyle="round")
RAIL_STYLES = ("metro", "s_tog", "light_rail", "regional")
def classify_station(r):
"""Style key for a station row, or None. Stations are pre-classified."""
s = r.get("style")
return s if s in RAIL_STYLES else None
def plot_stations(ax, stations, styling, active_styles):
"""Draw station markers. Input is pre-collapsed: one row per (name, style)."""
if stations is None or stations.empty:
return
cfg = styling.get("stations", {})
if not cfg.get("show", True):
return
marker_styles = set(cfg.get("styles", ["metro", "s_tog", "regional"]))
mk = cfg.get("marker", {})
sizes = mk.get("size", {})
shape = mk.get("shape", "circle")
fill = mk.get("fill", "white")
edge = mk.get("edge", "#2b2b2b")
lw = mk.get("linewidth", 0.8)
marker = "o" if shape == "circle" else "s"
pts_by_style = {}
for _, r in stations.iterrows():
s = classify_station(r)
if s is None or s not in marker_styles or s not in active_styles:
continue
pts_by_style.setdefault(s, []).append((r.geometry.x, r.geometry.y))
zord = styling["zorder"]
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)):
pts = pts_by_style[s]
sz = sizes.get(s, 3.0)
ax.scatter([p[0] for p in pts], [p[1] for p in pts],
s=sz ** 2, marker=marker, c=fill, edgecolors=edge,
linewidths=lw, zorder=top_z, alpha=1.0)
def rail_station_names(stations):
"""Set of names of rail stations (metro/s_tog/light_rail/regional)."""
if stations is None or stations.empty:
return set()
names = set()
for _, r in stations.iterrows():
if classify_station(r) is not None:
name = r.get("name")
if isinstance(name, str) and name.strip():
names.add(name)
return names
def plot_bus_stops(ax, stops, styling, active_styles, rail_names=None):
"""Draw small markers for bus stops (pre-collapsed: one point per name).
A bus stop whose name exactly matches a rail station is skipped — the
station marker represents it.
"""
if stops is None or stops.empty or not ({"bus", "ferry"} & active_styles):
return
cfg = styling.get("stations", {})
if not cfg.get("show", True) or "bus" not in set(cfg.get("styles", [])):
return
bus = stops[stops["style"].isin(["bus", "ferry"])]
if bus.empty:
return
rail_names = rail_names or set()
mk = cfg.get("marker", {})
sz = mk.get("size", {}).get("bus", 1.5)
fill = mk.get("fill", "white")
edge = mk.get("edge", "#2b2b2b")
lw = mk.get("linewidth", 0.8)
marker = "o" if mk.get("shape", "circle") == "circle" else "s"
top_z = max(styling["zorder"].values()) + 1
pts = [(r.geometry.x, r.geometry.y)
for _, r in bus.iterrows()
if not (isinstance(r.get("name"), str) and r.get("name") in rail_names)]
ax.scatter([p[0] for p in pts], [p[1] for p in pts],
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):
if stations is None or stations.empty:
return
cfg = styling.get("labels", {})
if not cfg.get("show", False):
return
label_styles = set(cfg.get("styles", ["metro", "s_tog"])) & active_styles
fmin = cfg.get("min_fontsize", 5)
fmax = cfg.get("max_fontsize", 9)
# stations are unique per (name, style); dedupe by name for labelling
seen = set()
uniq = []
for _, r in stations.iterrows():
s = classify_station(r)
if s not in label_styles:
continue
name = r.get("name")
if not isinstance(name, str) or not name.strip() or name in seen:
continue
seen.add(name)
uniq.append((r.geometry.x, r.geometry.y, name, s == "metro"))
if not uniq:
return
texts = []
for x, y, name, is_m in uniq:
fs = fmax if is_m else fmin
bbox = dict(boxstyle="round,pad=0.15", fc="white", ec="none", alpha=0.7)
t = ax.text(x, y, name, fontsize=fs, color="#111111",
zorder=10, ha="center", va="center", bbox=bbox)
texts.append(t)
try:
adjust_text(texts, ax=ax, expand_points=(1.4, 1.6),
force_text=(0.4, 0.6), lim=300,
arrowprops=dict(arrowstyle="-", color="#888", lw=0.4))
except Exception as e:
print(f" [labels] adjustText failed ({e}); labels may overlap", flush=True)
def main():
args = load_args()
styling = yaml.safe_load((CONFIG / "styling.yaml").read_text())
area = gpd.read_file(PROCESSED / "area.gpkg").to_crs(TARGET_CRS)
lines = gpd.read_file(PROCESSED / "master.gpkg", layer="lines").to_crs(TARGET_CRS)
try:
stations = gpd.read_file(PROCESSED / "master.gpkg", layer="stations").to_crs(TARGET_CRS)
except Exception:
stations = None
try:
stops = gpd.read_file(PROCESSED / "master.gpkg", layer="stops").to_crs(TARGET_CRS)
except Exception:
stops = None
lines = filter_lines(lines, args)
print(f"rendering {len(lines)} line features", flush=True)
bounds = area.total_bounds # (minx, miny, maxx, maxy)
dx = bounds[2] - bounds[0]
dy = bounds[3] - bounds[1]
aspect = dx / dy
pad = 0.12
extent = (bounds[0] - pad * dx, bounds[2] + pad * dx,
bounds[1] - pad * dy, bounds[3] + pad * dy)
fig_w, fig_h = parse_size(args.size, aspect)
fig, ax = plt.subplots(figsize=(fig_w, fig_h), dpi=args.dpi)
ax.set_xlim(extent[0], extent[1])
ax.set_ylim(extent[2], extent[3])
ax.set_aspect("equal")
ax.axis("off")
# basemap
if not args.no_basemap:
add_basemap(ax, styling)
# fade mask outside area
shapeburst_mask(ax, area, extent, styling)
# lines
plot_lines(ax, lines, styling)
# active styles (which mode layers are actually drawn)
active_styles = set(lines["style"].unique())
# station markers (symbols, no text)
if not args.no_stops:
plot_stations(ax, stations, styling, active_styles)
rail_names = rail_station_names(stations)
plot_bus_stops(ax, stops, styling, active_styles, rail_names=rail_names)
# station labels (opt-in via --labels)
if args.labels:
styling["labels"]["show"] = True
if styling.get("labels", {}).get("show", False):
label_stations(ax, stations, styling, active_styles)
# title + attribution
ax.text(0.5, 0.985, "Kurragömma Köpenhamn",
transform=ax.transAxes, ha="center", va="top",
fontsize=fig_w * 1.1, fontweight="bold", color="#222",
bbox=dict(boxstyle="round,pad=0.3", fc="white", ec="none", alpha=0.7))
ax.text(0.01, 0.01,
"Transit: Rejseplanen GTFS · Area: © OpenStreetMap contributors (ODbL) · Base: Esri, HERE",
transform=ax.transAxes, ha="left", va="bottom",
fontsize=max(5, fig_w * 0.5), color="#666")
out_dir = OUTPUT
out_dir.mkdir(parents=True, exist_ok=True)
if args.out:
out_path = Path(args.out)
if not out_path.is_absolute():
out_path = out_dir / out_path
else:
out_path = out_dir / f"copenhagen.{args.format}"
out_path.parent.mkdir(parents=True, exist_ok=True)
plt.savefig(out_path, dpi=args.dpi, bbox_inches="tight",
pad_inches=0.2, facecolor="white")
plt.close(fig)
print(f"wrote {out_path} ({args.format}, {fig_w}x{fig_h}in @ {args.dpi}dpi)")
if __name__ == "__main__":
main()
+20
View File
@@ -0,0 +1,20 @@
[project]
name = "jetlag-maps"
version = "0.1.0"
description = "Scriptable transit maps (Copenhagen) built from OSM + GTFS."
requires-python = ">=3.12"
dependencies = [
"geopandas>=1.1",
"shapely>=2.0",
"matplotlib>=3.9",
"contextily>=1.6",
"pyogrio>=0.10",
"requests>=2.31",
"pyyaml>=6.0",
"adjusttext>=1.2",
"rtree>=1.0",
"scipy>=1.13",
]
[tool.uv]
package = false
Generated
+1237
View File
File diff suppressed because it is too large Load Diff