Files
Trainpulse/web/app.js
T

266 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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: '<a href="https://protomaps.com">Protomaps</a> © <a href="https://www.openstreetmap.org/copyright">OpenStreetMap-Mitwirkende</a>',
},
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: '<a href="https://s2maps.eu">Sentinel-2 cloudless</a> 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: '© <a href="https://www.swisstopo.admin.ch">swisstopo</a>',
},
route: { type: "geojson", data: EMPTY },
trains: {
type: "geojson", data: EMPTY,
attribution: 'Fahrplan &amp; Echtzeit: <a href="https://opentransportdata.swiss">opentransportdata.swiss</a>',
},
},
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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);
function delayBadge(sec) {
if (sec === null || sec === undefined) return '<span class="badge" style="background:var(--na)">ohne Echtzeit</span>';
const min = Math.round(sec / 60);
const c = sec < 180 ? "--ok" : sec < 360 ? "--d1" : sec < 900 ? "--d2" : "--d3";
return `<span class="badge" style="background:var(${c})">${min <= 0 ? "pünktlich" : "+" + min + "′"}</span>`;
}
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 `<span class="d" style="color:var(${c})">${min > 0 ? "+" : ""}${min}′</span>`;
}
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 = `
<h2>${esc(t.line)} ${esc(t.nr)} ${t.canceled ? '<span class="badge" style="background:var(--d3)">fällt aus</span>' : delayBadge(curDelay)}</h2>
<p class="sub">nach ${esc(t.to)} · ${esc(t.agency)}</p>
<ul class="stops">${t.stops.map((s) => `
<li class="${s.passed ? "passed" : ""}">
<span class="t">${esc(s.arr ?? s.dep)}</span>
<span>${esc(s.name)}${s.platform ? ` <span class="pl">Gl. ${esc(s.platform)}</span>` : ""}</span>
${delayText(s.arr !== null ? s.arr_delay : s.dep_delay)}
</li>`).join("")}
</ul>`;
document.getElementById("panel").hidden = false;
map.getSource("route").setData({ type: "Feature", geometry: { type: "LineString", coordinates: t.shape } });
}
function closeTrip() {
openId = null;
document.getElementById("panel").hidden = true;
map.getSource("route").setData(EMPTY);
}
document.getElementById("close").onclick = closeTrip;
map.on("click", "trains", (e) => showTrip(e.features[0].properties.id));
const hover = new maplibregl.Popup({ closeButton: false, closeOnClick: false, offset: 12, className: "hover" });
map.on("mousemove", "trains", (e) => {
map.getCanvas().style.cursor = "pointer";
const p = e.features[0].properties;
const delay = p.delay === undefined || p.delay === null ? "" : delayText(Number(p.delay)) || '<span class="d" style="color:var(--ok)">pünktlich</span>';
hover.setLngLat(e.features[0].geometry.coordinates)
.setHTML(`<b>${esc(p.line)} ${esc(p.nr)}</b> ${delay}<br>nach ${esc(p.to)}`).addTo(map);
});
map.on("mouseleave", "trains", () => { map.getCanvas().style.cursor = ""; hover.remove(); });
map.on("load", () => {
let sat = false;
try { sat = localStorage.getItem("sat") === "1"; } catch (e) { /* egal */ }
setBackground(sat);
refreshTrains();
refreshStatus();
setInterval(refreshTrains, 5000);
requestAnimationFrame(animate);
setInterval(refreshStatus, 30000);
setInterval(() => openId && showTrip(openId), 30000);
});