"use strict"; const EMPTY = { type: "FeatureCollection", features: [] }; const protocol = new pmtiles.Protocol(); maplibregl.addProtocol("pmtiles", protocol.tile); const flavor = basemaps.namedFlavor("light"); const baseLayers = basemaps.layers("osm", flavor, { lang: "de" }); const labelIds = new Set(basemaps.layers("osm", flavor, { labelsOnly: true, lang: "de" }).map((l) => l.id)); // Verspätung in Sekunden -> Farbe (SBB zählt < 3 min als pünktlich) const delayColor = ["case", ["==", ["get", "delay"], null], "#94a3b8", ["<", ["get", "delay"], 180], "#16a34a", ["<", ["get", "delay"], 360], "#eab308", ["<", ["get", "delay"], 900], "#f97316", "#dc2626"]; const map = new maplibregl.Map({ container: "map", center: [8.23, 46.82], zoom: 7.4, hash: true, style: { version: 8, glyphs: "https://protomaps.github.io/basemaps-assets/fonts/{fontstack}/{range}.pbf", sprite: "https://protomaps.github.io/basemaps-assets/sprites/v4/light", sources: { osm: { type: "vector", url: `pmtiles://${location.origin}/tiles/region.pmtiles`, attribution: 'Protomaps © OpenStreetMap-Mitwirkende', }, s2: { type: "raster", tileSize: 256, maxzoom: 14, tiles: ["https://tiles.maps.eox.at/wmts/1.0.0/s2cloudless-2024_3857/default/g/{z}/{y}/{x}.jpg"], attribution: 'Sentinel-2 cloudless by EOX IT Services GmbH (Contains modified Copernicus Sentinel data 2024)', }, swissimage: { type: "raster", tileSize: 256, minzoom: 8, maxzoom: 19, bounds: [5.96, 45.82, 10.49, 47.81], tiles: ["https://wmts.geo.admin.ch/1.0.0/ch.swisstopo.swissimage/default/current/3857/{z}/{x}/{y}.jpeg"], attribution: '© swisstopo', }, route: { type: "geojson", data: EMPTY }, trains: { type: "geojson", data: EMPTY, attribution: 'Fahrplan & Echtzeit: opentransportdata.swiss', }, }, layers: [ { id: "s2", type: "raster", source: "s2", layout: { visibility: "none" } }, { id: "swissimage", type: "raster", source: "swissimage", layout: { visibility: "none" } }, ...baseLayers, { id: "route", type: "line", source: "route", layout: { "line-cap": "round", "line-join": "round" }, paint: { "line-color": "#2563eb", "line-width": 4, "line-opacity": 0.85 } }, { id: "trains", type: "circle", source: "trains", paint: { "circle-color": delayColor, "circle-radius": ["interpolate", ["linear"], ["zoom"], 6, 3, 10, 5.5, 14, 9], "circle-stroke-color": "#fff", "circle-stroke-width": ["interpolate", ["linear"], ["zoom"], 6, 0.6, 12, 1.8], } }, { id: "train-labels", type: "symbol", source: "trains", minzoom: 10, layout: { "text-field": ["concat", ["get", "line"], " ", ["get", "nr"]], "text-font": ["Noto Sans Medium"], "text-size": 11, "text-offset": [0, 1.1], "text-anchor": "top", "text-optional": true, }, paint: { "text-color": "#111827", "text-halo-color": "#fff", "text-halo-width": 1.5 } }, ], }, }); map.addControl(new maplibregl.NavigationControl({ visualizePitch: false }), "bottom-right"); map.addControl(new maplibregl.ScaleControl(), "bottom-left"); // ---------- Karte / Satellit ---------- function setBackground(sat) { for (const id of ["s2", "swissimage"]) map.setLayoutProperty(id, "visibility", sat ? "visible" : "none"); for (const l of baseLayers) { if (!labelIds.has(l.id)) map.setLayoutProperty(l.id, "visibility", sat ? "none" : "visible"); } document.getElementById("btn-map").setAttribute("aria-pressed", String(!sat)); document.getElementById("btn-sat").setAttribute("aria-pressed", String(sat)); try { localStorage.setItem("sat", sat ? "1" : ""); } catch (e) { /* privater Modus */ } } document.getElementById("btn-map").onclick = () => setBackground(false); document.getElementById("btn-sat").onclick = () => setBackground(true); // ---------- Züge ---------- async function refreshTrains() { if (document.hidden) return; try { const r = await fetch("api/vehicles"); if (r.ok) map.getSource("trains").setData(await r.json()); } catch (e) { /* nächster Versuch in ein paar Sekunden */ } } async function refreshStatus() { const el = document.getElementById("status"); try { const s = await (await fetch("api/status")).json(); if (!s.feed_version) { el.textContent = "Noch kein Fahrplan geladen …"; el.className = "warn"; return; } const v = s.feed_version; const parts = [`Fahrplan ${v.slice(6, 8)}.${v.slice(4, 6)}.${v.slice(0, 4)}`, `${s.vehicles} Züge`]; let warn = !s.shapes; if (!s.realtime) { parts.push("keine Echtzeit (API-Key fehlt)"); warn = true; } else if (s.rt_error || s.rt_age === null || s.rt_age > 180) { parts.push("Echtzeit gestört"); warn = true; } else parts.push(`Echtzeit vor ${s.rt_age} s`); if (!s.shapes) parts.push("Strecken: Luftlinie"); el.textContent = parts.join(" · "); el.className = warn ? "warn" : ""; } catch (e) { el.textContent = "Server nicht erreichbar"; el.className = "warn"; } } // ---------- Zugdetails ---------- let openId = null; const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]); function delayBadge(sec) { if (sec === null || sec === undefined) return 'ohne Echtzeit'; const min = Math.round(sec / 60); const c = sec < 180 ? "--ok" : sec < 360 ? "--d1" : sec < 900 ? "--d2" : "--d3"; return `${min <= 0 ? "pünktlich" : "+" + min + "′"}`; } function delayText(sec) { if (sec === null || sec === undefined || Math.round(sec / 60) === 0) return ""; const min = Math.round(sec / 60); const c = sec < 180 ? "--ok" : sec < 360 ? "--d1" : sec < 900 ? "--d2" : "--d3"; return `${min > 0 ? "+" : ""}${min}′`; } async function showTrip(id) { openId = id; const r = await fetch(`api/trip/${id}`); if (!r.ok || openId !== id) return; const t = await r.json(); const next = t.stops.find((s) => !s.passed); const curDelay = t.realtime ? (next ? (next.arr_delay ?? next.dep_delay) : t.stops.at(-1).arr_delay) : null; document.getElementById("detail").innerHTML = `
nach ${esc(t.to)} · ${esc(t.agency)}