Files
Trainpulse/web/app.js
T

336 lines
15 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);
// Drei Grundkarten-Sätze im selben Style, umgeschaltet per Sichtbarkeit:
// hell, dunkel, und Beschriftung für Satellit (weisse Schrift mit dunklem Rand, wie bei Google Maps)
const prefixed = (layers, pre, extra = {}) => layers.filter((l) => l.id !== "roads_shields").map((l) => ({ ...l, id: pre + l.id,
layout: { ...l.layout, visibility: "none" }, paint: { ...l.paint, ...(l.type === "symbol" ? extra : {}) } }));
const LAYERS = {
light: prefixed(basemaps.layers("osm", basemaps.namedFlavor("light"), { lang: "de" }), "l-"),
dark: prefixed(basemaps.layers("osm", basemaps.namedFlavor("dark"), { lang: "de" }), "d-"),
sat: prefixed(basemaps.layers("osm", basemaps.namedFlavor("dark"), { labelsOnly: true, lang: "de" }), "s-",
{ "text-color": "#ffffff", "text-halo-color": "rgba(0,0,0,0.85)", "text-halo-width": 1.6, "text-halo-blur": 0.5 }),
};
// 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: [9.6, 49.2], // Deutschland + Schweiz
zoom: 5.6,
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>, '
+ '<a href="https://gtfs.de">gtfs.de</a> (<a href="https://www.delfi.de">DELFI e.V.</a>, CC BY-SA 4.0)',
},
},
layers: [
{ id: "s2", type: "raster", source: "s2", layout: { visibility: "none" } },
{ id: "swissimage", type: "raster", source: "swissimage", layout: { visibility: "none" } },
...LAYERS.light, ...LAYERS.dark, ...LAYERS.sat,
{ 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"], "tp-arrow", "tp-dot"],
"icon-rotate": ["coalesce", ["get", "bearing"], 0],
"icon-rotation-alignment": "map",
"icon-size": ["interpolate", ["linear"], ["zoom"], 6, 0.3, 9, 0.5, 12, 0.9, 14, 1.3],
"icon-allow-overlap": true, "icon-ignore-placement": true,
},
paint: {
"icon-color": delayColor, "icon-halo-color": "#fff",
// Rand muss mit der Symbolgrösse skalieren, sonst füllt er bei kleinem Zoom das ganze Quadrat
"icon-halo-width": ["interpolate", ["linear"], ["zoom"], 6, 0.8, 9, 1.4, 12, 2.4, 14, 3.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",
// nicht bei jedem Animationsschritt neu auf Überschneidung prüfen, sonst flackert die Linie
"text-allow-overlap": true, "text-ignore-placement": 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 / Dunkel / Satellit ----------
const MODES = ["light", "dark", "sat"];
function setMode(mode) {
if (!MODES.includes(mode)) mode = "light";
const show = (layers, on) => layers.forEach((l) => map.setLayoutProperty(l.id, "visibility", on ? "visible" : "none"));
show(LAYERS.light, mode === "light");
show(LAYERS.dark, mode === "dark");
show(LAYERS.sat, mode === "sat");
for (const id of ["s2", "swissimage"]) map.setLayoutProperty(id, "visibility", mode === "sat" ? "visible" : "none");
const light = mode === "light";
map.setPaintProperty("train-labels", "text-color", light ? "#111827" : "#ffffff");
map.setPaintProperty("train-labels", "text-halo-color", light ? "#ffffff" : "rgba(0,0,0,0.85)");
map.setPaintProperty("trains", "icon-halo-color", light ? "#ffffff" : "#111827");
for (const m of MODES) document.getElementById("btn-" + m).setAttribute("aria-pressed", String(m === mode));
try { localStorage.setItem("mode", mode); } catch (e) { /* privater Modus */ }
}
for (const m of MODES) document.getElementById("btn-" + m).onclick = () => setMode(m);
// ---------- Züge ----------
// Server liefert je Zug die Position jetzt und alle `step` Sekunden danach (entlang der Strecke);
// dazwischen wird gefahren, und die Spitze dreht sich weich in die jeweilige Richtung.
let trains = { type: "FeatureCollection", features: [] };
let ts = 0, step = 2, clockOffset = 0;
let ready = false; // erst Züge zeigen, wenn der Server fertige Daten (inkl. Echtzeit) hat
async function refreshTrains() {
if (document.hidden || !ready) 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.pts = [[...f.geometry.coordinates, f.properties.heading], ...(f.path || [])];
const b = old.get(f.properties.id);
if (b !== undefined) f.properties.bearing = b;
}
trains = d;
ts = d.ts;
step = d.step || 2;
} catch (e) { /* nächster Versuch in ein paar Sekunden */ }
}
// kürzester Drehwinkel von a nach b in Grad (-180..180)
const turn = (a, b) => ((((b - a) % 360) + 540) % 360) - 180;
// SDF-Icons, damit MapLibre sie nach Verspätung einfärben und mit Rand zeichnen kann
const S = 64, R = 8;
function sdfIcon(mask) {
const img = new Uint8Array(S * S * 4);
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 };
}
// Pfeil-Form (Navigations-Cursor), um 45° gedreht, damit die Spitze nach oben (Norden) zeigt
const ARROW = new Path2D("M.008 183.043a38.09 38.09 0 0 0 26.772 36.18l196.737 61.813a10.72 10.72 0 0 1 7.44 7.453l61.82 196.731A38.11 38.11 0 0 0 328.953 512h.3a38.11 38.11 0 0 0 36.282-26.2L510.018 50.311A38.233 38.233 0 0 0 461.7 1.975L26.2 146.458A38.115 38.115 0 0 0 .008 183.043");
function shapeMask(draw, fit = 44) {
const c = document.createElement("canvas");
c.width = c.height = S;
const ctx = c.getContext("2d", { willReadFrequently: true });
const raster = (k, dx, dy) => {
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, S, S);
ctx.translate(S / 2 + dx, S / 2 + dy);
ctx.scale(k, k);
draw(ctx);
const a = ctx.getImageData(0, 0, S, S).data, m = [];
let x0 = S, x1 = 0, y0 = S, y1 = 0;
for (let i = 0; i < S * S; i++) {
m.push(a[i * 4 + 3] > 127);
if (m[i]) { const x = i % S, y = (i / S) | 0; x0 = Math.min(x0, x); x1 = Math.max(x1, x); y0 = Math.min(y0, y); y1 = Math.max(y1, y); }
}
return { m, x0, x1, y0, y1 };
};
let b = raster(0.04, 0, 0); // erst messen, dann skalieren und zentrieren
const k = 0.04 * fit / Math.max(b.x1 - b.x0 + 1, b.y1 - b.y0 + 1);
b = raster(k, 0, 0);
return raster(k, S / 2 - (b.x0 + b.x1 + 1) / 2, S / 2 - (b.y0 + b.y1 + 1) / 2).m;
}
map.addImage("tp-arrow", sdfIcon(shapeMask((ctx) => {
ctx.rotate(-Math.PI / 4);
ctx.translate(-256, -256);
ctx.fill(ARROW);
})), { sdf: true, pixelRatio: 2 });
map.addImage("tp-dot", sdfIcon(shapeMask((ctx) => {
ctx.beginPath();
ctx.arc(0, 0, 100, 0, 2 * Math.PI);
ctx.fill();
}, 26)), { sdf: true, pixelRatio: 2 });
let lastRender = 0;
function render() {
const t = Date.now() / 1000 - clockOffset - ts;
const dtFrame = Math.min(0.2, (performance.now() - lastRender) / 1000); // nach Ruckler nicht schlagartig drehen
lastRender = performance.now();
const ease = 1 - Math.exp(-dtFrame * 6); // Richtung folgt in ~0,3 s
for (const f of trains.features) {
const p = f.pts;
if (!p) continue;
const x = Math.max(0, t / step);
const i = Math.min(Math.floor(x), p.length - 1);
const a = p[i], b = p[Math.min(i + 1, p.length - 1)], k = i + 1 < p.length ? x - i : 0;
f.geometry.coordinates = [a[0] + (b[0] - a[0]) * k, a[1] + (b[1] - a[1]) * k];
// Richtung kommt vom Server (Gleisverlauf an der Position); dazwischen interpolieren und weich nachdrehen
const ha = a[2] ?? b[2], hb = b[2] ?? a[2];
if (ha === null || ha === undefined) continue;
const target = ha + turn(ha, hb) * k;
const cur = f.properties.bearing;
f.properties.bearing = cur === undefined ? target : cur + turn(cur, target) * ease;
}
map.getSource("trains").setData(trains);
if (hoverId) { // Hover-Kärtchen fährt mit dem Zug mit
const f = trains.features.find((x) => x.properties.id === hoverId);
if (f) hover.setLngLat(f.geometry.coordinates); else { hover.remove(); hoverId = null; }
}
}
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"), loading = document.getElementById("loading");
try {
const s = await (await fetch("api/status")).json();
if (!s.ready) {
loading.textContent = s.loaded ? "Züge werden geladen …"
: "Fahrplan wird vorbereitet … beim ersten Start dauert das 30–45 Minuten.";
loading.hidden = false;
return;
}
if (!ready) {
ready = true;
await refreshTrains();
loading.hidden = true;
}
const rtOk = s.rt_ok;
el.textContent = `${s.vehicles} Züge unterwegs` + (rtOk ? "" : s.rt_some
? " · Verspätungen teilweise nicht verfügbar" : " · Verspätungen zurzeit nicht verfügbar");
el.className = rtOk ? "" : "warn";
} catch (e) {
loading.textContent = "Keine Verbindung zum Server …";
loading.hidden = false;
}
}
async function statusLoop() { // bis alles bereit ist alle 2 s nachfragen, danach alle 30 s
await refreshStatus();
setTimeout(statusLoop, ready ? 30000 : 2000);
}
// ---------- 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" });
let hoverId = null;
function hoverHtml(p) {
const delay = p.delay === undefined || p.delay === null ? "" : delayText(Number(p.delay)) || '<span class="d" style="color:var(--ok)">pünktlich</span>';
return `<b>${esc(p.line)} ${esc(p.nr)}</b> ${delay}<br>nach ${esc(p.to)}`;
}
map.on("mousemove", "trains", (e) => {
map.getCanvas().style.cursor = "pointer";
const p = e.features[0].properties;
if (p.id !== hoverId) hover.setHTML(hoverHtml(p));
hoverId = p.id;
hover.setLngLat(e.features[0].geometry.coordinates).addTo(map);
});
map.on("mouseleave", "trains", () => { map.getCanvas().style.cursor = ""; hover.remove(); hoverId = null; });
map.on("load", () => {
let mode = "light";
try { mode = localStorage.getItem("mode") || (localStorage.getItem("sat") === "1" ? "sat" : "light"); } catch (e) { /* egal */ }
setMode(mode);
statusLoop();
setInterval(refreshTrains, 5000);
requestAnimationFrame(animate);
setInterval(() => openId && showTrip(openId), 30000);
});