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

Removed: 94 bus stops (including Skyttehøj), 1 station (Vestamager),
and line segments south of the boundary (Metro M1 tail, buses 32-36).
2026-09-18 00:32:02 +02:00
marvin b27421aaa1 Add bus whitelist, route exclusions, crow-fly shape filter, and KML export
- modes.yaml: bus_whitelist (A-buses + selected refs) and exclude list
  (Snälltåget 083 night train — sparse crow-fly stubs)
- prepare.py: drop degenerate crow-fly shapes below 0.05 pts/km; apply
  bus whitelist and route exclusions from modes.yaml
- export_google_mymaps.py: new script — master.gpkg -> KML for Google
  My Maps import (stops, routes, boundary, coastline layers)
- coastline.kml: reference coastline layer for the KML export
- .gitignore: also ignore generated output/*.kml
2026-09-18 00:24:37 +02:00
marvin b7d51ca29f Source Copenhagen transit data from Rejseplanen GTFS
- Replace OSM Overpass transit data with the Rejseplanen GTFS feed
  (routes keyed by (agency, short name); styles from modes.yaml)
- Draw one shape per (style, ref, direction), choosing the shape that
  serves the most in-area stops so lines pass the stops we show
- Collapse stops by names (verified unambiguous); prune stops whose
  serving refs have no drawn line within 300 m
- Patch Københavns Havn + Nordhavn into the area polygon so ferry
  routes and sub-harbour metro tunnels survive clipping; 100 m buffer
  closes relation boundary slivers
- Drop OSM download pipeline; keep tiled basemap
2026-09-17 21:07:40 +02:00
marvin 4aca62ca1a Add orphan bus stops (platforms not in route relations)
Some bus stop platform nodes in OSM are not members of any route
relation (e.g. Borgergade). These were missed by the route-membership
download approach. Now download_osm.py:
- tracks bus platform node ids captured by route queries
- runs a supplementary query for all highway=bus_stop + platform nodes
- appends orphans (with empty route_refs) to the bus stops layer

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

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

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

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

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

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

Threshold configurable via styling.yaml stations.stop_cluster_m.
2026-09-13 23:15:29 +02:00
marvin e12b13cd8d Fix stop markers rendering below lines
All station and bus stop markers now use zorder = max(line zorders) + 1,
ensuring they always render on top of every transit line layer.
2026-09-13 22:50:41 +02:00
marvin a81b52a456 Add bus stop markers from stops layer
- plot_bus_stops(): loads bus stops from master.gpkg stops layer (1610
  points), draws small white-filled circles (1.5pt) at each location
- stops layer loaded in main() alongside stations
- 'bus' added to stations.styles and marker.size in styling.yaml
- bus stops respect --no-stops and --no-buses / --modes filters
- bus marker zorder sits just above bus lines
2026-09-13 22:42:05 +02:00
marvin 7dc9a15932 Make station labels opt-in; add station markers (symbols without text)
- labels: off by default (was on). Enable via --labels flag or
  styling.yaml labels.show: true. Previously --no-labels opted out.
- stations: new markers layer -- small white-filled circles with dark
  border at each station, sized by mode (metro 3.5pt, s_tog 3.0pt,
  regional 2.5pt). On by default; disable via --no-stops.
- markers respect --modes filter (only mark stations for drawn layers)
- styling.yaml: add stations section (shape/fill/edge/size/linewidth)
- classify_station() helper deduplicates the metro/s_tog/regional logic
- fix attribution to Esri (was CARTO)
2026-09-13 22:37:50 +02:00
marvin 169edee227 Switch basemap to ESRI WorldGrayCanvas (OSM tiles blocked)
OSM's tile server blocks apps without a valid identifying User-Agent
(contextily's default is a random UUID, which OSM rejects with an
'Access blocked' error tile). Switched to ESRI WorldGrayCanvas, a free
no-key light-gray basemap comparable to CartoDB Positron, and set a
proper User-Agent header (jetlag-maps/0.1) on contextily requests per
OSM tile usage policy.
2026-09-13 22:32:16 +02:00
marvin d38c280a4e Copenhagen Phase 3: render.py -> PNG/SVG/PDF map
- render.py: parameterized CLI (--modes, --no-buses, --bus-subset,
  --max-bus-routes, --dpi, --size, --format, --out, --no-basemap, --no-labels)
- basemap: OSM standard tiles via contextily (EPSG:3857)
- two-tone lines: dark casing + colour body per route, z-order
  bus->s_tog->light_rail->regional->metro
- shapeburst fade mask outside City Pass area (distance-transform alpha)
- station labels for metro + S-tog via adjustText
- title + attribution
- styling.yaml: switch basemap to OpenStreetMap.Mapnik (CartoDB now needs API key)
- .gitignore: exclude generated outputs (regenerable via render.py)
- verified: full map, --no-buses, --bus-subset, --modes, SVG all work
2026-09-13 01:27:55 +02:00
marvin 8e32ca2b1c Copenhagen Phase 2: prepare.py merges + clips + colours -> master.gpkg
- prepare.py: merge per-mode OSM lines, resolve colours
  (GTFS route_color -> OSM colour tag -> keyed palette, robust to pd.NA),
  categorise buses (A/C/S/regular via regex), clip lines+stops to area,
  write master.gpkg layers (lines/stops/stations)
- download_osm.py: add stations query (public_transport=station /
  railway=station nodes+areas, out center) -> stations.gpkg with proper
  human-readable names + subway/light_rail mode tags for labelling
- verified: 135 lines (8 metro/16 s-tog/28 regional/83 bus), 90 named
  stations, zero NaN colours
2026-09-13 01:19:34 +02:00
marvin 43c04a94f1 Copenhagen Phase 1: scaffolding + area + OSM download
- pyproject.toml with all deps (geopandas, contextily, partridge, etc.)
- config/: area.json (3 OSM boundary relations), modes.yaml (subway/s_tog/
  light_rail/regional/bus, S-tog scoped to route=light_rail network=Takst),
  styling.yaml (zorder/widths/colours/palette/mask/labels)
- build_area.py: fetch 3 relations via OSM API, stitch rings, dissolve to
  area.gpkg/area.geojson (City Pass approximation)
- download_osm.py: per-mode Overpass (mirror+backoff, out geom + recurse for
  stops), writes {mode}.gpkg (lines+stops) + query text
- download_gtfs.py / gtfs_to_geopackage.py: stubs (blocked on Rejseplanen)
- data/ gitignored (regenerable); output/ tracked
2026-09-13 01:15:25 +02:00
5 changed files with 632 additions and 4 deletions
+2 -1
View File
@@ -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/
+251
View File
@@ -0,0 +1,251 @@
<?xml version='1.0' encoding='utf-8'?>
<kml xmlns="http://www.opengis.net/kml/2.2">
<Document>
<name>Coastline (from Hide+Seek Copenhagen City Pass map)</name>
<StyleMap id="line-01579B-3131-nodesc">
<Pair>
<key>normal</key>
<styleUrl>#line-01579B-3131-nodesc-normal</styleUrl>
</Pair>
<Pair>
<key>highlight</key>
<styleUrl>#line-01579B-3131-nodesc-highlight</styleUrl>
</Pair>
</StyleMap>
<Style id="line-01579B-3131-nodesc-highlight">
<LineStyle>
<color>ff9b5701</color>
<width>4.6965</width>
</LineStyle>
<BalloonStyle>
<text>&lt;h3&gt;$[name]&lt;/h3&gt;</text>
</BalloonStyle>
</Style>
<Style id="line-01579B-3131-nodesc-normal">
<LineStyle>
<color>ff9b5701</color>
<width>3.131</width>
</LineStyle>
<BalloonStyle>
<text>&lt;h3&gt;$[name]&lt;/h3&gt;</text>
</BalloonStyle>
</Style>
<Folder>
<name>Coastline</name>
<Placemark>
<name>Coastline</name>
<styleUrl>#line-01579B-3131-nodesc</styleUrl>
<LineString>
<tessellate>1</tessellate>
<coordinates>
12.5843171,55.7397957,0
12.5827293,55.7360025,0
12.5824718,55.7315324,0
12.5817422,55.731484,0
12.5813989,55.7304449,0
12.5821284,55.7290433,0
12.5866346,55.7285359,0
12.5878509,55.7274296,0
12.5895354,55.7273934,0
12.5903811,55.7271523,0
12.6023887,55.7256926,0
12.6158183,55.7206703,0
12.620303,55.7236674,0
12.6216977,55.7241629,0
12.6243907,55.7247369,0
12.6262897,55.7249967,0
12.6284247,55.7251719,0
12.6303022,55.7251779,0
12.6324909,55.7250873,0
12.6324695,55.7249665,0
12.637909,55.7230934,0
12.6386434,55.7218282,0
12.6177346,55.7122447,0
12.6173913,55.7123233,0
12.6116943,55.709694,0
12.6113267,55.7091432,0
12.6115091,55.7090676,0
12.6128441,55.7077879,0
12.612709,55.7073944,0
12.612709,55.7065058,0
12.61295,55.7056923,0
12.6132772,55.7049427,0
12.6128646,55.7039017,0
12.6146831,55.7036991,0
12.6153002,55.7030916,0
12.6146212,55.7020159,0
12.6161983,55.7016803,0
12.6164274,55.6978865,0
12.619534,55.6980419,0
12.6289389,55.6973909,0
12.6295397,55.6974605,0
12.6305757,55.6946383,0
12.6328654,55.6905452,0
12.6359244,55.6877876,0
12.6341066,55.6871202,0
12.6367352,55.6835607,0
12.6369351,55.6835927,0
12.6370405,55.6835331,0
12.6387568,55.6845909,0
12.6386816,55.6846722,0
12.6388157,55.6847159,0
12.6392421,55.6843356,0
12.6390959,55.684283,0
12.6388532,55.6844572,0
12.637179,55.6834458,0
12.6372853,55.6833646,0
12.6370415,55.6832048,0
12.6391502,55.6804415,0
12.6397198,55.6804556,0
12.6407945,55.6788023,0
12.6446818,55.6791733,0
12.6481294,55.6760777,0
12.6477002,55.6758901,0
12.6482367,55.6754545,0
12.6482152,55.6750794,0
12.6465406,55.6743208,0
12.6448562,55.6719189,0
12.6458325,55.6714712,0
12.6459398,55.6711989,0
12.6457537,55.6708966,0
12.6449598,55.6705669,0
12.6441852,55.6693045,0
12.6439062,55.6691804,0
12.6432464,55.6691078,0
12.6420252,55.6671961,0
12.6419078,55.6671067,0
12.6416769,55.6670523,0
12.639605,55.6655071,0
12.6396426,55.6652772,0
12.639571,55.6651256,0
12.6388107,55.6643574,0
12.6389868,55.6640928,0
12.6392577,55.6638084,0
12.6395473,55.6635557,0
12.6396974,55.6632324,0
12.6397753,55.6629964,0
12.6398423,55.6628557,0
12.6403183,55.6622263,0
12.6404072,55.6620556,0
12.6406298,55.6617788,0
12.6408639,55.661419,0
12.6409444,55.6613494,0
12.641049,55.6611951,0
12.641113,55.6611449,0
12.6415267,55.6606773,0
12.641626,55.6606054,0
12.6417306,55.6604957,0
12.641803,55.6604564,0
12.6422784,55.6599927,0
12.6423696,55.6599458,0
12.6427853,55.6595902,0
12.642856,55.6594981,0
12.6431243,55.659318,0
12.6436914,55.6588662,0
12.6438497,55.6587679,0
12.6444571,55.658322,0
12.6446935,55.658102,0
12.6448129,55.6580369,0
12.6450637,55.6578107,0
12.6452367,55.657732,0
12.6454375,55.6575592,0
12.6456441,55.6574465,0
12.6457118,55.657381,0
12.6459499,55.6572384,0
12.6464378,55.6568159,0
12.6469394,55.6565511,0
12.6473741,55.656235,0
12.6476763,55.6560435,0
12.6478747,55.6559391,0
12.6480178,55.6558298,0
12.6483299,55.6556358,0
12.6484803,55.6555661,0
12.648821,55.6554397,0
12.6492219,55.655188,0
12.649856,55.6547974,0
12.6500907,55.6547195,0
12.6504703,55.654637,0
12.6520371,55.6546291,0
12.6520451,55.6545564,0
12.6505229,55.6545288,0
12.6500079,55.6543775,0
12.6497504,55.6541565,0
12.6493642,55.6540082,0
12.6488855,55.6534695,0
12.6485422,55.6528339,0
12.6482847,55.6525433,0
12.6480272,55.652053,0
12.6481384,55.6512885,0
12.647838,55.6509979,0
12.6479453,55.6507679,0
12.6480847,55.6506529,0
12.6482242,55.6503804,0
12.6481906,55.649885,0
12.6482577,55.649549,0
12.6485414,55.6491014,0
12.6486309,55.6489064,0
12.6488052,55.6486401,0
12.6489045,55.6485477,0
12.6490386,55.648516,0
12.6491539,55.6484191,0
12.6502279,55.6483093,0
12.6504773,55.6482442,0
12.6506651,55.648161,0
12.6507885,55.6480263,0
12.6508232,55.6478915,0
12.6507293,55.6476554,0
12.6506341,55.6475652,0
12.650433,55.6474623,0
12.6502318,55.6474138,0
12.6499394,55.6474002,0
12.6485447,55.6475591,0
12.6483221,55.6473064,0
12.6480517,55.6467941,0
12.6479123,55.6463279,0
12.6478908,55.6459889,0
12.6481054,55.6455166,0
12.6484007,55.6451585,0
12.6485601,55.6447979,0
12.6488874,55.6444679,0
12.6500944,55.6444286,0
12.6505128,55.6443408,0
12.6507434,55.6441894,0
12.651591,55.6432419,0
12.6525459,55.6434296,0
12.6531499,55.6433718,0
12.6534396,55.643172,0
12.6549523,55.6415493,0
12.6548772,55.6413071,0
12.6533607,55.640507,0
12.6547246,55.6390978,0
12.6564626,55.6388799,0
12.6574282,55.6382682,0
12.6566614,55.6368416,0
12.6569832,55.6364147,0
12.657974,55.6358075,0
12.6578238,55.6355471,0
12.6568636,55.6353685,0
12.6570406,55.6350324,0
12.6568199,55.6344235,0
12.6570774,55.6340874,0
12.657882,55.6340419,0
12.6596845,55.6338481,0
12.661562,55.6338179,0
12.6637722,55.6339874,0
12.6649201,55.6340419,0
12.6671088,55.6340359,0
12.6694657,55.6338899,0
12.6714935,55.6336659,0
12.6735105,55.6333267,0
12.6766638,55.6325319,0
12.677919,55.6320049,0
12.6793996,55.6310904,0
12.6805918,55.6297217,0
12.6809995,55.6286073,0
12.6811927,55.6248094,0
</coordinates>
</LineString>
</Placemark>
</Folder>
</Document>
</kml>
+22
View File
@@ -13,6 +13,28 @@
# ORDER MATTERS: the styles listed here are also the priority order when a # 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 # pole is served by several modes (e.g. S-tog and regional trains share
# platforms at hub stations — the higher style wins). # 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:
metro: metro:
agencies: [Metroselskabet] agencies: [Metroselskabet]
+257
View File
@@ -0,0 +1,257 @@
#!/usr/bin/env python3
"""Export the master GeoPackage to a Google My Maps-importable KML.
Reads (from data/processed): master.gpkg (lines, stops, stations), area.gpkg
Read (from config): coastline.kml — the coastline layer extracted verbatim
from the reference map
(https://www.google.com/maps/d/u/0/kml?mid=17T6ZDYbGz72h_eteL-CxdkiaXcgDLEA&forcekml=1)
Writes: output/copenhagen-mymaps.kml
My Maps turns each top-level KML Folder into a layer on import. We emit:
Bus stops / Bus routes
Train stations / Train routes (metro + S-tog + light rail + regional)
Ferry stops / Ferry routes
City Pass boundary (outline + faint fill)
Coastline (verbatim from the reference map's layer)
Colours match the PNG map: routes use colour_final from prepare.py; stop
icons use styling.yaml palette defaults (train stations match the reference
map's red pin). Import manually in Google My Maps:
Create map → Import → upload the .kml.
"""
import re
import sys
import xml.etree.ElementTree as ET
import geopandas as gpd
import yaml
from _common import CONFIG, OUTPUT, PROCESSED
KML = "http://www.opengis.net/kml/2.2"
ET.register_namespace("", KML)
# My Maps stock pin (same as the reference map uses); tinted via IconStyle.
ICON_HREF = "https://www.gstatic.com/mapspro/images/stock/503-wht-blank_maps.png"
ICON_SCALE = 1.0
TRAIN_STATION_COLOR = "#C2185B" # reference map's station-pin red
TRAIN_STYLES = ["metro", "s_tog", "light_rail", "regional"]
# route line widths in pixels, per style
LINE_WIDTH = {
"metro": 4.5,
"s_tog": 4.25,
"light_rail": 4.25,
"regional": 3.5,
"ferry": 3.75,
"bus": 2.75,
}
BOUNDARY_COLOR = "#006064"
BOUNDARY_LINE_WIDTH = 3.0
BOUNDARY_FILL_ALPHA = 0x54 # ~33 %
class Styles:
"""Registry of unique (kind, color, width) -> KML <Style> ids."""
def __init__(self):
self._map = {}
def get(self, kind, color_hex, width=0.0):
key = (kind, color_hex, width)
if key not in self._map:
self._map[key] = f"s{len(self._map):03d}"
return self._map[key]
def elements(self):
"""Yield <Style> elements for everything registered so far."""
by_id = sorted(self._map.items(), key=lambda kv: kv[1])
for (kind, color_hex, width), sid in by_id:
st = ET.Element(f"{{{KML}}}Style", id=sid)
if kind == "icon":
el = ET.SubElement(st, f"{{{KML}}}IconStyle")
el.append(_tex("color", kml_color(color_hex)))
el.append(_tex("scale", str(ICON_SCALE)))
icon = ET.SubElement(el, f"{{{KML}}}Icon")
icon.append(_tex("href", ICON_HREF))
else:
el = ET.SubElement(st, f"{{{KML}}}LineStyle")
el.append(_tex("color", kml_color(color_hex)))
el.append(_tex("width", str(width)))
if kind == "poly":
poly = ET.SubElement(st, f"{{{KML}}}PolyStyle")
poly.append(_tex("color", kml_color(color_hex, BOUNDARY_FILL_ALPHA)))
balloon = ET.SubElement(st, f"{{{KML}}}BalloonStyle")
balloon.append(_tex("text", "<h3>$[name]</h3>"))
yield st
def _tex(tag, value):
el = ET.Element(f"{{{KML}}}{tag}")
el.text = value
return el
def kml_color(hex_color, alpha=0xFF):
"""#RRGGBB -> KML AABBGGRR."""
h = hex_color.lstrip("#")
return f"{alpha:02x}{h[4:6]}{h[2:4]}{h[0:2]}"
def placemark(folder_el, name, sid, geom_el):
pm = ET.SubElement(folder_el, f"{{{KML}}}Placemark")
pm.append(_tex("name", str(name)))
pm.append(_tex("styleUrl", f"#{sid}"))
pm.append(geom_el)
def point_el(geom):
pt = ET.Element(f"{{{KML}}}Point")
pt.append(_tex("coordinates", f"{geom.x:.7f},{geom.y:.7f}"))
return pt
def linestring_el(geom):
ls = ET.Element(f"{{{KML}}}LineString")
ls.append(_tex("tessellate", "1"))
ls.append(_tex("coordinates", " ".join(f"{x:.7f},{y:.7f}" for x, y in geom.coords)))
return ls
def polygon_el(geom):
pg = ET.Element(f"{{{KML}}}Polygon")
pg.append(_tex("tessellate", "1"))
outer = ET.SubElement(pg, f"{{{KML}}}outerBoundaryIs")
ring = ET.SubElement(outer, f"{{{KML}}}LinearRing")
ring.append(_tex("coordinates", " ".join(f"{x:.7f},{y:.7f}" for x, y in geom.exterior.coords)))
for inner in geom.interiors:
b = ET.SubElement(pg, f"{{{KML}}}innerBoundaryIs")
ring = ET.SubElement(b, f"{{{KML}}}LinearRing")
ring.append(_tex("coordinates", " ".join(f"{x:.7f},{y:.7f}" for x, y in inner.coords)))
return pg
def multi_el(geom, single):
"""Wrap (Multi)Geometry into a KML element (MultiGeometry if needed)."""
geoms = list(getattr(geom, "geoms", [geom]))
if len(geoms) == 1:
return single(geoms[0])
mg = ET.Element(f"{{{KML}}}MultiGeometry")
for g in geoms:
mg.append(single(g))
return mg
def add_folder(document, name):
f = ET.SubElement(document, f"{{{KML}}}Folder")
f.append(_tex("name", name))
return f
def add_stops_folder(document, name, gdf, tint, styles):
f = add_folder(document, name)
sid = styles.get("icon", tint)
for _, row in gdf.sort_values("name").iterrows():
placemark(f, row["name"], sid, point_el(row.geometry))
return len(gdf)
def add_routes_folder(document, name, gdf, styles):
f = add_folder(document, name)
for _, row in gdf.iterrows():
sid = styles.get("line", row["colour_final"], LINE_WIDTH[row["style"]])
placemark(f, f'{row["ref"]} {row["name"]}', sid, multi_el(row.geometry, linestring_el))
return len(gdf)
def add_boundary_folder(document, area_geom, styles):
f = add_folder(document, "City Pass boundary")
sid = styles.get("poly", BOUNDARY_COLOR, BOUNDARY_LINE_WIDTH)
placemark(f, "City Pass boundary", sid, multi_el(area_geom, polygon_el))
return 1
def append_coastline(document):
"""Append the verbatim coastline layer stored in config/coastline.kml."""
src = ET.parse(CONFIG / "coastline.kml").getroot().find(f"{{{KML}}}Document")
n = 0
for el in src:
if el.tag == f"{{{KML}}}Folder":
document.append(el)
n = len(el.findall(f"{{{KML}}}Placemark"))
elif el.tag in (f"{{{KML}}}Style", f"{{{KML}}}StyleMap"):
document.append(el)
return n
def natural_key(ref):
return tuple(int(t) if t.isdigit() else t for t in re.split(r"(\d+)", str(ref)))
def main():
lines = gpd.read_file(PROCESSED / "master.gpkg", layer="lines")
stops = gpd.read_file(PROCESSED / "master.gpkg", layer="stops")
stations = gpd.read_file(PROCESSED / "master.gpkg", layer="stations")
area = gpd.read_file(PROCESSED / "area.gpkg").geometry.union_all()
styling = yaml.safe_load((CONFIG / "styling.yaml").read_text())
palette = styling["palette"]
kml = ET.Element(f"{{{KML}}}kml")
document = ET.SubElement(kml, f"{{{KML}}}Document")
document.append(_tex("name", "Kurragömma Köpenhamn"))
styles = Styles()
counts = {}
counts["Bus stops"] = add_stops_folder(
document, "Bus stops", stops[stops["style"] == "bus"],
palette["bus"]["default"], styles,
)
bus_lines = lines[lines["style"] == "bus"].copy()
bus_lines["_k"] = bus_lines["ref"].map(natural_key)
counts["Bus routes"] = add_routes_folder(
document, "Bus routes", bus_lines.sort_values("_k"), styles
)
counts["Train stations"] = add_stops_folder(
document, "Train stations", stations, TRAIN_STATION_COLOR, styles
)
train = lines[lines["style"].isin(TRAIN_STYLES)].copy()
train["_order"] = train["style"].map(TRAIN_STYLES.index)
train["_k"] = train["ref"].map(natural_key)
counts["Train routes"] = add_routes_folder(
document, "Train routes", train.sort_values(["_order", "_k"]), styles
)
counts["Ferry stops"] = add_stops_folder(
document, "Ferry stops", stops[stops["style"] == "ferry"],
palette["ferry"]["default"], styles,
)
ferry_lines = lines[lines["style"] == "ferry"].sort_values("ref")
counts["Ferry routes"] = add_routes_folder(document, "Ferry routes", ferry_lines, styles)
counts["City Pass boundary"] = add_boundary_folder(document, area, styles)
counts["Coastline"] = append_coastline(document)
# <Style> elements belong before the first <Folder> (KML resolves style
# ids regardless of order; this is conventional + nicer on the eyes).
insert_at = list(document).index(document.find(f"{{{KML}}}name")) + 1
for offset, st in enumerate(styles.elements()):
document.insert(insert_at + offset, st)
out = OUTPUT / "copenhagen-mymaps.kml"
tree = ET.ElementTree(kml)
ET.indent(tree, space=" ")
tree.write(out, encoding="utf-8", xml_declaration=True)
print(f"wrote {out}")
for n, c in counts.items():
print(f" {n}: {c} placemarks")
if __name__ == "__main__":
main()
+100 -3
View File
@@ -30,6 +30,7 @@ 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, GTFS_RAW, PROCESSED from _common import CONFIG, GTFS_RAW, PROCESSED
@@ -39,10 +40,47 @@ STATION_STYLES = {"metro", "s_tog", "light_rail", "regional"}
# within this distance. Routes have variants; without this check a stop can # 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. # end up further from the map than the variant we chose not to draw.
STOP_LINE_MARGIN_M = 300 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():
@@ -66,7 +104,7 @@ def classify(agency, route_type, modes):
return None return None
def load_routes(modes): def load_routes(modes, exclude=()):
"""route_colors.csv joined with agency names and classified by style.""" """route_colors.csv joined with agency names and classified by style."""
agencies = pd.read_csv(GTFS_RAW / "agency.txt", dtype=str) agencies = pd.read_csv(GTFS_RAW / "agency.txt", dtype=str)
agency_names = dict(zip(agencies["agency_id"], agencies["agency_name"])) agency_names = dict(zip(agencies["agency_id"], agencies["agency_name"]))
@@ -77,6 +115,17 @@ def load_routes(modes):
classify(a, t, modes) classify(a, t, modes)
for a, t in zip(routes["agency_name"], routes["route_type"]) for a, t in zip(routes["agency_name"], routes["route_type"])
] ]
if exclude:
mask = [
(a, str(r)) in set(exclude)
for a, r in zip(routes["agency_name"], routes["route_short_name"])
]
n = sum(mask)
if n:
routes.loc[mask, "style"] = None
print(f"excluded by modes.yaml: {n} route(s) "
f"({sorted(set(zip(routes.loc[mask, 'agency_name'], routes.loc[mask, 'route_short_name'])) )})",
flush=True)
return routes return routes
@@ -99,6 +148,15 @@ def resolve_colour(style, ref, bus_category, gtfs_colour, palette):
return p.get(ref) or p.get("default") return p.get(ref) or p.get("default")
def shape_points_per_km(shapes_25832):
"""Point density per shape. Geometries are projected (metres)."""
def n_pts(geom):
geoms = getattr(geom, "geoms", [geom])
return sum(len(g.coords) for g in geoms)
pts = shapes_25832.geometry.map(n_pts)
return pts / (shapes_25832.geometry.length / 1000.0).clip(lower=1e-6)
def build_lines(routes, styling, area_geom, trips, st, area_stop_ids): def build_lines(routes, styling, area_geom, trips, st, area_stop_ids):
"""One feature per (style, ref, direction). """One feature per (style, ref, direction).
@@ -108,6 +166,20 @@ def build_lines(routes, styling, area_geom, trips, st, area_stop_ids):
""" """
shapes = gpd.read_file(PROCESSED / "gtfs_shapes.gpkg") 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_rows = trips.dropna(subset=["shape_id"]).drop_duplicates("shape_id")
shape_direction = dict(zip(shape_rows["shape_id"], shape_rows["direction_id"])) shape_direction = dict(zip(shape_rows["shape_id"], shape_rows["direction_id"]))
@@ -269,7 +341,32 @@ def main():
styling = load_styling() styling = load_styling()
area_geom = load_area() area_geom = load_area()
routes = load_routes(modes) south_bound = load_south_bound()
if south_bound is not None:
north = box(-180, south_bound, 180, 90)
area_geom = area_geom.intersection(north)
print(f"south bound: clipping area at lat {south_bound}", flush=True)
routes = load_routes(modes, load_exclude())
bus_wl = load_bus_whitelist()
if bus_wl is not None:
patterns = styling["bus_filters"]["categories"]
is_bus = routes["style"] == "bus"
keep = routes["route_short_name"].map(
lambda r: str(r) in bus_wl["refs"]
or categorise_bus(r, patterns) in bus_wl["categories"]
)
routes.loc[is_bus & ~keep, "style"] = None
kept = sorted(
routes.loc[is_bus & keep, "route_short_name"].unique(),
key=lambda s: [int(t) if t.isdigit() else t
for t in re.split(r"(\d+)", str(s))],
)
print(f"bus whitelist: kept {len(kept)} of "
f"{int(is_bus.sum())} bus refs: {', '.join(kept)}",
flush=True)
print(f"routes classified: {routes['style'].notna().sum()} of " print(f"routes classified: {routes['style'].notna().sum()} of "
f"{len(routes)} map to a style", flush=True) f"{len(routes)} map to a style", flush=True)