diff --git a/web/app.js b/web/app.js
index 67277b1..c2d84f7 100644
--- a/web/app.js
+++ b/web/app.js
@@ -52,16 +52,22 @@ const map = new maplibregl.Map({
...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",
+ { 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: {
- "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],
+ "icon-color": delayColor, "icon-halo-color": "#fff",
+ "icon-halo-width": 4,
} },
{ id: "train-labels", type: "symbol", source: "trains", minzoom: 10,
layout: {
- "text-field": ["concat", ["get", "line"], " ", ["get", "nr"]],
+ "text-field": ["get", "line"],
"text-font": ["Noto Sans Medium"], "text-size": 11,
"text-offset": [0, 1.1], "text-anchor": "top", "text-optional": true,
},
@@ -100,13 +106,54 @@ async function refreshTrains() {
const now = Number(r.headers.get("X-Now"));
const d = await r.json();
if (now) clockOffset = Date.now() / 1000 - now;
- for (const f of d.features) f.start = f.geometry.coordinates;
+ 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));
@@ -194,8 +241,16 @@ function closeTrip() {
document.getElementById("close").onclick = closeTrip;
map.on("click", "trains", (e) => showTrip(e.features[0].properties.id));
-map.on("mouseenter", "trains", () => { map.getCanvas().style.cursor = "pointer"; });
-map.on("mouseleave", "trains", () => { map.getCanvas().style.cursor = ""; });
+
+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)) || 'pünktlich';
+ hover.setLngLat(e.features[0].geometry.coordinates)
+ .setHTML(`${esc(p.line)} ${esc(p.nr)} ${delay}
nach ${esc(p.to)}`).addTo(map);
+});
+map.on("mouseleave", "trains", () => { map.getCanvas().style.cursor = ""; hover.remove(); });
map.on("load", () => {
let sat = false;
diff --git a/web/favicon.svg b/web/favicon.svg
new file mode 100644
index 0000000..e65998f
--- /dev/null
+++ b/web/favicon.svg
@@ -0,0 +1 @@
+
diff --git a/web/index.html b/web/index.html
index 4f13e1f..d5f6fa6 100644
--- a/web/index.html
+++ b/web/index.html
@@ -5,6 +5,7 @@