Unterschiede

Hier werden die Unterschiede zwischen zwei Versionen angezeigt.

Link zu dieser Vergleichsansicht

Beide Seiten der vorigen Revision Vorhergehende Überarbeitung
Nächste Überarbeitung
Vorhergehende Überarbeitung
maplibre [2026/04/02 14:32]
admin [GeoJSON von URL nachladen]
maplibre [2026/04/05 05:11] (aktuell)
jango
Zeile 1: Zeile 1:
 +MapLibre GL ist ein Open-Source Fork von MapBox GL.
 +
 =====Minimalbeispiel===== =====Minimalbeispiel=====
  
Zeile 64: Zeile 66:
 </code> </code>
  
 +====Marker====
 +
 +<code javascript>
 +// 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);
 +</code>
 +
 +<code javascript>
 +// 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();
 +</code>
 +
 +<code javascript>
 +// Marker per Klick setzen
 +let currentMarker = null;
 +
 +map.on("click", (e) => {
 +  if (currentMarker) {
 +    currentMarker.remove();
 +  }
 +
 +  currentMarker = new maplibregl.Marker()
 +    .setLngLat(e.lngLat)
 +    .addTo(map);
 +});
 +</code>
 +
 +<code javascript>
 +// 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);
 +</code>
 ====GeoJSON einbinden==== ====GeoJSON einbinden====
 im map load Event im map load Event
-<code>+<code javascript>
 map.on("load", () => { map.on("load", () => {
         console.log("Karte geladen");         console.log("Karte geladen");
Zeile 159: Zeile 243:
 Beispiele  Beispiele 
 <code javascript> <code javascript>
 +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, { addGeoJsonLayerToMap(map, {
     sourceId: "pt-source",     sourceId: "pt-source",
Zeile 210: Zeile 314:
 loadExternalGeoJson("orte.geojson", "orte", "circle"); loadExternalGeoJson("orte.geojson", "orte", "circle");
 </code> </code>
 +
 +=====OEPNV=====
 +
 +Siehe auch [[GTFS]]
 +
 +====V1====
 +===Data Export===
 +<code bash>
 +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
 +</code>
 +
 +===Python script===
 +<code python>
 +#!/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)}")
 +</code>
 +
 +===Datenaufbereitung===
 +<code bash>
 +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
 +</code>
 +
 +===Anzeigen===
 +<code html>
 +<!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>
 +</code>
 +
 +====V2====
 +
 +Öffi Router
 +
 +===Data Export===
 +<code bash>
 +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
 +</code>
 +
 +===Python script===
 +<code python>
 +#!/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)}")
 +</code>
 +
 +===Datenaufbereitung===
 +<code>
 +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
 +</code>
 +
 +<code>
 +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
 +</code>
 +
 +===Test===
 +<code>
 +python3 route_station_transit.py \
 +  merged-station-graph.json \
 +  16.328566,48.169252 \
 +  16.387568,48.203825 \
 +  route-result.json
 +</code>
 +
 +=====Links=====
 +
 +  * [[https://dev.to/geoapify-maps-api/how-to-visualize-and-style-routes-on-a-maplibre-gl-map-416g|Visual and style routes on a map]]