Züge als Tropfen in Fahrtrichtung, nur Linie als Label, Hover mit Zugnummer, Favicon
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X48pE4bzjHej8w9HyZXCjb
This commit is contained in:
+64
-9
@@ -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)) || '<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;
|
||||
|
||||
Reference in New Issue
Block a user