Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37c3171f39 | ||
|
|
a16816bf73 |
+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/
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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]
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user