MapLibre GL ist ein Open-Source Fork von MapBox GL.

Minimalbeispiel

Library einbinden

<link rel="stylesheet" href="maplibre-gl.css" />
<script src="maplibre-gl.js"></script>
<script src="pmtiles.js"></script>

Map initialisieren

Man kann den Style auch in eine Datei auslagern, dann statt dem JSON Array den Dateinamen schreiben
// PMTiles-Protokoll bei MapLibre registrieren
const protocol = new pmtiles.Protocol();
maplibregl.addProtocol("pmtiles", protocol.tile);
// Karte erzeugen
const map = new maplibregl.Map({
    container: "map", // Die div-id in welches Div die Map geladen wird
    center: [10.0, 51.0], // Startposition
    zoom: 6,
    style: {
        "version": 8,
        "sources": {
            "omt": {
                "type": "vector",
                "url": "pmtiles://http://localhost/wien-edited.pmtiles",
                "attribution": "© OpenStreetMap and contributors"
            }
        },
        "layers": [{
            "id": "background",
            "type": "background",
            "paint": {
                "background-color": "#f2efe9"
            }
        }, {
            "id": "landcover",
            "type": "fill",
            "source": "omt",
            "source-layer": "landcover",
            "paint": {
                "fill-color": ["match",
                    ["get", "class"], "forest", "#d8e8c8", "wood", "#d8e8c8", "grass", "#e6efd8", "#e9efe3"
                ]
            }
        }, {
            "id": "landuse",
            "type": "fill",
            "source": "omt",
            "source-layer": "landuse",
            "paint": {
                "fill-color": ["match",
                    ["get", "class"], "residential", "#ece7e1", "industrial", "#e3ddd5", "commercial", "#e9e1d6", "farmland", "#eef3d6", "#ebe7df"
                ],
                "fill-opacity": 0.7
            }
        }, ]
    }
});
map.addControl(new maplibregl.NavigationControl(), "top-right");
map.on("load", () => {
    console.log("Karte geladen");
});

Marker

// Marker setzen
const marker = new maplibregl.Marker().setLngLat([16.3738, 48.2082]).addTo(map);
 
// Marker entfernen
marker.remove();
 
// Marker mit Popup
const marker = new maplibregl.Marker()
  .setLngLat([16.3738, 48.2082])
  .setPopup(
    new maplibregl.Popup().setHTML("<b>Hallo Wien</b>")
  )
  .addTo(map);
// Neuen Marker setzen und alten löschen
let currentMarker = null;
 
function setMarker(lng, lat) {
  if (currentMarker) {
    currentMarker.remove();
  }
 
  currentMarker = new maplibregl.Marker()
    .setLngLat([lng, lat])
    .addTo(map);
}
 
function removeMarker() {
  if (currentMarker) {
    currentMarker.remove();
    currentMarker = null;
  }
}
 
// Test
setMarker(16.3738, 48.2082);
removeMarker();
// Marker per Klick setzen
let currentMarker = null;
 
map.on("click", (e) => {
  if (currentMarker) {
    currentMarker.remove();
  }
 
  currentMarker = new maplibregl.Marker()
    .setLngLat(e.lngLat)
    .addTo(map);
});
// Marker per Klick setzen und mit Funktion löschen
let currentMarker = null;
 
function addMarkerAtClick(e) {
  if (currentMarker) {
    currentMarker.remove();
  }
 
  currentMarker = new maplibregl.Marker()
    .setLngLat([e.lngLat.lng, e.lngLat.lat])
    .addTo(map);
}
 
function clearMarker() {
  if (currentMarker) {
    currentMarker.remove();
    currentMarker = null;
  }
}
 
map.on("click", addMarkerAtClick);

GeoJSON einbinden

im map load Event

map.on("load", () => {
        console.log("Karte geladen");
	map.addSource("punkte", {
		type: "geojson",
		data: "pt.geojson"
	});
	map.addLayer({
		id: "punkte-layer",
		type: "circle",
		source: "punkte",
		paint: {
		  "circle-radius": 5,
		  "circle-color": "#e60000"
		}
	});
});

Layer später dynamisch hinzufügen/entfernen

// GeoJSON-Layer hinzufügen oder aktualisieren
function addGeoJsonLayerToMap(map, options) {
	const {
		sourceId,
		layerId,
		data,
		type = "circle",
		paint = {},
		layout = {},
		filter = null,
		beforeId = null,
		onClick = null
	} = options;
 
	if (map.getSource(sourceId)) {
		map.getSource(sourceId).setData(data);
	} else {
		map.addSource(sourceId, {
			type: "geojson",
			data: data
		});
	}
 
	if (!map.getLayer(layerId)) {
		const layerConfig = {
			id: layerId,
			type: type,
			source: sourceId,
			paint: paint,
			layout: layout
		};
 
		if (filter) {
			layerConfig.filter = filter;
		}
 
		if (beforeId && map.getLayer(beforeId)) {
			map.addLayer(layerConfig, beforeId);
		} else {
			map.addLayer(layerConfig);
		}
 
		if (onClick) {
			map.on("click", layerId, (e) => {
				if (!e.features || !e.features.length) return;
				onClick(e.features[0], e);
			});
 
			map.on("mouseenter", layerId, () => {
				map.getCanvas().style.cursor = "pointer";
			});
 
			map.on("mouseleave", layerId, () => {
				map.getCanvas().style.cursor = "";
			});
		}
	}
}
 
// GeoJSON-Layer entfernen
function removeGeoJsonLayerFromMap(map, layerId, sourceId) {
	if (map.getLayer(layerId)) {
		map.removeLayer(layerId);
	}
 
	if (map.getSource(sourceId)) {
		map.removeSource(sourceId);
	}
}

Beispiele

addGeoJsonLayerToMap(map, {
    sourceId: "pt-source",
    layerId: "pt-layer",
    data: "pt.geojson",
    type: "circle",
    paint: {
        "circle-radius": 6,
        "circle-color": "#ff0000"
    },
    onClick: (feature, e) => {
        new maplibregl.Popup()
            .setLngLat(e.lngLat)
            .setHTML(`
        <b>${feature.properties.name || "Ohne Name"}</b><br>
        ID: ${feature.properties.id || "-"}
      `)
            .addTo(map);
    }
});
 
addGeoJsonLayerToMap(map, {
    sourceId: "pt-source",
    layerId: "pt-layer",
    data: "pt.geojson",
    type: "circle",
    paint: {
        "circle-radius": 6,
        "circle-color": "#ff0000"
    },
    onClick: (feature, e) => {
        new maplibregl.Popup()
            .setLngLat(e.lngLat)
            .setHTML(`
        <b>${feature.properties.name || "Ohne Name"}</b><br>
        ID: ${feature.properties.id || "-"}
      `)
            .addTo(map);
    }
});
 
removeGeoJsonLayer(map, "pt-layer", "pt-source");

GeoJSON von URL nachladen

function loadExternalGeoJson(url, idBase, type) {
    addGeoJsonLayer(map, {
        sourceId: `${idBase}-source`,
        layerId: `${idBase}-layer`,
        data: url,
        type: type,
        paint: type === "circle" ?
            {
                "circle-radius": 5,
                "circle-color": "#ff0000"
            } :
            type === "line" ?
            {
                "line-color": "#0000ff",
                "line-width": 3
            } :
            {
                "fill-color": "#00aa00",
                "fill-opacity": 0.4
            }
    });
}
 
loadExternalGeoJson("radwege.geojson", "radwege", "line");
loadExternalGeoJson("orte.geojson", "orte", "circle");

OEPNV

Siehe auch GTFS

V1

Data Export

osmium tags-filter wien.osm.pbf \
  n/highway=bus_stop \
  r/route=bus \
  w/highway \
  -o bus-relevant.osm.pbf -O
 
osmium tags-filter wien.osm.pbf \
  n/railway=tram_stop \
  r/route=tram \
  w/railway=tram \
  -o tram-relevant.osm.pbf -O
 
osmium tags-filter wien.osm.pbf \
  n/station=subway \
  n/railway=station \
  r/route=subway \
  w/railway=subway \
  -o subway-relevant.osm.pbf -O
 
osmium tags-filter wien.osm.pbf \
  n/railway=station \
  n/railway=halt \
  r/route=train \
  w/railway \
  -o train-relevant.osm.pbf -O

Python script

#!/usr/bin/env python3
import json
import subprocess
import sys
import xml.etree.ElementTree as ET
from collections import defaultdict
 
 
def fail(msg, code=1):
    print(msg, file=sys.stderr)
    sys.exit(code)
 
 
def dedupe_dicts(items, key_fn):
    seen = set()
    out = []
    for item in items:
        key = key_fn(item)
        if key in seen:
            continue
        seen.add(key)
        out.append(item)
    return out
 
 
def sort_key(value):
    return (value or "").strip().lower()
 
 
if len(sys.argv) != 5:
    fail(
        "Aufruf: python3 osm_pt_data_to_geojson.py "
        "input.osm.pbf stops.geojson routes.geojson route_type"
    )
 
input_pbf = sys.argv[1]
stops_geojson_path = sys.argv[2]
routes_geojson_path = sys.argv[3]
route_type = sys.argv[4].strip().lower()
 
allowed_route_types = {"bus", "tram", "subway", "train"}
if route_type not in allowed_route_types:
    fail("route_type muss einer von: bus, tram, subway, train sein.")
 
 
def is_stop(tags, current_route_type):
    """
    Konservative Stop-Erkennung, damit Modi sich weniger vermischen.
    """
    highway = tags.get("highway")
    railway = tags.get("railway")
    station = tags.get("station")
    public_transport = tags.get("public_transport")
    bus = tags.get("bus")
    tram = tags.get("tram")
    subway = tags.get("subway")
    train = tags.get("train")
 
    if current_route_type == "bus":
        # Nur echte Bus-Haltestellen.
        # Kein generisches public_transport=platform mehr.
        return highway == "bus_stop" or bus == "yes"
 
    if current_route_type == "tram":
        # Nur echte Tram-Haltestellen.
        return railway == "tram_stop" or tram == "yes"
 
    if current_route_type == "subway":
        # U-Bahn-Stationen enger fassen.
        return (
            station == "subway"
            or subway == "yes"
            or (railway in {"station", "halt"} and public_transport == "station" and station == "subway")
        )
 
    if current_route_type == "train":
        # Zugstationen, aber keine U-Bahn.
        return (
            (railway in {"station", "halt"} and station != "subway" and subway != "yes")
            or train == "yes"
        )
 
    return False
 
 
try:
    proc = subprocess.Popen(
        ["osmium", "cat", input_pbf, "-f", "osm"],
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
        text=True,
        encoding="utf-8",
    )
except FileNotFoundError:
    fail("Fehler: 'osmium' wurde nicht gefunden.")
 
if proc.stdout is None or proc.stderr is None:
    fail("Fehler: Konnte osmium-Streams nicht öffnen.")
 
nodes = {}
ways = {}
stops = {}
route_relations = []
 
try:
    for _, elem in ET.iterparse(proc.stdout, events=("end",)):
        if elem.tag == "node":
            node_id = elem.get("id")
            lat = elem.get("lat")
            lon = elem.get("lon")
 
            if node_id and lat and lon:
                tags = {tag.get("k"): tag.get("v") for tag in elem.findall("tag")}
                node_data = {
                    "id": node_id,
                    "lat": float(lat),
                    "lon": float(lon),
                    "tags": tags,
                }
                nodes[node_id] = node_data
 
                if is_stop(tags, route_type):
                    stops[node_id] = node_data
 
            elem.clear()
 
        elif elem.tag == "way":
            way_id = elem.get("id")
            if way_id:
                nd_refs = [nd.get("ref") for nd in elem.findall("nd") if nd.get("ref")]
                tags = {tag.get("k"): tag.get("v") for tag in elem.findall("tag")}
                ways[way_id] = {
                    "id": way_id,
                    "nd_refs": nd_refs,
                    "tags": tags,
                }
 
            elem.clear()
 
        elif elem.tag == "relation":
            rel_tags = {tag.get("k"): tag.get("v") for tag in elem.findall("tag")}
            if rel_tags.get("route") == route_type:
                members = []
                for member in elem.findall("member"):
                    members.append({
                        "type": member.get("type"),
                        "ref": member.get("ref"),
                        "role": member.get("role", ""),
                    })
 
                route_relations.append({
                    "id": elem.get("id"),
                    "tags": rel_tags,
                    "members": members,
                })
 
            elem.clear()
 
except ET.ParseError as e:
    fail(f"XML Parse Error: {e}")
 
stderr_text = proc.stderr.read()
return_code = proc.wait()
 
if return_code != 0:
    fail(f"Fehler bei 'osmium cat':\n{stderr_text}")
 
if not nodes and not ways and not route_relations:
    fail("Keine OSM-Objekte gefunden. Ist die Eingabedatei korrekt?")
 
routes_by_stop = defaultdict(list)
route_features = []
 
for rel in route_relations:
    rel_id = rel["id"]
    rel_tags = rel["tags"]
 
    route_info = {
        "relation_id": rel_id,
        "ref": rel_tags.get("ref"),
        "name": rel_tags.get("name"),
        "from": rel_tags.get("from"),
        "to": rel_tags.get("to"),
        "operator": rel_tags.get("operator"),
        "network": rel_tags.get("network"),
        "route_type": route_type,
    }
 
    multiline_coords = []
 
    for member in rel["members"]:
        member_type = member.get("type")
        member_ref = member.get("ref")
        member_role = member.get("role", "")
 
        if member_type == "node":
            if member_ref in stops:
                entry = dict(route_info)
                entry["member_role"] = member_role
                routes_by_stop[member_ref].append(entry)
 
        elif member_type == "way":
            way = ways.get(member_ref)
            if not way:
                continue
 
            coords = []
            for nd_ref in way["nd_refs"]:
                node = nodes.get(nd_ref)
                if not node:
                    continue
                coords.append([node["lon"], node["lat"]])
 
            if len(coords) >= 2:
                multiline_coords.append(coords)
 
    if multiline_coords:
        route_features.append({
            "type": "Feature",
            "geometry": {
                "type": "MultiLineString",
                "coordinates": multiline_coords,
            },
            "properties": {
                "osm_type": "relation",
                "osm_id": rel_id,
                **route_info,
            },
        })
 
stop_features = []
 
for stop_id, stop in stops.items():
    routes = routes_by_stop.get(stop_id, [])
 
    routes = dedupe_dicts(
        routes,
        lambda r: (
            r.get("relation_id"),
            r.get("ref"),
            r.get("name"),
            r.get("from"),
            r.get("to"),
            r.get("member_role"),
        ),
    )
 
    grouped = defaultdict(list)
    for route in routes:
        ref = (route.get("ref") or "").strip()
        if not ref:
            # Falls keine ref da ist, über relation_id gruppieren
            ref = f"rel-{route.get('relation_id')}"
        grouped[ref].append(route)
 
    routes_unique = []
    for ref, variants in grouped.items():
        variants = dedupe_dicts(
            variants,
            lambda r: (
                r.get("relation_id"),
                r.get("from"),
                r.get("to"),
                r.get("name"),
            ),
        )
 
        routes_unique.append({
            "ref": ref,
            "name": next((v.get("name") for v in variants if v.get("name")), ""),
            "operator": next((v.get("operator") for v in variants if v.get("operator")), ""),
            "network": next((v.get("network") for v in variants if v.get("network")), ""),
            "variants": [
                {
                    "relation_id": v.get("relation_id"),
                    "from": v.get("from"),
                    "to": v.get("to"),
                    "name": v.get("name"),
                }
                for v in variants
            ],
        })
 
    routes_unique.sort(key=lambda r: sort_key(r.get("ref")))
 
    properties = dict(stop["tags"])
    properties["osm_id"] = stop["id"]
    properties["osm_type"] = "node"
    properties["route_type"] = route_type
    properties["route_refs"] = [r["ref"] for r in routes_unique]
    properties["routes"] = routes_unique
    properties["route_count"] = len(routes_unique)
 
    stop_features.append({
        "type": "Feature",
        "geometry": {
            "type": "Point",
            "coordinates": [stop["lon"], stop["lat"]],
        },
        "properties": properties,
    })
 
stops_geojson = {
    "type": "FeatureCollection",
    "features": stop_features,
}
 
routes_geojson = {
    "type": "FeatureCollection",
    "features": route_features,
}
 
try:
    with open(stops_geojson_path, "w", encoding="utf-8") as f:
        json.dump(stops_geojson, f, ensure_ascii=False, indent=2)
 
    with open(routes_geojson_path, "w", encoding="utf-8") as f:
        json.dump(routes_geojson, f, ensure_ascii=False, indent=2)
 
except OSError as e:
    fail(f"Fehler beim Schreiben der Ausgabedateien: {e}")
 
stops_with_routes = sum(
    1 for feature in stop_features if feature["properties"].get("route_count", 0) > 0
)
 
print(f"Stops geschrieben: {stops_geojson_path}")
print(f"Routen geschrieben: {routes_geojson_path}")
print(f"Typ: {route_type}")
print(f"Haltestellen: {len(stop_features)}")
print(f"Haltestellen mit Routeninfo: {stops_with_routes}")
print(f"Routen-Geometrien: {len(route_features)}")

Datenaufbereitung

python3 osm_pt_data_to_geojson.py bus-relevant.osm.pbf bus-stops.geojson bus-routes.geojson bus
python3 osm_pt_data_to_geojson.py tram-relevant.osm.pbf tram-stops.geojson tram-routes.geojson tram
python3 osm_pt_data_to_geojson.py subway-relevant.osm.pbf subway-stops.geojson subway-routes.geojson subway
python3 osm_pt_data_to_geojson.py train-relevant.osm.pbf train-stops.geojson train-routes.geojson train

Anzeigen

<!doctype html>
<html lang="de">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width,initial-scale=1" />
  <title>Wien Öffis</title>
 
  <link rel="stylesheet" href="maplibre-gl.css" />
  <script src="maplibre-gl.js"></script>
  <script src="pmtiles.js"></script>
 
  <style>
    html, body {
      margin: 0;
      padding: 0;
      height: 100%;
      font-family: sans-serif;
    }
 
    #map {
      width: 100%;
      height: 100vh;
    }
 
    .maplibregl-popup-content {
      min-width: 280px;
      max-width: 420px;
    }
 
    .popup-stop-name {
      font-weight: 700;
      margin-bottom: 8px;
    }
 
    .popup-subtitle {
      margin-bottom: 8px;
    }
 
    .route-list {
      margin: 0;
      padding-left: 18px;
    }
 
    .route-list li {
      margin-bottom: 6px;
    }
 
    .route-link {
      display: inline;
      border: 0;
      background: none;
      padding: 0;
      margin: 0;
      font: inherit;
      text-align: left;
      cursor: pointer;
      color: #0a58ca;
      text-decoration: underline;
    }
 
    .legend {
      position: absolute;
      top: 12px;
      left: 12px;
      z-index: 10;
      background: rgba(255,255,255,0.95);
      border-radius: 8px;
      padding: 10px 12px;
      box-shadow: 0 2px 10px rgba(0,0,0,0.12);
      font-size: 14px;
      line-height: 1.4;
    }
 
    .legend-row {
      display: flex;
      align-items: center;
      gap: 8px;
      margin-top: 6px;
    }
 
    .legend-dot {
      width: 10px;
      height: 10px;
      border-radius: 50%;
      display: inline-block;
    }
 
    .dot-bus { background: #e30000; }
    .dot-tram { background: #0066dd; }
    .dot-subway { background: #009966; }
    .dot-train { background: #7a3db8; }
  </style>
 
</head>
<body>
 
  <div id="map"></div>
 
  <div class="legend">
    <div><strong>Öffi-Stationen</strong></div>
    <div class="legend-row"><span class="legend-dot dot-bus"></span> Bus</div>
    <div class="legend-row"><span class="legend-dot dot-tram"></span> Tram</div>
    <div class="legend-row"><span class="legend-dot dot-subway"></span> U-Bahn</div>
    <div class="legend-row"><span class="legend-dot dot-train"></span> Zug</div>
  </div>
 
<script>
const protocol = new pmtiles.Protocol();
maplibregl.addProtocol("pmtiles", protocol.tile);
 
const MODES = {
  bus: {
    label: "Bus",
    color: "#e30000",
    stopsUrl: "bus-stops.geojson",
    routesUrl: "bus-routes.geojson",
    stopSourceId: "bus-stops-source",
    stopLayerId: "bus-stops-layer",
    routeHighlightSourceId: "bus-route-highlight-source",
    routeHighlightLayerId: "bus-route-highlight-layer"
  },
  tram: {
    label: "Tram",
    color: "#0066dd",
    stopsUrl: "tram-stops.geojson",
    routesUrl: "tram-routes.geojson",
    stopSourceId: "tram-stops-source",
    stopLayerId: "tram-stops-layer",
    routeHighlightSourceId: "tram-route-highlight-source",
    routeHighlightLayerId: "tram-route-highlight-layer"
  },
  subway: {
    label: "U-Bahn",
    color: "#009966",
    stopsUrl: "subway-stops.geojson",
    routesUrl: "subway-routes.geojson",
    stopSourceId: "subway-stops-source",
    stopLayerId: "subway-stops-layer",
    routeHighlightSourceId: "subway-route-highlight-source",
    routeHighlightLayerId: "subway-route-highlight-layer"
  },
  train: {
    label: "Zug",
    color: "#7a3db8",
    stopsUrl: "train-stops.geojson",
    routesUrl: "train-routes.geojson",
    stopSourceId: "train-stops-source",
    stopLayerId: "train-stops-layer",
    routeHighlightSourceId: "train-route-highlight-source",
    routeHighlightLayerId: "train-route-highlight-layer"
  }
};
 
let activePopup = null;
const routeDataByMode = {};
 
const map = new maplibregl.Map({
  container: "map",
  center: [16.3738, 48.2082],
  zoom: 12,
  style: {
    version: 8,
    sources: {
      omt: {
        type: "vector",
        url: "pmtiles://http://localhost/wien-edited.pmtiles",
        attribution: "© OpenStreetMap and contributors"
      }
    },
    layers: [
      {
        id: "background",
        type: "background",
        paint: { "background-color": "#f2efe9" }
      },
      {
        id: "landcover",
        type: "fill",
        source: "omt",
        "source-layer": "landcover",
        paint: {
          "fill-color": [
            "match",
            ["get", "class"],
            "forest", "#d8e8c8",
            "wood", "#d8e8c8",
            "grass", "#e6efd8",
            "#e9efe3"
          ]
        }
      },
      {
        id: "landuse",
        type: "fill",
        source: "omt",
        "source-layer": "landuse",
        paint: {
          "fill-color": [
            "match",
            ["get", "class"],
            "residential", "#ece7e1",
            "industrial", "#e3ddd5",
            "commercial", "#e9e1d6",
            "farmland", "#eef3d6",
            "#ebe7df"
          ],
          "fill-opacity": 0.7
        }
      },
      {
        id: "buildings",
        type: "fill",
        source: "omt",
        "source-layer": "building",
        paint: {
          "fill-color": "lightblue",
          "fill-opacity": 0.7
        }
      },
      {
        id: "roads-motorway",
        type: "line",
        source: "omt",
        "source-layer": "transportation",
        filter: ["==", ["get", "class"], "motorway"],
        layout: {
          "line-cap": "round",
          "line-join": "round"
        },
        paint: {
          "line-color": "#e3c08d",
          "line-width": [
            "interpolate", ["linear"], ["zoom"],
            5, 0.8,
            8, 1.2,
            10, 2.0,
            12, 3.5,
            14, 6.0,
            16, 10.0,
            18, 16.0
          ]
        }
      },
      {
        id: "roads-primary",
        type: "line",
        source: "omt",
        "source-layer": "transportation",
        filter: ["==", ["get", "class"], "primary"],
        layout: {
          "line-cap": "round",
          "line-join": "round"
        },
        paint: {
          "line-color": "#f2f2f2",
          "line-width": [
            "interpolate", ["linear"], ["zoom"],
            5, 0.5,
            8, 0.9,
            10, 1.5,
            12, 2.6,
            14, 4.2,
            16, 7.0,
            18, 11.0
          ]
        }
      },
      {
        id: "roads-secondary",
        type: "line",
        source: "omt",
        "source-layer": "transportation",
        filter: ["==", ["get", "class"], "secondary"],
        layout: {
          "line-cap": "round",
          "line-join": "round"
        },
        paint: {
          "line-color": "#e8e8e8",
          "line-width": [
            "interpolate", ["linear"], ["zoom"],
            5, 0.4,
            8, 0.7,
            10, 1.2,
            12, 2.0,
            14, 3.2,
            16, 5.5,
            18, 8.5
          ]
        }
      },
      {
        id: "roads-other",
        type: "line",
        source: "omt",
        "source-layer": "transportation",
        filter: [
          "all",
          ["!=", ["get", "class"], "motorway"],
          ["!=", ["get", "class"], "primary"],
          ["!=", ["get", "class"], "secondary"],
          ["!=", ["get", "class"], "path"],
          ["!=", ["get", "subclass"], "footway"],
          ["!=", ["get", "subclass"], "platform"],
          ["!=", ["get", "class"], "bridge"],
          ["!=", ["get", "brunnel"], "tunnel"]
        ],
        layout: {
          "line-cap": "round",
          "line-join": "round"
        },
        paint: {
          "line-color": "#dcdcdc",
          "line-width": [
            "interpolate", ["linear"], ["zoom"],
            5, 0.2,
            8, 0.4,
            10, 0.8,
            12, 1.3,
            14, 2.0,
            16, 3.2,
            18, 5.0
          ]
        }
      }
    ]
  }
});
 
map.addControl(new maplibregl.NavigationControl(), "top-right");
 
function closeActivePopup() {
  if (activePopup) {
    activePopup.remove();
    activePopup = null;
  }
}
 
function safeJsonParse(value, fallback = null) {
  if (value == null) return fallback;
  if (typeof value !== "string") return value;
  try {
    return JSON.parse(value);
  } catch {
    return fallback;
  }
}
 
function normalizeRoutesFromStopProperties(properties) {
  const rawRoutes = safeJsonParse(properties.routes, properties.routes);
  if (!Array.isArray(rawRoutes)) return [];
 
  const routes = rawRoutes.map(route => ({
    ref: String(route?.ref ?? "").trim(),
    name: String(route?.name ?? "").trim(),
    variants: Array.isArray(route?.variants)
      ? route.variants.map(v => ({
          relation_id: String(v?.relation_id ?? "").trim(),
          from: String(v?.from ?? "").trim(),
          to: String(v?.to ?? "").trim(),
          name: String(v?.name ?? "").trim()
        }))
      : []
  })).filter(route => route.ref || route.variants.length);
 
  routes.sort((a, b) => {
    const ra = a.ref || "";
    const rb = b.ref || "";
    return ra.localeCompare(rb, "de", { numeric: true });
  });
 
  return routes;
}
 
function clearAllHighlights() {
  for (const mode of Object.values(MODES)) {
    const source = map.getSource(mode.routeHighlightSourceId);
    if (!source) continue;
 
    source.setData({
      type: "FeatureCollection",
      features: []
    });
  }
}
 
function fitToFeatures(features) {
  if (!features.length) return;
 
  const bounds = new maplibregl.LngLatBounds();
  let hasCoords = false;
 
  function extendCoords(coords) {
    for (const c of coords) {
      if (Array.isArray(c[0])) {
        extendCoords(c);
      } else {
        bounds.extend(c);
        hasCoords = true;
      }
    }
  }
 
  for (const feature of features) {
    if (feature.geometry?.coordinates) {
      extendCoords(feature.geometry.coordinates);
    }
  }
 
  if (hasCoords) {
    map.fitBounds(bounds, {
      padding: 40,
      maxZoom: 15,
      duration: 500
    });
  }
}
 
function showRouteHighlightByRelation(routeType, relationId) {
  const mode = MODES[routeType];
  if (!mode) {
    console.warn("Unbekannter routeType:", routeType);
    return;
  }
 
  const allRoutes = routeDataByMode[routeType];
  if (!allRoutes || !Array.isArray(allRoutes.features)) {
    console.warn("Keine Routendaten geladen für:", routeType);
    return;
  }
 
  const normalizedRelationId = String(relationId ?? "").trim();
 
  const matches = allRoutes.features.filter(feature => {
    const osmId = String(feature?.properties?.osm_id ?? "").trim();
    return osmId === normalizedRelationId;
  });
 
  console.log("Highlight relation matches:", routeType, relationId, matches.length);
 
  const source = map.getSource(mode.routeHighlightSourceId);
  if (!source) {
    console.warn("Highlight-Source fehlt:", mode.routeHighlightSourceId);
    return;
  }
 
  clearAllHighlights();
 
  source.setData({
    type: "FeatureCollection",
    features: matches
  });
 
  fitToFeatures(matches);
}
 
function showRouteHighlightByRef(routeType, routeRef) {
  const mode = MODES[routeType];
  if (!mode) {
    console.warn("Unbekannter routeType:", routeType);
    return;
  }
 
  const allRoutes = routeDataByMode[routeType];
  if (!allRoutes || !Array.isArray(allRoutes.features)) {
    console.warn("Keine Routendaten geladen für:", routeType);
    return;
  }
 
  const normalizedRef = String(routeRef ?? "").trim().toLowerCase();
 
  const matches = allRoutes.features.filter(feature => {
    const ref = String(feature?.properties?.ref ?? "").trim().toLowerCase();
    return ref === normalizedRef;
  });
 
  console.log("Highlight ref matches:", routeType, routeRef, matches.length);
 
  const source = map.getSource(mode.routeHighlightSourceId);
  if (!source) {
    console.warn("Highlight-Source fehlt:", mode.routeHighlightSourceId);
    return;
  }
 
  clearAllHighlights();
 
  source.setData({
    type: "FeatureCollection",
    features: matches
  });
 
  fitToFeatures(matches);
}
 
function buildPopupContent(feature) {
  const properties = feature.properties || {};
  const stopName =
    properties.name ||
    properties.local_ref ||
    properties.ref ||
    "Haltestelle";
 
  const routeType = String(properties.route_type || "").trim().toLowerCase();
  const mode = MODES[routeType];
  const routes = normalizeRoutesFromStopProperties(properties);
 
  const wrapper = document.createElement("div");
 
  const title = document.createElement("div");
  title.className = "popup-stop-name";
  title.textContent = stopName;
  wrapper.appendChild(title);
 
  const subtitle = document.createElement("div");
  subtitle.className = "popup-subtitle";
  subtitle.textContent = `${mode ? mode.label : routeType}-Linien`;
  wrapper.appendChild(subtitle);
 
  if (!routes.length) {
    const empty = document.createElement("div");
    empty.textContent = "Keine Linieninformationen vorhanden.";
    wrapper.appendChild(empty);
    return wrapper;
  }
 
  const list = document.createElement("ul");
  list.className = "route-list";
 
  for (const route of routes) {
    if (route.variants && route.variants.length) {
      for (const variant of route.variants) {
        const li = document.createElement("li");
        const btn = document.createElement("button");
 
        btn.type = "button";
        btn.className = "route-link";
 
        const label = route.ref
          ? `${route.ref}: ${variant.from || "?"} → ${variant.to || "?"}`
          : `${variant.from || "?"} → ${variant.to || "?"}`;
 
        btn.textContent = label;
        btn.setAttribute("data-route-type", routeType);
        btn.setAttribute("data-relation-id", variant.relation_id || "");
 
        li.appendChild(btn);
        list.appendChild(li);
      }
    } else {
      const li = document.createElement("li");
      const btn = document.createElement("button");
 
      btn.type = "button";
      btn.className = "route-link";
      btn.textContent = route.ref || route.name || "(ohne Bezeichnung)";
      btn.setAttribute("data-route-type", routeType);
      btn.setAttribute("data-route-ref", route.ref || "");
 
      li.appendChild(btn);
      list.appendChild(li);
    }
  }
 
  wrapper.appendChild(list);
 
  wrapper.addEventListener("click", (event) => {
    const relationBtn = event.target.closest("[data-route-type][data-relation-id]");
    if (relationBtn) {
      event.preventDefault();
      event.stopPropagation();
 
      const clickedRouteType = relationBtn.getAttribute("data-route-type");
      const relationId = relationBtn.getAttribute("data-relation-id");
 
      showRouteHighlightByRelation(clickedRouteType, relationId);
      return;
    }
 
    const refBtn = event.target.closest("[data-route-type][data-route-ref]");
    if (refBtn) {
      event.preventDefault();
      event.stopPropagation();
 
      const clickedRouteType = refBtn.getAttribute("data-route-type");
      const clickedRouteRef = refBtn.getAttribute("data-route-ref");
 
      showRouteHighlightByRef(clickedRouteType, clickedRouteRef);
    }
  });
 
  return wrapper;
}
 
function openStopPopup(feature, lngLat) {
  closeActivePopup();
 
  activePopup = new maplibregl.Popup({
    closeButton: true,
    closeOnClick: false
  })
    .setLngLat(lngLat)
    .setDOMContent(buildPopupContent(feature))
    .addTo(map);
}
 
async function loadJson(url) {
  const response = await fetch(url);
  if (!response.ok) {
    throw new Error(`Fehler beim Laden von ${url}: HTTP ${response.status}`);
  }
  return await response.json();
}
 
function addModeLayers(modeKey, stopsGeojson, routesGeojson) {
  const mode = MODES[modeKey];
  routeDataByMode[modeKey] = routesGeojson;
 
  map.addSource(mode.stopSourceId, {
    type: "geojson",
    data: stopsGeojson
  });
 
  map.addSource(mode.routeHighlightSourceId, {
    type: "geojson",
    data: {
      type: "FeatureCollection",
      features: []
    }
  });
 
  map.addLayer({
    id: mode.routeHighlightLayerId,
    type: "line",
    source: mode.routeHighlightSourceId,
    layout: {
      "line-cap": "round",
      "line-join": "round"
    },
    paint: {
      "line-color": mode.color,
      "line-width": [
        "interpolate", ["linear"], ["zoom"],
        10, 3,
        14, 5,
        18, 8
      ],
      "line-opacity": 0.95
    }
  });
 
  map.addLayer({
    id: mode.stopLayerId,
    type: "circle",
    source: mode.stopSourceId,
    paint: {
      "circle-radius": [
        "interpolate", ["linear"], ["zoom"],
        10, 3,
        14, 5,
        18, 7
      ],
      "circle-color": mode.color,
      "circle-stroke-width": 1.2,
      "circle-stroke-color": "#ffffff"
    }
  });
 
  map.on("click", mode.stopLayerId, (e) => {
    if (!e.features || !e.features.length) return;
    openStopPopup(e.features[0], e.lngLat);
  });
 
  map.on("mouseenter", mode.stopLayerId, () => {
    map.getCanvas().style.cursor = "pointer";
  });
 
  map.on("mouseleave", mode.stopLayerId, () => {
    map.getCanvas().style.cursor = "";
  });
}
 
map.on("load", async () => {
  try {
    for (const [modeKey, mode] of Object.entries(MODES)) {
      const [stopsGeojson, routesGeojson] = await Promise.all([
        loadJson(mode.stopsUrl),
        loadJson(mode.routesUrl)
      ]);
 
      addModeLayers(modeKey, stopsGeojson, routesGeojson);
    }
 
    const stopLayerIds = Object.values(MODES).map(mode => mode.stopLayerId);
 
    map.on("click", (e) => {
      const hits = map.queryRenderedFeatures(e.point, {
        layers: stopLayerIds
      });
 
      if (!hits.length) {
        closeActivePopup();
        clearAllHighlights();
      }
    });
 
    console.log("Öffi-Daten geladen.");
  } catch (error) {
    console.error(error);
  }
});
</script>
 
</body>
</html>

V2

Öffi Router

Data Export

osmium tags-filter wien.osm.pbf r/route=bus -o bus-routes-only.osm.pbf -O
osmium cat bus-routes-only.osm.pbf -f opl | awk -F' ' '$1 ~ /^r/ {print $1}' > bus-route-ids.txt
osmium getid -r wien.osm.pbf $(cat bus-route-ids.txt) -o bus-complete.osm.pbf -O
 
osmium tags-filter wien.osm.pbf r/route=tram -o tram-routes-only.osm.pbf -O
osmium cat tram-routes-only.osm.pbf -f opl | awk -F' ' '$1 ~ /^r/ {print $1}' > tram-route-ids.txt
osmium getid -r wien.osm.pbf $(cat tram-route-ids.txt) -o tram-complete.osm.pbf -O
 
osmium tags-filter wien.osm.pbf r/route=subway -o subway-routes-only.osm.pbf -O
osmium cat subway-routes-only.osm.pbf -f opl | awk -F' ' '$1 ~ /^r/ {print $1}' > subway-route-ids.txt
osmium getid -r wien.osm.pbf $(cat subway-route-ids.txt) -o subway-complete.osm.pbf -O
 
osmium tags-filter wien.osm.pbf r/route=train -o train-routes-only.osm.pbf -O
osmium cat train-routes-only.osm.pbf -f opl | awk -F' ' '$1 ~ /^r/ {print $1}' > train-route-ids.txt
osmium getid -r wien.osm.pbf $(cat train-route-ids.txt) -o train-complete.osm.pbf -O

Python script

#!/usr/bin/env python3
import json
import math
import re
import subprocess
import sys
import xml.etree.ElementTree as ET
from collections import defaultdict
 
 
def fail(msg, code=1):
    print(msg, file=sys.stderr)
    sys.exit(code)
 
 
def haversine_m(lat1, lon1, lat2, lon2):
    r = 6371000.0
    p1 = math.radians(lat1)
    p2 = math.radians(lat2)
    dp = math.radians(lat2 - lat1)
    dl = math.radians(lon2 - lon1)
    a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
    return 2 * r * math.asin(math.sqrt(a))
 
 
def normalize_name(name):
    name = (name or "").strip().lower()
    name = name.replace("wien ", "")
    name = name.replace("/", " ")
    name = name.replace("-", " ")
    name = re.sub(r"[()\,\.]", " ", name)
    name = re.sub(r"\s+", " ", name).strip()
    return name
 
 
def display_name(tags, fallback_id):
    return (
        tags.get("name")
        or tags.get("local_ref")
        or tags.get("ref")
        or f"stop-{fallback_id}"
    )
 
 
def is_station_only(tags):
    public_transport = tags.get("public_transport")
    railway = tags.get("railway")
    station = tags.get("station")
 
    if public_transport == "station":
        return True
    if railway == "station":
        return True
    if station == "subway":
        return True
    return False
 
 
def is_real_stop_position(tags):
    return tags.get("public_transport") == "stop_position"
 
 
def is_real_platform(tags, route_type):
    public_transport = tags.get("public_transport")
    highway = tags.get("highway")
    railway = tags.get("railway")
 
    if public_transport == "platform":
        return True
    if route_type == "bus" and highway == "bus_stop":
        return True
    if route_type == "tram" and railway == "tram_stop":
        return True
    return False
 
 
def classify_member(member, node, route_type):
    role = (member.get("role") or "").strip().lower()
    tags = node["tags"]
 
    if is_station_only(tags):
        return None
 
    if role in {"stop", "stop_entry_only", "stop_exit_only"}:
        if is_real_stop_position(tags):
            return "stop"
        # wenn Rolle stop ist, aber kein station-only Objekt, trotzdem akzeptieren
        return "stop"
 
    if is_real_stop_position(tags):
        return "stop"
 
    if role in {"platform", "platform_entry_only", "platform_exit_only"}:
        if is_real_platform(tags, route_type):
            return "platform"
 
    if is_real_platform(tags, route_type):
        return "platform"
 
    return None
 
 
def extract_relation_stop_sequence(rel, nodes, route_type):
    raw = []
 
    for idx, member in enumerate(rel["members"]):
        if member.get("type") != "node":
            continue
 
        ref = member.get("ref")
        node = nodes.get(ref)
        if not node:
            continue
 
        kind = classify_member(member, node, route_type)
        if kind is None:
            continue
 
        tags = node["tags"]
        name = display_name(tags, ref)
        norm = normalize_name(name)
        if not norm:
            continue
 
        raw.append({
            "node_id": ref,
            "name": name,
            "normalized_name": norm,
            "lat": node["lat"],
            "lon": node["lon"],
            "kind": kind,
            "member_index": idx,
        })
 
    if not raw:
        return []
 
    grouped = []
    current_group = [raw[0]]
 
    for item in raw[1:]:
        if item["normalized_name"] == current_group[-1]["normalized_name"]:
            current_group.append(item)
        else:
            grouped.append(current_group)
            current_group = [item]
    grouped.append(current_group)
 
    cleaned = []
    for group in grouped:
        stop_candidates = [g for g in group if g["kind"] == "stop"]
        chosen = stop_candidates[0] if stop_candidates else group[0]
        cleaned.append(chosen)
 
    final = []
    for item in cleaned:
        if final and final[-1]["normalized_name"] == item["normalized_name"]:
            continue
        final.append(item)
 
    return final
 
 
def build_spatial_index(stations, cell_size_deg=0.003):
    grid = defaultdict(list)
    for sid, st in stations.items():
        key = (int(st["lon"] / cell_size_deg), int(st["lat"] / cell_size_deg))
        grid[key].append(sid)
    return grid
 
 
def nearby_station_pairs(stations, max_transfer_m=120):
    grid = build_spatial_index(stations)
    cell_size_deg = 0.003
    seen = set()
 
    for sid, st in stations.items():
        gx = int(st["lon"] / cell_size_deg)
        gy = int(st["lat"] / cell_size_deg)
 
        for dx in (-1, 0, 1):
            for dy in (-1, 0, 1):
                for other_sid in grid.get((gx + dx, gy + dy), []):
                    if other_sid == sid:
                        continue
 
                    a, b = sorted((sid, other_sid))
                    if (a, b) in seen:
                        continue
                    seen.add((a, b))
 
                    sa = stations[a]
                    sb = stations[b]
                    dist = haversine_m(sa["lat"], sa["lon"], sb["lat"], sb["lon"])
                    if dist <= max_transfer_m:
                        yield a, b, dist
 
 
args = sys.argv[1:]
if len(args) < 3:
    fail(
        "Aufruf: python3 osm_route_relations_to_station_graph.py "
        "input.osm.pbf graph.json route_type [--transfer=120]"
    )
 
input_pbf = args[0]
graph_json_path = args[1]
route_type = args[2].strip().lower()
max_transfer_m = 120.0
 
for arg in args[3:]:
    if arg.startswith("--transfer="):
        max_transfer_m = float(arg.split("=", 1)[1])
    else:
        fail(f"Unbekanntes Argument: {arg}")
 
if route_type not in {"bus", "tram", "subway", "train"}:
    fail("route_type muss einer von: bus, tram, subway, train sein.")
 
try:
    proc = subprocess.Popen(
        ["osmium", "cat", input_pbf, "-f", "osm"],
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
        text=True,
        encoding="utf-8",
    )
except FileNotFoundError:
    fail("Fehler: 'osmium' wurde nicht gefunden.")
 
if proc.stdout is None or proc.stderr is None:
    fail("Fehler: Konnte osmium-Streams nicht öffnen.")
 
nodes = {}
route_relations = []
 
try:
    for _, elem in ET.iterparse(proc.stdout, events=("end",)):
        if elem.tag == "node":
            node_id = elem.get("id")
            lat = elem.get("lat")
            lon = elem.get("lon")
            if node_id and lat and lon:
                tags = {tag.get("k"): tag.get("v") for tag in elem.findall("tag")}
                nodes[node_id] = {
                    "id": node_id,
                    "lat": float(lat),
                    "lon": float(lon),
                    "tags": tags,
                }
            elem.clear()
 
        elif elem.tag == "relation":
            rel_tags = {tag.get("k"): tag.get("v") for tag in elem.findall("tag")}
            if rel_tags.get("route") == route_type:
                members = []
                for member in elem.findall("member"):
                    members.append({
                        "type": member.get("type"),
                        "ref": member.get("ref"),
                        "role": member.get("role", ""),
                    })
                route_relations.append({
                    "id": elem.get("id"),
                    "tags": rel_tags,
                    "members": members,
                })
            elem.clear()
 
except ET.ParseError as e:
    fail(f"XML Parse Error: {e}")
 
stderr_text = proc.stderr.read()
return_code = proc.wait()
if return_code != 0:
    fail(f"Fehler bei 'osmium cat':\n{stderr_text}")
 
stations = {}
station_members = defaultdict(list)
edges = []
route_variants = []
 
skipped_night_buses = 0
 
for rel in route_relations:
    rel_id = rel["id"]
    rel_tags = rel["tags"]
 
    ref = (rel_tags.get("ref") or "").strip().upper()
    if route_type == "bus" and ref.startswith("N"):
        skipped_night_buses += 1
        continue
 
    stop_seq = extract_relation_stop_sequence(rel, nodes, route_type)
    if len(stop_seq) < 2:
        continue
 
    ordered_station_ids = []
 
    for item in stop_seq:
        sid = f"station:{item['normalized_name']}"
        ordered_station_ids.append(sid)
 
        if sid not in stations:
            stations[sid] = {
                "id": sid,
                "type": "station",
                "name": item["name"],
                "normalized_name": item["normalized_name"],
                "lat": item["lat"],
                "lon": item["lon"],
            }
 
        station_members[sid].append(item)
 
    route_meta = {
        "relation_id": rel_id,
        "ref": rel_tags.get("ref"),
        "route_name": rel_tags.get("name"),
        "route_from": rel_tags.get("from"),
        "route_to": rel_tags.get("to"),
        "operator": rel_tags.get("operator"),
        "network": rel_tags.get("network"),
        "route_type": route_type,
    }
 
    for i in range(len(ordered_station_ids) - 1):
        a = ordered_station_ids[i]
        b = ordered_station_ids[i + 1]
        if a == b:
            continue
 
        sa = stations[a]
        sb = stations[b]
        dist = haversine_m(sa["lat"], sa["lon"], sb["lat"], sb["lon"])
 
        edges.append({
            "id": f"ride:{rel_id}:{i}",
            "type": "ride",
            "from": a,
            "to": b,
            "distance_m": round(dist, 1),
            "weight": round(dist, 1),
            "from_stop_name": sa["name"],
            "to_stop_name": sb["name"],
            **route_meta,
        })
 
        edges.append({
            "id": f"ride:{rel_id}:{i}:rev",
            "type": "ride",
            "from": b,
            "to": a,
            "distance_m": round(dist, 1),
            "weight": round(dist, 1),
            "from_stop_name": sb["name"],
            "to_stop_name": sa["name"],
            **route_meta,
            "is_reverse_edge": True,
        })
 
    route_variants.append({
        "relation_id": rel_id,
        "ref": rel_tags.get("ref"),
        "route_name": rel_tags.get("name"),
        "route_from": rel_tags.get("from"),
        "route_to": rel_tags.get("to"),
        "route_type": route_type,
        "ordered_station_ids": ordered_station_ids,
        "ordered_station_names": [s["name"] for s in stop_seq],
        "ordered_stop_kinds": [s["kind"] for s in stop_seq],
    })
 
for sid, members in station_members.items():
    if members:
        stations[sid]["lat"] = sum(m["lat"] for m in members) / len(members)
        stations[sid]["lon"] = sum(m["lon"] for m in members) / len(members)
 
for e in edges:
    if e["type"] != "ride":
        continue
    a = stations[e["from"]]
    b = stations[e["to"]]
    dist = haversine_m(a["lat"], a["lon"], b["lat"], b["lon"])
    e["distance_m"] = round(dist, 1)
    e["weight"] = round(dist, 1)
 
for a, b, dist in nearby_station_pairs(stations, max_transfer_m=max_transfer_m):
    sa = stations[a]
    sb = stations[b]
 
    edges.append({
        "id": f"transfer:{a}:{b}",
        "type": "transfer",
        "from": a,
        "to": b,
        "distance_m": round(dist, 1),
        "weight": round(dist, 1),
        "from_stop_name": sa["name"],
        "to_stop_name": sb["name"],
        "route_type": "transfer",
        "transfer_kind": "nearby_local",
    })
    edges.append({
        "id": f"transfer:{b}:{a}",
        "type": "transfer",
        "from": b,
        "to": a,
        "distance_m": round(dist, 1),
        "weight": round(dist, 1),
        "from_stop_name": sb["name"],
        "to_stop_name": sa["name"],
        "route_type": "transfer",
        "transfer_kind": "nearby_local",
    })
 
# Plausibilitätscheck, damit kaputte Läufe sofort abbrechen
if route_type == "bus" and len(stations) < 500:
    fail(f"Bus-Export unplausibel: nur {len(stations)} Stationen. Datei/Script ist kaputt.")
if route_type == "tram" and len(stations) < 100:
    fail(f"Tram-Export unplausibel: nur {len(stations)} Stationen. Datei/Script ist kaputt.")
if route_type == "subway" and len(stations) < 20:
    fail(f"Subway-Export unplausibel: nur {len(stations)} Stationen. Datei/Script ist kaputt.")
if route_type == "train" and len(stations) < 20:
    fail(f"Train-Export unplausibel: nur {len(stations)} Stationen. Datei/Script ist kaputt.")
 
graph = {
    "type": "TransitGraph",
    "route_type": route_type,
    "nodes": list(stations.values()),
    "edges": edges,
    "route_variants": route_variants,
    "transfer_radius_m": max_transfer_m,
}
 
with open(graph_json_path, "w", encoding="utf-8") as f:
    json.dump(graph, f, ensure_ascii=False, indent=2)
 
print(f"Graph geschrieben: {graph_json_path}")
print(f"Typ: {route_type}")
print(f"Route-Relationen: {len(route_relations)}")
print(f"Übersprungene Nachtbusse: {skipped_night_buses}")
print(f"Stationen: {len(stations)}")
print(f"Kanten: {len(edges)}")
print(f"Linienvarianten: {len(route_variants)}")

Datenaufbereitung

python3 osm_route_relations_to_station_graph.py bus-complete.osm.pbf bus-station-graph.json bus --transfer=120
python3 osm_route_relations_to_station_graph.py tram-complete.osm.pbf tram-station-graph.json tram --transfer=120
python3 osm_route_relations_to_station_graph.py subway-complete.osm.pbf subway-station-graph.json subway --transfer=120
python3 osm_route_relations_to_station_graph.py train-complete.osm.pbf train-station-graph.json train --transfer=120
python3 merge_station_graphs.py merged-station-graph.json \
  bus-station-graph.json tram-station-graph.json subway-station-graph.json train-station-graph.json \
  --global-transfer=150

Test

python3 route_station_transit.py \
  merged-station-graph.json \
  16.328566,48.169252 \
  16.387568,48.203825 \
  route-result.json