Compare commits
3
Commits
master
..
37c3171f39
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37c3171f39 | ||
|
|
a16816bf73 | ||
|
|
a6715eaff5 |
+2
-1
@@ -4,10 +4,11 @@ copenhagen/data/processed/**
|
|||||||
!copenhagen/data/raw/.gitkeep
|
!copenhagen/data/raw/.gitkeep
|
||||||
!copenhagen/data/processed/.gitkeep
|
!copenhagen/data/processed/.gitkeep
|
||||||
|
|
||||||
# Generated map outputs (regenerable via render.py)
|
# Generated map outputs (regenerable via render.py / export_google_mymaps.py)
|
||||||
copenhagen/output/*.png
|
copenhagen/output/*.png
|
||||||
copenhagen/output/*.svg
|
copenhagen/output/*.svg
|
||||||
copenhagen/output/*.pdf
|
copenhagen/output/*.pdf
|
||||||
|
copenhagen/output/*.kml
|
||||||
|
|
||||||
# Python
|
# Python
|
||||||
.venv/
|
.venv/
|
||||||
|
|||||||
@@ -5,5 +5,22 @@
|
|||||||
{"name": "Kobenhavns Kommune", "id": 2192363},
|
{"name": "Kobenhavns Kommune", "id": 2192363},
|
||||||
{"name": "Frederiksberg Kommune", "id": 2186660},
|
{"name": "Frederiksberg Kommune", "id": 2186660},
|
||||||
{"name": "Amager", "id": 5175924}
|
{"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]
|
||||||
|
]
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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><h3>$[name]</h3></text>
|
||||||
|
</BalloonStyle>
|
||||||
|
</Style>
|
||||||
|
<Style id="line-01579B-3131-nodesc-normal">
|
||||||
|
<LineStyle>
|
||||||
|
<color>ff9b5701</color>
|
||||||
|
<width>3.131</width>
|
||||||
|
</LineStyle>
|
||||||
|
<BalloonStyle>
|
||||||
|
<text><h3>$[name]</h3></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>
|
||||||
@@ -1,40 +1,56 @@
|
|||||||
# Mapping of map modes to OSM route filters, GTFS route types, and style keys.
|
# Mapping of map styles to GTFS agency / route_type filters.
|
||||||
# `style` references a key in styling.yaml.
|
|
||||||
#
|
#
|
||||||
# OSM filter keys map directly to Overpass tag filters. A key suffixed with
|
# Used by prepare.py to classify GTFS routes (lines layer) and the routes
|
||||||
# `_neq` emits a negated tag filter (["k"!="v"]); any other key emits an
|
# serving each stop pole (stops/stations layers). Style keys are referenced
|
||||||
# equality filter (["k"="v"]). The bbox is appended by download_osm.py.
|
# by styling.yaml (palette, zorder, widths).
|
||||||
#
|
#
|
||||||
# S-tog are tagged route=light_rail in OSM (NOT route=train); they are scoped
|
# `agencies` matches GTFS agency_name from agency.txt.
|
||||||
# by network="Takst Sjælland". The separate `light_rail` mode catches other
|
# `route_types` optionally restricts GTFS route_type values, including the
|
||||||
# light rail (e.g. Hovedstadens Letbane, opened Aug 2026) via network!=Takst.
|
# extended codes used in this feed (109 = S-tog suburban rail, 700/715 =
|
||||||
# GTFS route_type: 0=tram,1=subway,2=rail,3=bus,4=ferry.
|
# 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:
|
modes:
|
||||||
subway:
|
metro:
|
||||||
osm:
|
agencies: [Metroselskabet]
|
||||||
route: subway
|
|
||||||
gtfs_route_type: 1
|
|
||||||
style: metro
|
|
||||||
s_tog:
|
s_tog:
|
||||||
osm:
|
agencies: [DSB S-tog]
|
||||||
route: light_rail
|
route_types: [109, 2]
|
||||||
network: "Takst Sjælland"
|
|
||||||
gtfs_route_type: 2
|
|
||||||
style: s_tog
|
|
||||||
light_rail:
|
light_rail:
|
||||||
osm:
|
agencies: [Hovedstadens Letbane]
|
||||||
route: light_rail
|
|
||||||
network_neq: "Takst Sjælland"
|
|
||||||
gtfs_route_type: 2
|
|
||||||
style: light_rail
|
|
||||||
regional:
|
regional:
|
||||||
osm:
|
agencies: [DSB, Lokaltog A/S, Skånetrafiken, Snälltåget AB, DSB Vores Tog]
|
||||||
route: train
|
route_types: [2]
|
||||||
gtfs_route_type: 2
|
ferry:
|
||||||
style: regional
|
# 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:
|
bus:
|
||||||
osm:
|
agencies: [Movia]
|
||||||
route: bus
|
route_types: [3, 700, 715]
|
||||||
network: Movia
|
|
||||||
gtfs_route_type: 3
|
|
||||||
style: bus
|
|
||||||
|
|||||||
@@ -4,13 +4,15 @@
|
|||||||
|
|
||||||
zorder:
|
zorder:
|
||||||
bus: 1
|
bus: 1
|
||||||
s_tog: 2
|
ferry: 2
|
||||||
light_rail: 3
|
s_tog: 3
|
||||||
regional: 4
|
light_rail: 4
|
||||||
metro: 5
|
regional: 5
|
||||||
|
metro: 6
|
||||||
|
|
||||||
line_width: # body width in points (outline = body + outline_width)
|
line_width: # body width in points (outline = body + outline_width)
|
||||||
bus: 0.8
|
bus: 0.8
|
||||||
|
ferry: 1.4
|
||||||
s_tog: 2.0
|
s_tog: 2.0
|
||||||
light_rail: 2.0
|
light_rail: 2.0
|
||||||
regional: 1.8
|
regional: 1.8
|
||||||
@@ -21,14 +23,16 @@ outline_color: "#2b2b2b"
|
|||||||
|
|
||||||
alpha:
|
alpha:
|
||||||
bus: 0.45
|
bus: 0.45
|
||||||
|
ferry: 1.0
|
||||||
s_tog: 0.95
|
s_tog: 0.95
|
||||||
light_rail: 0.95
|
light_rail: 0.95
|
||||||
regional: 0.9
|
regional: 0.9
|
||||||
metro: 1.0
|
metro: 1.0
|
||||||
|
|
||||||
# Fallback palette when no GTFS route_color and no OSM colour tag are present.
|
# Line colours. The Rejseplanen GTFS feed leaves route_color empty for the
|
||||||
# Metro / S-tog values mirror the OSM colour tags (verified). Regional trains
|
# Copenhagen operators, so in practice everything resolves to this palette.
|
||||||
# have no OSM colour, so the palette supplies one.
|
# 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:
|
palette:
|
||||||
metro:
|
metro:
|
||||||
M1: "#008d41"
|
M1: "#008d41"
|
||||||
@@ -52,6 +56,8 @@ palette:
|
|||||||
C: "#16a085"
|
C: "#16a085"
|
||||||
S: "#2a6fb5"
|
S: "#2a6fb5"
|
||||||
default: "#c8a80e"
|
default: "#c8a80e"
|
||||||
|
ferry:
|
||||||
|
default: "#2aa8e0" # harbour-bus blue
|
||||||
|
|
||||||
bus_filters:
|
bus_filters:
|
||||||
# ref-based bus categorisation (regex applied to route ref)
|
# ref-based bus categorisation (regex applied to route ref)
|
||||||
@@ -73,11 +79,8 @@ basemap:
|
|||||||
stations:
|
stations:
|
||||||
show: true # draw station markers (symbols without text)
|
show: true # draw station markers (symbols without text)
|
||||||
styles: [metro, s_tog, regional, bus]
|
styles: [metro, s_tog, regional, bus]
|
||||||
stop_cluster_m: # merge same-name stops within this distance (per mode)
|
# NOTE: stops/stations arrive pre-collapsed from prepare.py (name-keyed,
|
||||||
metro: 200 # covers platform/entrance spread (~170m max)
|
# one point per stop/station) — no clustering configuration lives here.
|
||||||
s_tog: 400 # covers platform/entrance spread (~380m max)
|
|
||||||
regional: 500 # covers platform spread (~475m max)
|
|
||||||
bus: 100 # opposite sides of road, wide boulevards
|
|
||||||
marker:
|
marker:
|
||||||
shape: circle # circle | square
|
shape: circle # circle | square
|
||||||
fill: white
|
fill: white
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ CPH = HERE.parent
|
|||||||
CONFIG = CPH / "config"
|
CONFIG = CPH / "config"
|
||||||
DATA = CPH / "data"
|
DATA = CPH / "data"
|
||||||
RAW = DATA / "raw"
|
RAW = DATA / "raw"
|
||||||
OSM_RAW = RAW / "osm"
|
|
||||||
GTFS_RAW = RAW / "gtfs"
|
GTFS_RAW = RAW / "gtfs"
|
||||||
PROCESSED = DATA / "processed"
|
PROCESSED = DATA / "processed"
|
||||||
OUTPUT = CPH / "output"
|
OUTPUT = CPH / "output"
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
|
|
||||||
Fetches relation/{id}/full.json from the OSM API for each relation in
|
Fetches relation/{id}/full.json from the OSM API for each relation in
|
||||||
config/area.json, assembles member ways into rings (respecting outer/inner
|
config/area.json, assembles member ways into rings (respecting outer/inner
|
||||||
roles), builds polygons, and dissolves the union into a single multipolygon.
|
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):
|
Outputs (in data/processed):
|
||||||
area.geojson (EPSG:4326, human-readable + portable)
|
area.geojson (EPSG:4326, human-readable + portable)
|
||||||
@@ -130,6 +132,23 @@ def main():
|
|||||||
if not dissolved.is_valid:
|
if not dissolved.is_valid:
|
||||||
dissolved = dissolved.buffer(0)
|
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)
|
PROCESSED.mkdir(parents=True, exist_ok=True)
|
||||||
gdf = gpd.GeoDataFrame(
|
gdf = gpd.GeoDataFrame(
|
||||||
{"name": ["City Pass area"]}, geometry=[dissolved], crs="EPSG:4326"
|
{"name": ["City Pass area"]}, geometry=[dissolved], crs="EPSG:4326"
|
||||||
|
|||||||
@@ -1,40 +1,27 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Download the Rejseplanen GTFS feed for the Copenhagen area.
|
"""Download the Rejseplanen GTFS feed (all of Denmark) and unzip into data/raw/gtfs/.
|
||||||
|
|
||||||
STATUS: BLOCKED. Rejseplanen (journey planner) does not currently expose a
|
Source: https://www.rejseplanen.info/labs — a static GTFS zip published by
|
||||||
public GTFS download. This script documents the intended flow and exits
|
Rejseplanen covering DSB, DSB S-tog, Metroselskabet, Movia, Lokaltog,
|
||||||
non-zero with guidance.
|
Hovedstadens Letbane, Skånetrafiken, etc. Nationwide feed; gtfs_to_geopackage.py
|
||||||
|
filters it down to the Copenhagen area.
|
||||||
When a feed URL becomes available, set it here and the script will download
|
|
||||||
and unzip into data/raw/gtfs/.
|
|
||||||
"""
|
"""
|
||||||
import sys
|
|
||||||
import urllib.request
|
import urllib.request
|
||||||
import zipfile
|
import zipfile
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from _common import GTFS_RAW
|
from _common import GTFS_RAW, UA
|
||||||
|
|
||||||
# Fill in once access is granted. Likely candidates:
|
GTFS_URL = "https://www.rejseplanen.info/labs/GTFS.zip"
|
||||||
# - Rejseplanen / DOT open-data portal
|
|
||||||
# - a static GTFS zip provided on request
|
|
||||||
GTFS_URL = None # e.g. "https://.../rejseplanen.zip"
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
if not GTFS_URL:
|
|
||||||
print(
|
|
||||||
"download_gtfs: BLOCKED — no GTFS feed URL configured.\n"
|
|
||||||
"Set GTFS_URL in this script once Rejseplanen grants access, then re-run.\n"
|
|
||||||
"The OSM-only pipeline (build_area -> download_osm -> prepare -> render)\n"
|
|
||||||
"is fully functional without GTFS; GTFS only enriches colours/stops.",
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
sys.exit(2)
|
|
||||||
GTFS_RAW.mkdir(parents=True, exist_ok=True)
|
GTFS_RAW.mkdir(parents=True, exist_ok=True)
|
||||||
zip_path = GTFS_RAW / "feed.zip"
|
zip_path = GTFS_RAW / "feed.zip"
|
||||||
print(f"downloading {GTFS_URL} -> {zip_path} ...", flush=True)
|
print(f"downloading {GTFS_URL} -> {zip_path} ...", flush=True)
|
||||||
urllib.request.urlretrieve(GTFS_URL, zip_path)
|
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:
|
with zipfile.ZipFile(zip_path) as z:
|
||||||
z.extractall(GTFS_RAW)
|
z.extractall(GTFS_RAW)
|
||||||
zip_path.unlink(missing_ok=True)
|
zip_path.unlink(missing_ok=True)
|
||||||
|
|||||||
@@ -1,302 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Download transit route lines + stops from Overpass, per mode.
|
|
||||||
|
|
||||||
For each mode in config/modes.yaml, runs an Overpass query (with mirror
|
|
||||||
fallback + exponential backoff) and writes:
|
|
||||||
data/raw/osm/{mode}.gpkg (layers: "lines", "stops", EPSG:4326)
|
|
||||||
data/raw/osm/{mode}.overpassql (the exact query text)
|
|
||||||
|
|
||||||
Lines: one MultiLineString per route relation (concatenation of member way
|
|
||||||
geometries), carrying ref/name/network/colour/operator/route/mode tags.
|
|
||||||
Stops: one Point per platform node member, carrying ref/name/mode/route_refs
|
|
||||||
(route refs of routes using this stop, for disambiguation).
|
|
||||||
"""
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import geopandas as gpd
|
|
||||||
import requests
|
|
||||||
import yaml
|
|
||||||
from shapely.geometry import LineString, MultiLineString, Point
|
|
||||||
|
|
||||||
from _common import CONFIG, OSM_RAW, PROCESSED, UA
|
|
||||||
|
|
||||||
MIRRORS = [
|
|
||||||
"https://overpass-api.de/api/interpreter",
|
|
||||||
"https://overpass.kumi.systems/api/interpreter",
|
|
||||||
"https://overpass.private.coffee/api/interpreter",
|
|
||||||
]
|
|
||||||
|
|
||||||
PLATFORM_ROLES = {"platform", "platform_entry", "platform_exit"}
|
|
||||||
STOP_ROLES = {"stop", "stop_entry", "stop_exit", "stop_entry_only", "stop_exit_only"}
|
|
||||||
# bus stops: use platform nodes (where passengers wait)
|
|
||||||
# rail stops: use stop nodes (platforms are often areas, stop nodes are reliable points)
|
|
||||||
BUS_STOP_ROLES = PLATFORM_ROLES
|
|
||||||
RAIL_STOP_ROLES = STOP_ROLES | PLATFORM_ROLES
|
|
||||||
|
|
||||||
|
|
||||||
def build_query(mode_cfg, bbox):
|
|
||||||
s, n, w, e = bbox
|
|
||||||
filters = []
|
|
||||||
for k, v in mode_cfg["osm"].items():
|
|
||||||
if k.endswith("_neq"):
|
|
||||||
filters.append(f'["{k[:-4]}"!="{v}"]')
|
|
||||||
else:
|
|
||||||
filters.append(f'["{k}"="{v}"]')
|
|
||||||
filt = "".join(filters)
|
|
||||||
return f"""[out:json][timeout:180];
|
|
||||||
relation{filt}({s},{w},{n},{e});
|
|
||||||
out geom;
|
|
||||||
>;
|
|
||||||
out body qt;
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
def overpass(ql):
|
|
||||||
last_err = None
|
|
||||||
for mi, mirror in enumerate(MIRRORS):
|
|
||||||
for attempt in range(4):
|
|
||||||
try:
|
|
||||||
r = requests.post(
|
|
||||||
mirror,
|
|
||||||
data={"data": ql},
|
|
||||||
headers={"User-Agent": UA},
|
|
||||||
timeout=300,
|
|
||||||
)
|
|
||||||
if r.status_code == 429 or r.status_code >= 500:
|
|
||||||
raise RuntimeError(f"HTTP {r.status_code}")
|
|
||||||
r.raise_for_status()
|
|
||||||
return r.json()
|
|
||||||
except Exception as e:
|
|
||||||
last_err = e
|
|
||||||
wait = 2 ** (attempt + mi)
|
|
||||||
print(f" [{mirror}] attempt {attempt+1} failed: {e}; retry in {wait}s", flush=True)
|
|
||||||
time.sleep(wait)
|
|
||||||
raise RuntimeError(f"all mirrors failed: {last_err}")
|
|
||||||
|
|
||||||
|
|
||||||
def parse(data, mode):
|
|
||||||
is_bus = mode == "bus"
|
|
||||||
stop_roles = BUS_STOP_ROLES if is_bus else RAIL_STOP_ROLES
|
|
||||||
|
|
||||||
nodes = {}
|
|
||||||
for e in data["elements"]:
|
|
||||||
if e["type"] == "node":
|
|
||||||
nodes[e["id"]] = e
|
|
||||||
|
|
||||||
lines = []
|
|
||||||
# collect route_refs per stop node: {node_id: set(route_refs)}
|
|
||||||
node_routes = {}
|
|
||||||
for e in data["elements"]:
|
|
||||||
if e["type"] != "relation" or "tags" not in e:
|
|
||||||
continue
|
|
||||||
tags = e["tags"]
|
|
||||||
if tags.get("route") is None:
|
|
||||||
continue
|
|
||||||
route_ref = tags.get("ref")
|
|
||||||
# collect line geometries
|
|
||||||
segs = []
|
|
||||||
for m in e["members"]:
|
|
||||||
if m["type"] == "way" and "geometry" in m and m.get("role") not in PLATFORM_ROLES:
|
|
||||||
coords = [(g["lon"], g["lat"]) for g in m["geometry"]]
|
|
||||||
if len(coords) >= 2:
|
|
||||||
segs.append(LineString(coords))
|
|
||||||
geom = MultiLineString(segs) if len(segs) > 1 else (segs[0] if segs else None)
|
|
||||||
if geom is None:
|
|
||||||
continue
|
|
||||||
lines.append({
|
|
||||||
"mode": mode,
|
|
||||||
"ref": route_ref,
|
|
||||||
"name": tags.get("name"),
|
|
||||||
"network": tags.get("network"),
|
|
||||||
"colour": tags.get("colour"),
|
|
||||||
"operator": tags.get("operator"),
|
|
||||||
"route": tags.get("route"),
|
|
||||||
"geometry": geom,
|
|
||||||
})
|
|
||||||
# record route_ref on each stop member node
|
|
||||||
for m in e["members"]:
|
|
||||||
if m["type"] != "node" or m.get("role") not in stop_roles:
|
|
||||||
continue
|
|
||||||
nid = m["ref"]
|
|
||||||
if nid not in nodes:
|
|
||||||
continue
|
|
||||||
node_routes.setdefault(nid, set())
|
|
||||||
if route_ref:
|
|
||||||
node_routes[nid].add(route_ref)
|
|
||||||
|
|
||||||
# build stops from collected nodes
|
|
||||||
stops = []
|
|
||||||
stop_seen = set()
|
|
||||||
for nid, routes in node_routes.items():
|
|
||||||
if nid in stop_seen:
|
|
||||||
continue
|
|
||||||
nd = nodes[nid]
|
|
||||||
if "lat" not in nd or "lon" not in nd:
|
|
||||||
continue
|
|
||||||
stop_seen.add(nid)
|
|
||||||
ntags = nd.get("tags", {})
|
|
||||||
stops.append({
|
|
||||||
"mode": mode,
|
|
||||||
"name": ntags.get("name") or ntags.get("public_transport") or "stop",
|
|
||||||
"ref": ntags.get("ref"),
|
|
||||||
"public_transport": ntags.get("public_transport"),
|
|
||||||
"railway": ntags.get("railway"),
|
|
||||||
"route_refs": ";".join(sorted(routes)),
|
|
||||||
"geometry": Point(nd["lon"], nd["lat"]),
|
|
||||||
})
|
|
||||||
return lines, stops
|
|
||||||
|
|
||||||
|
|
||||||
def stations_query(bbox):
|
|
||||||
s, n, w, e = bbox
|
|
||||||
return f"""[out:json][timeout:180];
|
|
||||||
(
|
|
||||||
node["railway"="station"]({s},{w},{n},{e});
|
|
||||||
node["public_transport"="station"]({s},{w},{n},{e});
|
|
||||||
way["railway"="station"]({s},{w},{n},{e});
|
|
||||||
way["public_transport"="station"]({s},{w},{n},{e});
|
|
||||||
);
|
|
||||||
out center;
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
def parse_stations(data):
|
|
||||||
"""Stations (nodes or area centroids) with proper human-readable names."""
|
|
||||||
rows = []
|
|
||||||
seen = set()
|
|
||||||
for e in data["elements"]:
|
|
||||||
if e["type"] not in ("node", "way"):
|
|
||||||
continue
|
|
||||||
tags = e.get("tags", {})
|
|
||||||
name = tags.get("name")
|
|
||||||
if not name:
|
|
||||||
continue
|
|
||||||
if e["type"] == "node":
|
|
||||||
geom = Point(e["lon"], e["lat"])
|
|
||||||
else:
|
|
||||||
c = e.get("center")
|
|
||||||
if not c:
|
|
||||||
continue
|
|
||||||
geom = Point(c["lon"], c["lat"])
|
|
||||||
key = (name, round(geom.x, 5), round(geom.y, 5))
|
|
||||||
if key in seen:
|
|
||||||
continue
|
|
||||||
seen.add(key)
|
|
||||||
rows.append({
|
|
||||||
"name": name,
|
|
||||||
"railway": tags.get("railway"),
|
|
||||||
"public_transport": tags.get("public_transport"),
|
|
||||||
"station": tags.get("station"),
|
|
||||||
"subway": tags.get("subway"),
|
|
||||||
"light_rail": tags.get("light_rail"),
|
|
||||||
"train": tags.get("train"),
|
|
||||||
"uic_ref": tags.get("uic_ref"),
|
|
||||||
"geometry": geom,
|
|
||||||
})
|
|
||||||
return rows
|
|
||||||
|
|
||||||
|
|
||||||
def orphan_bus_stops_query(bbox):
|
|
||||||
s, n, w, e = bbox
|
|
||||||
return f"""[out:json][timeout:180];
|
|
||||||
(
|
|
||||||
node["highway"="bus_stop"]["public_transport"="platform"]({s},{w},{n},{e});
|
|
||||||
);
|
|
||||||
out body;
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
def parse_orphan_bus_stops(data, known_node_ids):
|
|
||||||
"""Parse standalone bus platform nodes not already captured by routes."""
|
|
||||||
stops = []
|
|
||||||
for e in data["elements"]:
|
|
||||||
if e["type"] != "node" or e["id"] in known_node_ids:
|
|
||||||
continue
|
|
||||||
tags = e.get("tags", {})
|
|
||||||
if tags.get("public_transport") != "platform":
|
|
||||||
continue
|
|
||||||
name = tags.get("name")
|
|
||||||
if not name:
|
|
||||||
continue
|
|
||||||
stops.append({
|
|
||||||
"mode": "bus",
|
|
||||||
"name": name,
|
|
||||||
"ref": tags.get("ref"),
|
|
||||||
"public_transport": tags.get("public_transport"),
|
|
||||||
"railway": tags.get("railway"),
|
|
||||||
"route_refs": "", # orphan — no route membership
|
|
||||||
"geometry": Point(e["lon"], e["lat"]),
|
|
||||||
})
|
|
||||||
return stops
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
modes = yaml.safe_load((CONFIG / "modes.yaml").read_text())["modes"]
|
|
||||||
area = gpd.read_file(PROCESSED / "area.gpkg")
|
|
||||||
w, s, e, n = area.total_bounds # (minx, miny, maxx, maxy) = (west, south, east, north)
|
|
||||||
bbox = (s, n, w, e)
|
|
||||||
|
|
||||||
OSM_RAW.mkdir(parents=True, exist_ok=True)
|
|
||||||
bus_stop_node_ids = set()
|
|
||||||
for mode, cfg in modes.items():
|
|
||||||
ql = build_query(cfg, bbox)
|
|
||||||
(OSM_RAW / f"{mode}.overpassql").write_text(ql)
|
|
||||||
print(f"[{mode}] querying Overpass bbox=({s:.4f},{w:.4f},{n:.4f},{e:.4f})...", flush=True)
|
|
||||||
data = overpass(ql)
|
|
||||||
lines, stops = parse(data, mode)
|
|
||||||
# track bus stop node ids to avoid dupes with orphans
|
|
||||||
if mode == "bus":
|
|
||||||
for e in data["elements"]:
|
|
||||||
if e["type"] == "node" and e.get("tags", {}).get("public_transport") == "platform":
|
|
||||||
bus_stop_node_ids.add(e["id"])
|
|
||||||
print(f" -> {len(lines)} lines, {len(stops)} stops", flush=True)
|
|
||||||
if not lines:
|
|
||||||
continue
|
|
||||||
lgdf = gpd.GeoDataFrame(lines, crs="EPSG:4326")
|
|
||||||
lgdf.to_file(OSM_RAW / f"{mode}.gpkg", driver="GPKG", layer="lines")
|
|
||||||
if stops:
|
|
||||||
sgdf = gpd.GeoDataFrame(stops, crs="EPSG:4326")
|
|
||||||
sgdf.to_file(OSM_RAW / f"{mode}.gpkg", driver="GPKG", layer="stops")
|
|
||||||
|
|
||||||
# orphan bus stops (platform nodes not in any route relation)
|
|
||||||
oql = orphan_bus_stops_query(bbox)
|
|
||||||
(OSM_RAW / "orphan_bus_stops.overpassql").write_text(oql)
|
|
||||||
print("[orphan_bus_stops] querying Overpass...", flush=True)
|
|
||||||
odata = overpass(oql)
|
|
||||||
orphans = parse_orphan_bus_stops(odata, bus_stop_node_ids)
|
|
||||||
print(f" -> {len(orphans)} orphan bus stops (not in route relations)", flush=True)
|
|
||||||
if orphans:
|
|
||||||
# append to bus.gpkg stops layer
|
|
||||||
existing = gpd.read_file(OSM_RAW / "bus.gpkg", layer="stops")
|
|
||||||
combined = gpd.GeoDataFrame(
|
|
||||||
__import__("pandas").concat([existing, gpd.GeoDataFrame(orphans, crs="EPSG:4326")],
|
|
||||||
ignore_index=True),
|
|
||||||
crs="EPSG:4326"
|
|
||||||
)
|
|
||||||
# rewrite stops layer
|
|
||||||
import tempfile, shutil
|
|
||||||
tmp = OSM_RAW / "bus_tmp.gpkg"
|
|
||||||
lines_gdf = gpd.read_file(OSM_RAW / "bus.gpkg", layer="lines")
|
|
||||||
lines_gdf.to_file(tmp, driver="GPKG", layer="lines")
|
|
||||||
combined.to_file(tmp, driver="GPKG", layer="stops")
|
|
||||||
shutil.move(str(tmp), str(OSM_RAW / "bus.gpkg"))
|
|
||||||
print(f" -> bus stops layer now: {len(combined)} total", flush=True)
|
|
||||||
|
|
||||||
# stations (named station nodes/areas) for labelling
|
|
||||||
sql = stations_query(bbox)
|
|
||||||
(OSM_RAW / "stations.overpassql").write_text(sql)
|
|
||||||
print("[stations] querying Overpass...", flush=True)
|
|
||||||
sdata = overpass(sql)
|
|
||||||
st = parse_stations(sdata)
|
|
||||||
print(f" -> {len(st)} named stations", flush=True)
|
|
||||||
if st:
|
|
||||||
gpd.GeoDataFrame(st, crs="EPSG:4326").to_file(
|
|
||||||
OSM_RAW / "stations.gpkg", driver="GPKG", layer="stations"
|
|
||||||
)
|
|
||||||
print("done.")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -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()
|
||||||
@@ -1,41 +1,150 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Convert raw GTFS txt files into portable GeoPackage layers + a colour table.
|
"""Convert raw GTFS txt files into portable GeoPackage layers + a colour table.
|
||||||
|
|
||||||
STATUS: BLOCKED on download_gtfs.py (no feed URL yet).
|
Inputs (data/raw/gtfs): routes.txt, trips.txt, stops.txt, shapes.txt
|
||||||
|
Outputs (data/processed):
|
||||||
Intended outputs (data/processed):
|
gtfs_shapes.gpkg (shapes.txt -> LineString per shape_id, EPSG:4326,
|
||||||
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)
|
gtfs_stops.gpkg (stops.txt -> Point per stop, EPSG:4326)
|
||||||
route_colors.csv (route_id, route_short_name, route_type, route_color)
|
route_colors.csv (route_id, agency_id, route_short_name, route_long_name,
|
||||||
|
route_type, route_color, route_text_color)
|
||||||
|
|
||||||
Uses partridge for fast GTFS parsing. prepare.py reads these when present and
|
The feed is nationwide, so everything is pre-filtered to the Copenhagen area:
|
||||||
falls back to OSM-only data when absent.
|
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 sys
|
||||||
from pathlib import Path
|
|
||||||
|
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
|
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():
|
def main():
|
||||||
if not (GTFS_RAW / "routes.txt").exists():
|
if not (GTFS_RAW / "routes.txt").exists():
|
||||||
print(
|
print(
|
||||||
"gtfs_to_geopackage: BLOCKED — no GTFS data found in "
|
"gtfs_to_geopackage: no GTFS data found in "
|
||||||
f"{GTFS_RAW}. Run download_gtfs.py first once a feed URL is set.",
|
f"{GTFS_RAW}. Run download_gtfs.py first.",
|
||||||
file=sys.stderr,
|
file=sys.stderr,
|
||||||
)
|
)
|
||||||
sys.exit(2)
|
sys.exit(2)
|
||||||
|
|
||||||
# TODO (when GTFS available):
|
PROCESSED.mkdir(parents=True, exist_ok=True)
|
||||||
# import partridge as pt
|
area = load_area()
|
||||||
# import geopandas as gpd
|
|
||||||
# from shapely.geometry import LineString, Point
|
stops = build_stops(area)
|
||||||
# feed = pt.load_geo_feed(str(GTFS_RAW))
|
print(f"stops in area: {len(stops)}", flush=True)
|
||||||
# shapes -> gtfs_shapes.gpkg (LineString per shape_id)
|
|
||||||
# stops -> gtfs_stops.gpkg
|
shapes = build_shapes(area)
|
||||||
# routes -> route_colors.csv (route_id, route_short_name, route_type, route_color)
|
print(f"shapes in area: {len(shapes)}", flush=True)
|
||||||
print("gtfs_to_geopackage: not yet implemented (GTFS feed unavailable).")
|
|
||||||
sys.exit(2)
|
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__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
+355
-143
@@ -1,202 +1,414 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Merge OSM layers into a single master GeoPackage, enriched for rendering.
|
"""Merge GTFS-derived layers into a single master GeoPackage, enriched for rendering.
|
||||||
|
|
||||||
Reads data/raw/osm/{mode}.gpkg (lines + stops) and data/processed/area.gpkg,
|
Reads (from data/processed):
|
||||||
and writes data/processed/master.gpkg with layers "lines" and "stops".
|
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.
|
||||||
|
|
||||||
Enrichment:
|
Writes data/processed/master.gpkg with layers:
|
||||||
- style : from config/modes.yaml (mode -> style key)
|
lines one feature per (style, ref, direction) — the shape that serves
|
||||||
- colour : GTFS route_color (if present) -> OSM colour tag -> palette
|
the most in-area stops (so drawn lines pass the drawn stops)
|
||||||
- bus_category : A / C / S / regular (regex from styling.yaml)
|
stops bus/ferry stops, one point per stop name, pruned to stops
|
||||||
Lines and stops are clipped to the City Pass area polygon.
|
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 re
|
||||||
from pathlib import Path
|
import sys
|
||||||
|
|
||||||
import geopandas as gpd
|
import geopandas as gpd
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
import yaml
|
import yaml
|
||||||
|
from shapely.geometry import box
|
||||||
|
|
||||||
from _common import CONFIG, OSM_RAW, PROCESSED
|
from _common import CONFIG, GTFS_RAW, PROCESSED
|
||||||
|
|
||||||
MIXED_CRS_WARN = "mixed CRS"
|
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():
|
def load_modes():
|
||||||
return yaml.safe_load((CONFIG / "modes.yaml").read_text())["modes"]
|
return _load_modes_yaml().get("modes", {})
|
||||||
|
|
||||||
|
|
||||||
|
def load_exclude():
|
||||||
|
"""Hard route blacklist from modes.yaml: list of (agency, ref)."""
|
||||||
|
return [(e["agency"], str(e["ref"]))
|
||||||
|
for e in _load_modes_yaml().get("exclude", [])]
|
||||||
|
|
||||||
|
|
||||||
|
def load_bus_whitelist():
|
||||||
|
"""Bus whitelist from modes.yaml: dict with categories/refs sets.
|
||||||
|
|
||||||
|
Returns None when the section is absent (= include all buses).
|
||||||
|
"""
|
||||||
|
wl = _load_modes_yaml().get("bus_whitelist")
|
||||||
|
if wl is None:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"categories": set(wl.get("categories") or []),
|
||||||
|
"refs": {str(r) for r in (wl.get("refs") or [])},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def load_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():
|
def load_styling():
|
||||||
return yaml.safe_load((CONFIG / "styling.yaml").read_text())
|
return yaml.safe_load((CONFIG / "styling.yaml").read_text())
|
||||||
|
|
||||||
|
|
||||||
def collect_lines(modes):
|
def load_area():
|
||||||
frames = []
|
area = gpd.read_file(PROCESSED / "area.gpkg")
|
||||||
for mode, cfg in modes.items():
|
return area.geometry.union_all()
|
||||||
p = OSM_RAW / f"{mode}.gpkg"
|
|
||||||
if not p.exists():
|
|
||||||
continue
|
|
||||||
g = gpd.read_file(p, layer="lines")
|
|
||||||
if g.empty:
|
|
||||||
continue
|
|
||||||
g["mode"] = mode
|
|
||||||
g["style"] = cfg["style"]
|
|
||||||
g["gtfs_route_type"] = cfg.get("gtfs_route_type")
|
|
||||||
frames.append(g)
|
|
||||||
if not frames:
|
|
||||||
return gpd.GeoDataFrame(columns=["mode", "ref", "style", "geometry"], crs="EPSG:4326")
|
|
||||||
return pd.concat(frames, ignore_index=True)
|
|
||||||
|
|
||||||
|
|
||||||
def collect_stops(modes):
|
def classify(agency, route_type, modes):
|
||||||
frames = []
|
"""Map a GTFS (agency_name, route_type) pair to a style key, or None."""
|
||||||
for mode, cfg in modes.items():
|
for style, cfg in modes.items():
|
||||||
p = OSM_RAW / f"{mode}.gpkg"
|
if agency not in cfg.get("agencies", []):
|
||||||
if not p.exists():
|
|
||||||
continue
|
continue
|
||||||
try:
|
types = cfg.get("route_types")
|
||||||
g = gpd.read_file(p, layer="stops")
|
if types and route_type not in types:
|
||||||
except Exception:
|
|
||||||
continue
|
continue
|
||||||
if g.empty:
|
return style
|
||||||
continue
|
|
||||||
g["mode"] = mode
|
|
||||||
g["style"] = cfg["style"]
|
|
||||||
frames.append(g)
|
|
||||||
if not frames:
|
|
||||||
return None
|
return None
|
||||||
return pd.concat(frames, ignore_index=True)
|
|
||||||
|
|
||||||
|
|
||||||
def gtfs_colour_map():
|
def load_routes(modes, exclude=()):
|
||||||
"""Return {(route_short_name, route_type): route_color} if GTFS data exists."""
|
"""route_colors.csv joined with agency names and classified by style."""
|
||||||
csv = PROCESSED / "route_colors.csv"
|
agencies = pd.read_csv(GTFS_RAW / "agency.txt", dtype=str)
|
||||||
if not csv.exists():
|
agency_names = dict(zip(agencies["agency_id"], agencies["agency_name"]))
|
||||||
return {}
|
routes = pd.read_csv(PROCESSED / "route_colors.csv", dtype=str)
|
||||||
df = pd.read_csv(csv)
|
routes["route_type"] = pd.to_numeric(routes["route_type"], errors="coerce")
|
||||||
out = {}
|
routes["agency_name"] = routes["agency_id"].map(agency_names)
|
||||||
for _, r in df.iterrows():
|
routes["style"] = [
|
||||||
name = str(r.get("route_short_name") or "").strip()
|
classify(a, t, modes)
|
||||||
if name:
|
for a, t in zip(routes["agency_name"], routes["route_type"])
|
||||||
out[(name, r.get("route_type"))] = r.get("route_color")
|
]
|
||||||
return out
|
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):
|
def categorise_bus(ref, patterns):
|
||||||
if ref is None:
|
s = "" if ref is None else str(ref)
|
||||||
return "regular"
|
|
||||||
s = str(ref)
|
|
||||||
for cat, pat in patterns.items():
|
for cat, pat in patterns.items():
|
||||||
if re.search(pat, s):
|
if re.search(pat, s):
|
||||||
return cat
|
return cat
|
||||||
return "regular"
|
return "regular"
|
||||||
|
|
||||||
|
|
||||||
def resolve_colour(row, palette, gtfs):
|
def resolve_colour(style, ref, bus_category, gtfs_colour, palette):
|
||||||
style = row["style"]
|
# 1. GTFS route_color (mostly empty in this feed)
|
||||||
ref = row.get("ref")
|
if isinstance(gtfs_colour, str) and gtfs_colour.strip():
|
||||||
colour = row.get("colour")
|
return gtfs_colour
|
||||||
# 1. GTFS (if available)
|
# 2. palette
|
||||||
if gtfs and isinstance(ref, str):
|
|
||||||
c = gtfs.get((ref, row.get("gtfs_route_type")))
|
|
||||||
if c:
|
|
||||||
return str(c)
|
|
||||||
# 2. OSM colour tag (only accept real hex/named strings, not NA/None)
|
|
||||||
if isinstance(colour, str) and colour.strip():
|
|
||||||
return colour
|
|
||||||
# 3. palette
|
|
||||||
p = palette.get(style, {})
|
p = palette.get(style, {})
|
||||||
if style == "bus":
|
if style == "bus":
|
||||||
cat = row.get("bus_category")
|
return p.get(bus_category) or p.get("default")
|
||||||
if not isinstance(cat, str):
|
return p.get(ref) or p.get("default")
|
||||||
cat = "regular"
|
|
||||||
return p.get(cat) or p.get("default")
|
|
||||||
key = ref if isinstance(ref, str) else None
|
def shape_points_per_km(shapes_25832):
|
||||||
return p.get(key) or p.get("default")
|
"""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():
|
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()
|
modes = load_modes()
|
||||||
styling = load_styling()
|
styling = load_styling()
|
||||||
palette = styling["palette"]
|
area_geom = load_area()
|
||||||
bus_patterns = styling["bus_filters"]["categories"]
|
|
||||||
|
|
||||||
area = gpd.read_file(PROCESSED / "area.gpkg")
|
south_bound = load_south_bound()
|
||||||
area_geom = area.geometry.union_all()
|
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)
|
||||||
|
|
||||||
lines = collect_lines(modes)
|
routes = load_routes(modes, load_exclude())
|
||||||
print(f"collected {len(lines)} raw lines across modes", flush=True)
|
|
||||||
|
|
||||||
# bus categorisation (only meaningful for bus style)
|
bus_wl = load_bus_whitelist()
|
||||||
lines["bus_category"] = [
|
if bus_wl is not None:
|
||||||
categorise_bus(r, bus_patterns) if s == "bus" else None
|
patterns = styling["bus_filters"]["categories"]
|
||||||
for r, s in zip(lines.get("ref"), lines["style"])
|
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)
|
||||||
|
|
||||||
gtfs = gtfs_colour_map()
|
print(f"routes classified: {routes['style'].notna().sum()} of "
|
||||||
if gtfs:
|
f"{len(routes)} map to a style", flush=True)
|
||||||
print(f"GTFS colours loaded: {len(gtfs)} routes", flush=True)
|
|
||||||
else:
|
|
||||||
print("no GTFS colour data; using OSM colour -> palette", flush=True)
|
|
||||||
|
|
||||||
lines["colour_final"] = [
|
area_stop_ids = set(
|
||||||
resolve_colour(r, palette, gtfs) for _, r in lines.iterrows()
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
# clip to area
|
lines = build_lines(routes, styling, area_geom, trips, st, area_stop_ids)
|
||||||
lines = lines[~lines.geometry.isna() & lines.geometry.is_valid]
|
print(f"lines: {len(lines)} features (one per style/ref/direction)",
|
||||||
clipped = gpd.clip(lines, area_geom)
|
flush=True)
|
||||||
print(f"clipped to area: {len(clipped)} line features remain", flush=True)
|
|
||||||
|
|
||||||
out_cols = [
|
pole_styles, pole_refs = build_pole_classes(
|
||||||
"mode", "style", "ref", "name", "network", "operator", "route",
|
routes, modes, trips, st, area_stop_ids
|
||||||
"colour", "bus_category", "colour_final", "geometry",
|
)
|
||||||
]
|
stops, stations = build_stops_and_stations(
|
||||||
for c in out_cols:
|
pole_styles, pole_refs, area_geom, lines
|
||||||
if c not in clipped.columns:
|
)
|
||||||
clipped[c] = None
|
print(f"stops: {len(stops)} (bus + ferry); stations: {len(stations)}",
|
||||||
clipped = clipped.set_geometry("geometry")
|
flush=True)
|
||||||
clipped = gpd.GeoDataFrame(clipped[out_cols], crs="EPSG:4326")
|
|
||||||
|
|
||||||
PROCESSED.mkdir(parents=True, exist_ok=True)
|
|
||||||
# drop existing master.gpkg so layer overwrite is clean
|
|
||||||
out = PROCESSED / "master.gpkg"
|
out = PROCESSED / "master.gpkg"
|
||||||
if out.exists():
|
if out.exists():
|
||||||
out.unlink()
|
out.unlink()
|
||||||
clipped.to_file(out, driver="GPKG", layer="lines")
|
lines.to_file(out, driver="GPKG", layer="lines")
|
||||||
print(f"wrote lines layer: {len(clipped)} features", flush=True)
|
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)
|
||||||
|
|
||||||
# stops (stop_positions along routes)
|
|
||||||
stops = collect_stops(modes)
|
|
||||||
if stops is not None and not stops.empty:
|
|
||||||
stops = stops[~stops.geometry.isna() & stops.geometry.is_valid]
|
|
||||||
stops_clipped = gpd.clip(stops, area_geom)
|
|
||||||
keep = ["mode", "style", "name", "ref", "public_transport", "railway", "route_refs", "geometry"]
|
|
||||||
for c in keep:
|
|
||||||
if c not in stops_clipped.columns:
|
|
||||||
stops_clipped[c] = None
|
|
||||||
stops_clipped = gpd.GeoDataFrame(stops_clipped[keep], crs="EPSG:4326")
|
|
||||||
stops_clipped.to_file(out, driver="GPKG", layer="stops")
|
|
||||||
print(f"wrote stops layer: {len(stops_clipped)} features", flush=True)
|
|
||||||
else:
|
|
||||||
print("no stops to write", flush=True)
|
|
||||||
|
|
||||||
# stations (named station nodes/areas) for labelling
|
|
||||||
st_path = OSM_RAW / "stations.gpkg"
|
|
||||||
if st_path.exists():
|
|
||||||
st = gpd.read_file(st_path, layer="stations")
|
|
||||||
if not st.empty:
|
|
||||||
st = st[~st.geometry.isna() & st.geometry.is_valid]
|
|
||||||
st_clipped = gpd.clip(st, area_geom)
|
|
||||||
st_clipped = gpd.GeoDataFrame(st_clipped, crs="EPSG:4326")
|
|
||||||
st_clipped.to_file(out, driver="GPKG", layer="stations")
|
|
||||||
print(f"wrote stations layer: {len(st_clipped)} named stations", flush=True)
|
|
||||||
else:
|
|
||||||
print("no stations file; labelling will be limited", flush=True)
|
|
||||||
|
|
||||||
# summary by style
|
|
||||||
print("\nsummary by style:")
|
print("\nsummary by style:")
|
||||||
for style, grp in clipped.groupby("style"):
|
for style, grp in lines.groupby("style"):
|
||||||
refs = grp["ref"].dropna().unique()
|
refs = grp["ref"].dropna().unique()
|
||||||
print(f" {style:10s}: {len(grp):4d} feats, {len(refs):3d} refs")
|
print(f" {style:10s}: {len(grp):4d} feats, {len(refs):3d} refs")
|
||||||
|
|
||||||
|
|||||||
+42
-176
@@ -1,14 +1,19 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Render the Copenhagen transit map to PNG/SVG/PDF.
|
"""Render the Copenhagen transit map to PNG/SVG/PDF.
|
||||||
|
|
||||||
Reads data/processed/master.gpkg (layers: lines, stations) + area.gpkg +
|
Reads data/processed/master.gpkg (layers: lines, stops, stations — all
|
||||||
config/styling.yaml, applies CLI filters, and composes a printable map:
|
GTFS-derived by prepare.py) + area.gpkg + config/styling.yaml, applies CLI
|
||||||
- Carto Positron (no labels) basemap via contextily (EPSG:3857)
|
filters, and composes a printable map:
|
||||||
|
- Carto/Esri grey basemap via contextily (EPSG:3857)
|
||||||
- two-tone lines: dark casing + colour body, per route
|
- two-tone lines: dark casing + colour body, per route
|
||||||
- z-order: bus (bottom) -> s_tog -> light_rail -> regional -> metro (top)
|
- z-order: bus (bottom) -> s_tog -> light_rail -> regional -> metro (top)
|
||||||
- shapeburst fade mask outside the City Pass area
|
- shapeburst fade mask outside the City Pass area
|
||||||
- station labels (metro + S-tog) via adjustText
|
- 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:
|
Usage:
|
||||||
uv run python render.py # default: all modes, PNG, markers only
|
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 --labels # add station name labels
|
||||||
@@ -25,6 +30,7 @@ import matplotlib
|
|||||||
matplotlib.use("Agg")
|
matplotlib.use("Agg")
|
||||||
import matplotlib.pyplot as plt
|
import matplotlib.pyplot as plt
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
import yaml
|
import yaml
|
||||||
from affine import Affine
|
from affine import Affine
|
||||||
from adjustText import adjust_text
|
from adjustText import adjust_text
|
||||||
@@ -81,15 +87,11 @@ def filter_lines(lines, args):
|
|||||||
refs = list(dict.fromkeys(bus["ref"].dropna()))
|
refs = list(dict.fromkeys(bus["ref"].dropna()))
|
||||||
keep_refs = set(refs[:args.max_bus_routes])
|
keep_refs = set(refs[:args.max_bus_routes])
|
||||||
bus = bus[bus["ref"].isin(keep_refs)]
|
bus = bus[bus["ref"].isin(keep_refs)]
|
||||||
out = gpd.GeoDataFrame(pd_concat([other, bus]), crs=out.crs)
|
out = gpd.GeoDataFrame(pd.concat([other, bus], ignore_index=True),
|
||||||
|
crs=out.crs)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def pd_concat(frames):
|
|
||||||
import pandas as pd
|
|
||||||
return pd.concat(frames, ignore_index=True)
|
|
||||||
|
|
||||||
|
|
||||||
def add_basemap(ax, styling, zoom=None):
|
def add_basemap(ax, styling, zoom=None):
|
||||||
if zoom is None:
|
if zoom is None:
|
||||||
zoom = styling["basemap"]["zoom"]
|
zoom = styling["basemap"]["zoom"]
|
||||||
@@ -158,60 +160,23 @@ def plot_lines(ax, lines, styling):
|
|||||||
capstyle="round", joinstyle="round")
|
capstyle="round", joinstyle="round")
|
||||||
|
|
||||||
|
|
||||||
|
RAIL_STYLES = ("metro", "s_tog", "light_rail", "regional")
|
||||||
|
|
||||||
|
|
||||||
def classify_station(r):
|
def classify_station(r):
|
||||||
"""Return the style key for a station row, or None."""
|
"""Style key for a station row, or None. Stations are pre-classified."""
|
||||||
if r.get("subway") == "yes" or r.get("station") == "subway":
|
s = r.get("style")
|
||||||
return "metro"
|
return s if s in RAIL_STYLES else None
|
||||||
if r.get("light_rail") == "yes" or r.get("station") == "light_rail":
|
|
||||||
return "s_tog"
|
|
||||||
if r.get("railway") == "station" or r.get("train") == "yes":
|
|
||||||
return "regional"
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
DISTANCE_CRS = "EPSG:25832" # UTM 32N — accurate meters for Copenhagen
|
|
||||||
|
|
||||||
|
|
||||||
def cluster_stops(coords, threshold_m):
|
|
||||||
"""Cluster (x, y) coordinates in EPSG:3857 within threshold_m (real meters).
|
|
||||||
|
|
||||||
Reprojects to UTM 32N for accurate distance computation, clusters, then
|
|
||||||
returns centroids in EPSG:3857 for plotting.
|
|
||||||
"""
|
|
||||||
from pyproj import Transformer
|
|
||||||
from scipy.cluster.hierarchy import fcluster, linkage
|
|
||||||
from scipy.spatial.distance import pdist
|
|
||||||
|
|
||||||
coords = np.asarray(coords)
|
|
||||||
if len(coords) <= 1:
|
|
||||||
return coords.tolist() if len(coords) else []
|
|
||||||
|
|
||||||
# reproject to UTM for accurate distances
|
|
||||||
to_utm = Transformer.from_crs(TARGET_CRS, DISTANCE_CRS, always_xy=True)
|
|
||||||
back = Transformer.from_crs(DISTANCE_CRS, TARGET_CRS, always_xy=True)
|
|
||||||
utm = np.array([to_utm.transform(x, y) for x, y in coords])
|
|
||||||
|
|
||||||
dists = pdist(utm)
|
|
||||||
links = linkage(dists, method="single")
|
|
||||||
labels = fcluster(links, t=threshold_m, criterion="distance")
|
|
||||||
|
|
||||||
centroids = []
|
|
||||||
for label in set(labels):
|
|
||||||
members = utm[labels == label]
|
|
||||||
ux, uy = members.mean(axis=0)
|
|
||||||
cx, cy = back.transform(ux, uy)
|
|
||||||
centroids.append((cx, cy))
|
|
||||||
return centroids
|
|
||||||
|
|
||||||
|
|
||||||
def plot_stations(ax, stations, styling, active_styles):
|
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:
|
if stations is None or stations.empty:
|
||||||
return
|
return
|
||||||
cfg = styling.get("stations", {})
|
cfg = styling.get("stations", {})
|
||||||
if not cfg.get("show", True):
|
if not cfg.get("show", True):
|
||||||
return
|
return
|
||||||
marker_styles = set(cfg.get("styles", ["metro", "s_tog", "regional"]))
|
marker_styles = set(cfg.get("styles", ["metro", "s_tog", "regional"]))
|
||||||
cluster_cfg = cfg.get("stop_cluster_m", {})
|
|
||||||
mk = cfg.get("marker", {})
|
mk = cfg.get("marker", {})
|
||||||
sizes = mk.get("size", {})
|
sizes = mk.get("size", {})
|
||||||
shape = mk.get("shape", "circle")
|
shape = mk.get("shape", "circle")
|
||||||
@@ -220,37 +185,25 @@ def plot_stations(ax, stations, styling, active_styles):
|
|||||||
lw = mk.get("linewidth", 0.8)
|
lw = mk.get("linewidth", 0.8)
|
||||||
marker = "o" if shape == "circle" else "s"
|
marker = "o" if shape == "circle" else "s"
|
||||||
|
|
||||||
# group coordinates by style, then by name for clustering
|
|
||||||
pts_by_style = {}
|
pts_by_style = {}
|
||||||
names_by_style = {}
|
|
||||||
for _, r in stations.iterrows():
|
for _, r in stations.iterrows():
|
||||||
s = classify_station(r)
|
s = classify_station(r)
|
||||||
if s is None or s not in marker_styles or s not in active_styles:
|
if s is None or s not in marker_styles or s not in active_styles:
|
||||||
continue
|
continue
|
||||||
coord = (r.geometry.x, r.geometry.y)
|
pts_by_style.setdefault(s, []).append((r.geometry.x, r.geometry.y))
|
||||||
name = r.get("name") if isinstance(r.get("name"), str) else None
|
|
||||||
pts_by_style.setdefault(s, []).append(coord)
|
|
||||||
names_by_style.setdefault(s, {}).setdefault(name, []).append(coord)
|
|
||||||
|
|
||||||
zord = styling["zorder"]
|
zord = styling["zorder"]
|
||||||
top_z = max(zord.values()) + 1 # all markers above all lines
|
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)):
|
for s in sorted(pts_by_style, key=lambda k: zord.get(k, 0)):
|
||||||
threshold = cluster_cfg.get(s, 50) if isinstance(cluster_cfg, dict) else cluster_cfg
|
pts = pts_by_style[s]
|
||||||
# cluster same-name stations to one marker; keep unnamed as-is
|
|
||||||
centroids = []
|
|
||||||
for name, coords in names_by_style[s].items():
|
|
||||||
if name and len(coords) > 1:
|
|
||||||
centroids.extend(cluster_stops(coords, threshold))
|
|
||||||
else:
|
|
||||||
centroids.extend(coords)
|
|
||||||
sz = sizes.get(s, 3.0)
|
sz = sizes.get(s, 3.0)
|
||||||
ax.scatter([p[0] for p in centroids], [p[1] for p in centroids],
|
ax.scatter([p[0] for p in pts], [p[1] for p in pts],
|
||||||
s=sz ** 2, marker=marker, c=fill, edgecolors=edge,
|
s=sz ** 2, marker=marker, c=fill, edgecolors=edge,
|
||||||
linewidths=lw, zorder=top_z, alpha=1.0)
|
linewidths=lw, zorder=top_z, alpha=1.0)
|
||||||
|
|
||||||
|
|
||||||
def rail_station_names(stations):
|
def rail_station_names(stations):
|
||||||
"""Return the set of names for rail stations (metro/s_tog/regional)."""
|
"""Set of names of rail stations (metro/s_tog/light_rail/regional)."""
|
||||||
if stations is None or stations.empty:
|
if stations is None or stations.empty:
|
||||||
return set()
|
return set()
|
||||||
names = set()
|
names = set()
|
||||||
@@ -262,109 +215,34 @@ def rail_station_names(stations):
|
|||||||
return names
|
return names
|
||||||
|
|
||||||
|
|
||||||
def group_by_route_membership(rows, cluster_m):
|
|
||||||
"""Group stop rows by (name, shared route_ref) into distinct stops.
|
|
||||||
|
|
||||||
Two same-name stops that share at least one route_ref are the same stop.
|
|
||||||
Stops on disjoint routes are different stops. Within each group,
|
|
||||||
platform duplicates are merged by distance clustering.
|
|
||||||
Returns a list of (x, y) centroids.
|
|
||||||
"""
|
|
||||||
if rows is None or rows.empty:
|
|
||||||
return []
|
|
||||||
|
|
||||||
coords = [(r.geometry.x, r.geometry.y) for _, r in rows.iterrows()]
|
|
||||||
route_sets = []
|
|
||||||
for _, r in rows.iterrows():
|
|
||||||
rr = r.get("route_refs")
|
|
||||||
if isinstance(rr, str) and rr:
|
|
||||||
route_sets.append(set(rr.split(";")))
|
|
||||||
else:
|
|
||||||
route_sets.append(set())
|
|
||||||
|
|
||||||
n = len(coords)
|
|
||||||
# union-find: stops sharing a route are connected
|
|
||||||
parent = list(range(n))
|
|
||||||
|
|
||||||
def find(x):
|
|
||||||
while parent[x] != x:
|
|
||||||
parent[x] = parent[parent[x]]
|
|
||||||
x = parent[x]
|
|
||||||
return x
|
|
||||||
|
|
||||||
def union(a, b):
|
|
||||||
ra, rb = find(a), find(b)
|
|
||||||
if ra != rb:
|
|
||||||
parent[ra] = rb
|
|
||||||
|
|
||||||
for i in range(n):
|
|
||||||
for j in range(i + 1, n):
|
|
||||||
if route_sets[i] & route_sets[j]:
|
|
||||||
union(i, j)
|
|
||||||
|
|
||||||
# group indices by connected component
|
|
||||||
components = {}
|
|
||||||
for i in range(n):
|
|
||||||
root = find(i)
|
|
||||||
components.setdefault(root, []).append(i)
|
|
||||||
|
|
||||||
centroids = []
|
|
||||||
for indices in components.values():
|
|
||||||
comp_coords = [coords[i] for i in indices]
|
|
||||||
comp_routes = [route_sets[i] for i in indices]
|
|
||||||
has_routes = any(rs for rs in comp_routes)
|
|
||||||
if len(comp_coords) == 1:
|
|
||||||
centroids.append(comp_coords[0])
|
|
||||||
elif has_routes:
|
|
||||||
# stops sharing a route are the same stop; merge to centroid
|
|
||||||
centroids.append(tuple(np.mean(comp_coords, axis=0)))
|
|
||||||
else:
|
|
||||||
# no route info (orphans); fall back to distance clustering
|
|
||||||
centroids.extend(cluster_stops(comp_coords, cluster_m))
|
|
||||||
return centroids
|
|
||||||
|
|
||||||
|
|
||||||
def plot_bus_stops(ax, stops, styling, active_styles, rail_names=None):
|
def plot_bus_stops(ax, stops, styling, active_styles, rail_names=None):
|
||||||
"""Draw small markers for bus stops from the stops layer.
|
"""Draw small markers for bus stops (pre-collapsed: one point per name).
|
||||||
|
|
||||||
Bus stops whose name matches a rail station are skipped — the rail
|
A bus stop whose name exactly matches a rail station is skipped — the
|
||||||
station marker represents that stop. Stops are grouped by shared
|
station marker represents it.
|
||||||
route membership: same-name stops on disjoint routes are distinct.
|
|
||||||
"""
|
"""
|
||||||
if stops is None or stops.empty or "bus" not in active_styles:
|
if stops is None or stops.empty or not ({"bus", "ferry"} & active_styles):
|
||||||
return
|
return
|
||||||
cfg = styling.get("stations", {})
|
cfg = styling.get("stations", {})
|
||||||
if not cfg.get("show", True) or "bus" not in set(cfg.get("styles", [])):
|
if not cfg.get("show", True) or "bus" not in set(cfg.get("styles", [])):
|
||||||
return
|
return
|
||||||
bus = stops[stops["style"] == "bus"]
|
bus = stops[stops["style"].isin(["bus", "ferry"])]
|
||||||
if bus.empty:
|
if bus.empty:
|
||||||
return
|
return
|
||||||
|
|
||||||
if rail_names is None:
|
rail_names = rail_names or set()
|
||||||
rail_names = set()
|
|
||||||
|
|
||||||
cluster_cfg = cfg.get("stop_cluster_m", {})
|
|
||||||
cluster_m = cluster_cfg.get("bus", 50) if isinstance(cluster_cfg, dict) else cluster_cfg
|
|
||||||
mk = cfg.get("marker", {})
|
mk = cfg.get("marker", {})
|
||||||
sizes = mk.get("size", {})
|
sz = mk.get("size", {}).get("bus", 1.5)
|
||||||
sz = sizes.get("bus", 1.5)
|
|
||||||
fill = mk.get("fill", "white")
|
fill = mk.get("fill", "white")
|
||||||
edge = mk.get("edge", "#2b2b2b")
|
edge = mk.get("edge", "#2b2b2b")
|
||||||
lw = mk.get("linewidth", 0.8)
|
lw = mk.get("linewidth", 0.8)
|
||||||
shape = mk.get("shape", "circle")
|
marker = "o" if mk.get("shape", "circle") == "circle" else "s"
|
||||||
marker = "o" if shape == "circle" else "s"
|
|
||||||
top_z = max(styling["zorder"].values()) + 1
|
top_z = max(styling["zorder"].values()) + 1
|
||||||
|
|
||||||
# group by name, then by route membership; skip rail station names
|
pts = [(r.geometry.x, r.geometry.y)
|
||||||
centroids = []
|
for _, r in bus.iterrows()
|
||||||
skipped = 0
|
if not (isinstance(r.get("name"), str) and r.get("name") in rail_names)]
|
||||||
for name, grp in bus.groupby("name"):
|
ax.scatter([p[0] for p in pts], [p[1] for p in pts],
|
||||||
if name and name in rail_names:
|
|
||||||
skipped += len(grp)
|
|
||||||
continue
|
|
||||||
centroids.extend(group_by_route_membership(grp, cluster_m))
|
|
||||||
|
|
||||||
ax.scatter([p[0] for p in centroids], [p[1] for p in centroids],
|
|
||||||
s=sz ** 2, marker=marker, c=fill, edgecolors=edge,
|
s=sz ** 2, marker=marker, c=fill, edgecolors=edge,
|
||||||
linewidths=lw, zorder=top_z, alpha=0.8)
|
linewidths=lw, zorder=top_z, alpha=0.8)
|
||||||
|
|
||||||
@@ -379,33 +257,21 @@ def label_stations(ax, stations, styling, active_styles):
|
|||||||
fmin = cfg.get("min_fontsize", 5)
|
fmin = cfg.get("min_fontsize", 5)
|
||||||
fmax = cfg.get("max_fontsize", 9)
|
fmax = cfg.get("max_fontsize", 9)
|
||||||
|
|
||||||
def is_metro(r):
|
# stations are unique per (name, style); dedupe by name for labelling
|
||||||
return classify_station(r) == "metro"
|
seen = set()
|
||||||
|
uniq = []
|
||||||
def is_stog(r):
|
|
||||||
return classify_station(r) == "s_tog"
|
|
||||||
|
|
||||||
pts = []
|
|
||||||
for _, r in stations.iterrows():
|
for _, r in stations.iterrows():
|
||||||
s = classify_station(r)
|
s = classify_station(r)
|
||||||
if s not in label_styles:
|
if s not in label_styles:
|
||||||
continue
|
continue
|
||||||
name = r.get("name")
|
name = r.get("name")
|
||||||
if not isinstance(name, str) or not name.strip():
|
if not isinstance(name, str) or not name.strip() or name in seen:
|
||||||
continue
|
continue
|
||||||
pts.append((r.geometry.x, r.geometry.y, name, s == "metro"))
|
seen.add(name)
|
||||||
|
uniq.append((r.geometry.x, r.geometry.y, name, s == "metro"))
|
||||||
|
|
||||||
if not pts:
|
if not uniq:
|
||||||
return
|
return
|
||||||
# dedupe by name (keep first location)
|
|
||||||
seen = {}
|
|
||||||
uniq = []
|
|
||||||
for x, y, name, is_m in pts:
|
|
||||||
if name in seen:
|
|
||||||
continue
|
|
||||||
seen[name] = True
|
|
||||||
uniq.append((x, y, name, is_m))
|
|
||||||
|
|
||||||
texts = []
|
texts = []
|
||||||
for x, y, name, is_m in uniq:
|
for x, y, name, is_m in uniq:
|
||||||
fs = fmax if is_m else fmin
|
fs = fmax if is_m else fmin
|
||||||
@@ -485,7 +351,7 @@ def main():
|
|||||||
fontsize=fig_w * 1.1, fontweight="bold", color="#222",
|
fontsize=fig_w * 1.1, fontweight="bold", color="#222",
|
||||||
bbox=dict(boxstyle="round,pad=0.3", fc="white", ec="none", alpha=0.7))
|
bbox=dict(boxstyle="round,pad=0.3", fc="white", ec="none", alpha=0.7))
|
||||||
ax.text(0.01, 0.01,
|
ax.text(0.01, 0.01,
|
||||||
"Data: © OpenStreetMap contributors (ODbL) · Base: Esri, HERE",
|
"Transit: Rejseplanen GTFS · Area: © OpenStreetMap contributors (ODbL) · Base: Esri, HERE",
|
||||||
transform=ax.transAxes, ha="left", va="bottom",
|
transform=ax.transAxes, ha="left", va="bottom",
|
||||||
fontsize=max(5, fig_w * 0.5), color="#666")
|
fontsize=max(5, fig_w * 0.5), color="#666")
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ dependencies = [
|
|||||||
"matplotlib>=3.9",
|
"matplotlib>=3.9",
|
||||||
"contextily>=1.6",
|
"contextily>=1.6",
|
||||||
"pyogrio>=0.10",
|
"pyogrio>=0.10",
|
||||||
"partridge>=1.1",
|
|
||||||
"requests>=2.31",
|
"requests>=2.31",
|
||||||
"pyyaml>=6.0",
|
"pyyaml>=6.0",
|
||||||
"adjusttext>=1.2",
|
"adjusttext>=1.2",
|
||||||
|
|||||||
@@ -415,15 +415,6 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" },
|
{ url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "isoweek"
|
|
||||||
version = "1.3.3"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/9a/79/68c68f92d8d55b3b790224bdba879a8fe77756a42c0b719e2e695756eb34/isoweek-1.3.3.tar.gz", hash = "sha256:73f3f7bac443e05a3ab45c32a72048b0c4f26d53d81462ec4b142c7581d3ffe8", size = 6847, upload-time = "2017-01-04T15:16:31.072Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c2/d4/fe7e2637975c476734fcbf53776e650a29680194eb0dd21dbdc020ca92de/isoweek-1.3.3-py2.py3-none-any.whl", hash = "sha256:d3324c497d97f1534669de225ec877964222e4cc773a4a99063086f7a4e342b6", size = 7138, upload-time = "2017-01-04T15:16:33.969Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "jetlag-maps"
|
name = "jetlag-maps"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -433,7 +424,6 @@ dependencies = [
|
|||||||
{ name = "contextily" },
|
{ name = "contextily" },
|
||||||
{ name = "geopandas" },
|
{ name = "geopandas" },
|
||||||
{ name = "matplotlib" },
|
{ name = "matplotlib" },
|
||||||
{ name = "partridge" },
|
|
||||||
{ name = "pyogrio" },
|
{ name = "pyogrio" },
|
||||||
{ name = "pyyaml" },
|
{ name = "pyyaml" },
|
||||||
{ name = "requests" },
|
{ name = "requests" },
|
||||||
@@ -448,7 +438,6 @@ requires-dist = [
|
|||||||
{ name = "contextily", specifier = ">=1.6" },
|
{ name = "contextily", specifier = ">=1.6" },
|
||||||
{ name = "geopandas", specifier = ">=1.1" },
|
{ name = "geopandas", specifier = ">=1.1" },
|
||||||
{ name = "matplotlib", specifier = ">=3.9" },
|
{ name = "matplotlib", specifier = ">=3.9" },
|
||||||
{ name = "partridge", specifier = ">=1.1" },
|
|
||||||
{ name = "pyogrio", specifier = ">=0.10" },
|
{ name = "pyogrio", specifier = ">=0.10" },
|
||||||
{ name = "pyyaml", specifier = ">=6.0" },
|
{ name = "pyyaml", specifier = ">=6.0" },
|
||||||
{ name = "requests", specifier = ">=2.31" },
|
{ name = "requests", specifier = ">=2.31" },
|
||||||
@@ -652,15 +641,6 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/b2/d6/de0cc74f8d36976aeca0dd2e9cbf711882ff8e177495115fd82459afdc4d/mercantile-1.2.1-py3-none-any.whl", hash = "sha256:30f457a73ee88261aab787b7069d85961a5703bb09dc57a170190bc042cd023f", size = 14779, upload-time = "2021-04-21T14:42:39.841Z" },
|
{ url = "https://files.pythonhosted.org/packages/b2/d6/de0cc74f8d36976aeca0dd2e9cbf711882ff8e177495115fd82459afdc4d/mercantile-1.2.1-py3-none-any.whl", hash = "sha256:30f457a73ee88261aab787b7069d85961a5703bb09dc57a170190bc042cd023f", size = 14779, upload-time = "2021-04-21T14:42:39.841Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "networkx"
|
|
||||||
version = "3.6.1"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025, upload-time = "2025-12-08T17:02:39.908Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "numpy"
|
name = "numpy"
|
||||||
version = "2.5.3"
|
version = "2.5.3"
|
||||||
@@ -789,21 +769,6 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/d2/cf/6a51b2c38980e04c279fd2fa908a1b0982064e860444acfca4ec2e2c8359/pandas-3.0.5-cp314-cp314t-win_arm64.whl", hash = "sha256:3c5015fd1730fbf883647e88068176c839c102cea883ba1769a6f4593bfc1f8c", size = 9509776, upload-time = "2026-07-22T22:19:26.694Z" },
|
{ url = "https://files.pythonhosted.org/packages/d2/cf/6a51b2c38980e04c279fd2fa908a1b0982064e860444acfca4ec2e2c8359/pandas-3.0.5-cp314-cp314t-win_arm64.whl", hash = "sha256:3c5015fd1730fbf883647e88068176c839c102cea883ba1769a6f4593bfc1f8c", size = 9509776, upload-time = "2026-07-22T22:19:26.694Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "partridge"
|
|
||||||
version = "1.1.2"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "charset-normalizer" },
|
|
||||||
{ name = "isoweek" },
|
|
||||||
{ name = "networkx" },
|
|
||||||
{ name = "pandas" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/75/76/4bcf12ceb495f9e1d9861ed9211e348dd15fabb86208be0491e7ab1b3a96/partridge-1.1.2.tar.gz", hash = "sha256:9d9ba044f4123a83c6419dde073c15ac71b63301ed398386d6d3101ac8381e34", size = 27579, upload-time = "2023-12-03T23:05:32.26Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/5b/7e/78009acdcf798f821468894702d2accc23058ba35f56c7e18e42d6bf01ec/partridge-1.1.2-py2.py3-none-any.whl", hash = "sha256:18e1bf451153f6f8ac74f165bf8662dae39ee1c283125f44746717461b43a55f", size = 16216, upload-time = "2023-12-03T23:05:30.021Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pillow"
|
name = "pillow"
|
||||||
version = "12.3.0"
|
version = "12.3.0"
|
||||||
|
|||||||
Reference in New Issue
Block a user