Inhaltsverzeichnis
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