"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: "symbol", source: "trains", layout: { // Tropfen mit Spitze in Fahrtrichtung; Züge ohne bekannte Richtung als Punkt "icon-image": ["case", ["has", "bearing"], "drop", "dot"], "icon-rotate": ["coalesce", ["get", "bearing"], 0], "icon-rotation-alignment": "map", "icon-size": ["interpolate", ["linear"], ["zoom"], 6, 0.7, 10, 1.1, 14, 1.6], "icon-allow-overlap": true, "icon-ignore-placement": true, }, paint: { "icon-color": delayColor, "icon-halo-color": "#fff", "icon-halo-width": 4, } }, { id: "train-labels", type: "symbol", source: "trains", minzoom: 10, layout: { "text-field": ["get", "line"], "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 ---------- // Server liefert je Zug die Position jetzt und in `ahead` Sekunden; dazwischen wird linear gefahren. let trains = { type: "FeatureCollection", features: [] }; let ts = 0, ahead = 10, clockOffset = 0; async function refreshTrains() { if (document.hidden) return; try { const r = await fetch("api/vehicles"); if (!r.ok) return; const now = Number(r.headers.get("X-Now")); const d = await r.json(); if (now) clockOffset = Date.now() / 1000 - now; const old = new Map(trains.features.map((f) => [f.properties.id, f.properties.bearing])); for (const f of d.features) { f.start = f.geometry.coordinates; const b = f.next && bearing(f.start, f.next); if (b !== null && b !== undefined) f.properties.bearing = b; else if (old.get(f.properties.id) !== undefined) f.properties.bearing = old.get(f.properties.id); // steht: alte Richtung behalten } trains = d; ts = d.ts; ahead = d.ahead || 10; } catch (e) { /* nächster Versuch in ein paar Sekunden */ } } // Fahrtrichtung in Grad (0 = Norden, im Uhrzeigersinn), in Mercator gerechnet wie die Karte function bearing(a, b) { const y = (lat) => Math.log(Math.tan(Math.PI / 4 + (lat * Math.PI) / 360)); const dx = ((b[0] - a[0]) * Math.PI) / 180, dy = y(b[1]) - y(a[1]); if (Math.abs(dx) < 1e-9 && Math.abs(dy) < 1e-9) return null; return (Math.atan2(dx, dy) * 180) / Math.PI; } // SDF-Icons, damit MapLibre sie nach Verspätung einfärben und mit weissem Rand zeichnen kann function sdfIcon(inside) { const S = 64, R = 8, img = new Uint8Array(S * S * 4), mask = []; for (let y = 0; y < S; y++) for (let x = 0; x < S; x++) mask.push(inside(x + 0.5, y + 0.5)); for (let y = 0; y < S; y++) for (let x = 0; x < S; x++) { const self = mask[y * S + x]; let d = R; // Abstand zur nächsten Kante, gesucht im Umkreis R for (let j = -R; j <= R; j++) for (let i = -R; i <= R; i++) { const xx = x + i, yy = y + j; const other = xx >= 0 && yy >= 0 && xx < S && yy < S ? mask[yy * S + xx] : false; if (other !== self) d = Math.min(d, Math.hypot(i, j) - 0.5); } const sd = self ? -d : d; img[(y * S + x) * 4 + 3] = Math.max(0, Math.min(255, Math.round(255 * (1 - (sd / R + 0.25))))); } return { width: S, height: S, data: img }; } const C = 32, RAD = 13; // Kreis in der Bildmitte, damit die Drehung um den Kreismittelpunkt geht map.addImage("dot", sdfIcon((x, y) => Math.hypot(x - C, y - C) <= RAD), { sdf: true, pixelRatio: 2 }); map.addImage("drop", sdfIcon((x, y) => { if (Math.hypot(x - C, y - C) <= RAD) return true; const tip = 6, h = C - tip; // Spitze oben (Norden); Seiten tangential an den Kreis if (y < tip || y > C) return false; const half = (RAD * (y - tip)) / h * 1.15; return Math.abs(x - C) <= half; }), { sdf: true, pixelRatio: 2 }); function render() { // leicht über `ahead` hinaus weiterfahren, falls eine Abfrage hängt, dann anhalten const k = Math.max(0, Math.min((Date.now() / 1000 - clockOffset - ts) / ahead, 1.5)); for (const f of trains.features) { if (!f.next) continue; f.geometry.coordinates = [f.start[0] + (f.next[0] - f.start[0]) * k, f.start[1] + (f.next[1] - f.start[1]) * k]; } map.getSource("trains").setData(trains); } let lastFrame = 0; function animate(ms) { if (!document.hidden && ms - lastFrame > 66) { // ~15 Bilder/s reichen und schonen Akku lastFrame = ms; render(); } requestAnimationFrame(animate); } 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)}