Dies ist eine alte Version des Dokuments!
Inhaltsverzeichnis
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
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
#!/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)}")
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
<!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>