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 @@ TrainPulse + diff --git a/web/style.css b/web/style.css index 52ff389..4775800 100644 --- a/web/style.css +++ b/web/style.css @@ -43,3 +43,6 @@ h1::before { content: ""; display: inline-block; width: 10px; height: 10px; bord #panel { top: auto; bottom: 0; right: 0; left: 0; width: auto; max-height: 55%; border-radius: 12px 12px 0 0; } .legend { display: none; } } + +.hover .maplibregl-popup-content { background: var(--bg); color: var(--fg); padding: 6px 10px; border-radius: 8px; font-size: 13px; } +.hover .maplibregl-popup-tip { display: none; }