Add route-maps
Deploy PBT / deploy (push) Successful in 16s

This commit is contained in:
2026-09-11 14:54:40 +02:00
parent a54f859855
commit c9f298223b
4 changed files with 221 additions and 1 deletions
+146
View File
@@ -0,0 +1,146 @@
/* Shared Leaflet helper: a small OSM route preview (origin/destination
markers + a straight line between them - we don't have real route
geometry, just stop coordinates, so this is a "as the crow flies" hint,
not a turn-by-turn path).
Exposes window.PbtMap.createRouteMap(containerId) and self-wires:
- the "Von"/"Nach" live preview on the trip form (#route-map)
- the map-pin buttons + dialog on the "Meine Fahrten" list
No-ops safely wherever these elements/Leaflet aren't present. */
(function () {
"use strict";
if (typeof window.L === "undefined") return;
var ORIGIN_COLOR = "#3fb950";
var DEST_COLOR = "#f85149";
var AUSTRIA_CENTER = [47.6, 14.0];
function dot(color) {
return L.divIcon({
className: "",
html: '<span style="display:block;width:14px;height:14px;border-radius:50%;' +
"background:" + color + ';border:2px solid #fff;box-shadow:0 1px 4px rgba(0,0,0,.5)"></span>',
iconSize: [14, 14],
iconAnchor: [7, 7],
});
}
function createRouteMap(containerId) {
var map = L.map(containerId).setView(AUSTRIA_CENTER, 7);
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
maxZoom: 19,
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>-Mitwirkende',
}).addTo(map);
var layers = [];
function clear() {
layers.forEach(function (l) { map.removeLayer(l); });
layers = [];
}
function render(origin, destination) {
clear();
var points = [];
if (origin) {
var o = L.marker([origin.lat, origin.lon], { icon: dot(ORIGIN_COLOR) }).addTo(map);
if (origin.label) o.bindTooltip(origin.label);
layers.push(o);
points.push([origin.lat, origin.lon]);
}
if (destination) {
var d = L.marker([destination.lat, destination.lon], { icon: dot(DEST_COLOR) }).addTo(map);
if (destination.label) d.bindTooltip(destination.label);
layers.push(d);
points.push([destination.lat, destination.lon]);
}
if (points.length === 2) {
layers.push(
L.polyline(points, { color: "#4a9eff", weight: 3, dashArray: "6 8", opacity: 0.85 }).addTo(map)
);
map.fitBounds(L.latLngBounds(points), { padding: [28, 28], maxZoom: 15 });
} else if (points.length === 1) {
map.setView(points[0], 14);
} else {
map.setView(AUSTRIA_CENTER, 7);
}
return points.length > 0;
}
return { map: map, render: render };
}
window.PbtMap = { createRouteMap: createRouteMap };
// --- live preview on the trip form -------------------------------------
function setupFormPreview(form) {
var container = form.querySelector("#route-map");
var placeholder = form.querySelector("[data-route-map-placeholder]");
var originText = form.querySelector("#origin");
var destText = form.querySelector("#destination");
var originHidden = form.querySelector("#origin_stop_id");
var destHidden = form.querySelector("#destination_stop_id");
if (!container || !originText || !destText || !originHidden || !destHidden) return;
var routeMap = null;
function point(hidden, text) {
var lat = hidden.dataset.stopLat, lon = hidden.dataset.stopLon;
if (!lat || !lon) return null;
return { lat: parseFloat(lat), lon: parseFloat(lon), label: text.value.trim() };
}
function apply() {
var origin = point(originHidden, originText);
var destination = point(destHidden, destText);
if (!origin && !destination) {
container.hidden = true;
if (placeholder) placeholder.hidden = false;
return;
}
container.hidden = false;
if (placeholder) placeholder.hidden = true;
if (!routeMap) routeMap = createRouteMap("route-map");
routeMap.render(origin, destination);
setTimeout(function () { routeMap.map.invalidateSize(); }, 0);
}
form.addEventListener("stopchange", apply);
originText.addEventListener("input", apply);
destText.addEventListener("input", apply);
apply();
}
document.querySelectorAll("form").forEach(setupFormPreview);
// --- "Meine Fahrten" map-pin buttons + shared dialog --------------------
var dialog = document.getElementById("route-map-dialog");
if (dialog) {
var dialogTitle = document.getElementById("route-map-dialog-title");
var closeBtn = dialog.querySelector("[data-close-map-dialog]");
var dialogMap = null;
function coordsOf(btn, prefix) {
var lat = btn.dataset[prefix + "Lat"], lon = btn.dataset[prefix + "Lon"];
if (!lat || !lon) return null;
return { lat: parseFloat(lat), lon: parseFloat(lon), label: btn.dataset[prefix + "Label"] || "" };
}
document.querySelectorAll("[data-show-map]").forEach(function (btn) {
btn.addEventListener("click", function () {
var origin = coordsOf(btn, "origin");
var destination = coordsOf(btn, "destination");
dialogTitle.textContent = (btn.dataset.originLabel || "?") + " → " + (btn.dataset.destinationLabel || "?");
dialog.showModal();
if (!dialogMap) dialogMap = createRouteMap("dialog-route-map");
dialogMap.render(origin, destination);
setTimeout(function () { dialogMap.map.invalidateSize(); }, 0);
});
});
if (closeBtn) closeBtn.addEventListener("click", function () { dialog.close(); });
dialog.addEventListener("click", function (e) {
if (e.target === dialog) dialog.close(); // click on the backdrop
});
}
})();
+14
View File
@@ -1,5 +1,9 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}{{ "Fahrt bearbeiten" if fahrt else "Neue Fahrt" }} PBT{% endblock %} {% block title %}{{ "Fahrt bearbeiten" if fahrt else "Neue Fahrt" }} PBT{% endblock %}
{% block head %}
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="">
{% endblock %}
{% block content %} {% block content %}
<div class="card"> <div class="card">
<h2>{{ "Fahrt bearbeiten" if fahrt else "Neue Fahrt erfassen" }}</h2> <h2>{{ "Fahrt bearbeiten" if fahrt else "Neue Fahrt erfassen" }}</h2>
@@ -50,6 +54,13 @@
if fahrt and fahrt.destination_stop else "" }}</p> if fahrt and fahrt.destination_stop else "" }}</p>
</div> </div>
<div class="field">
<label>Kartenvorschau</label>
<p class="field-hint" data-route-map-placeholder>Wähle bekannte Haltestellen bei Von/Nach für eine Kartenvorschau.</p>
<div id="route-map" class="route-map" hidden></div>
<p class="field-hint">Luftlinie zwischen den Halten keine echte Streckenführung.</p>
</div>
<div class="field combo"> <div class="field combo">
<label for="line">Linie</label> <label for="line">Linie</label>
<input type="text" id="line" name="line" placeholder="z.B. S3, 693" <input type="text" id="line" name="line" placeholder="z.B. S3, 693"
@@ -104,5 +115,8 @@
</div> </div>
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin="" defer></script>
<script src="{{ url_for('static', filename='map.js') }}" defer></script>
<script src="{{ url_for('static', filename='stops.js') }}" defer></script> <script src="{{ url_for('static', filename='stops.js') }}" defer></script>
{% endblock %} {% endblock %}
+32 -1
View File
@@ -161,9 +161,39 @@
cursor: pointer; cursor: pointer;
} }
.icon-btn svg { width: 16px; height: 16px; } .icon-btn svg { width: 16px; height: 16px; }
.icon-btn.edit:hover { color: var(--accent); border-color: var(--accent); background: rgba(74, 158, 255, 0.1); } .icon-btn.edit:hover, .icon-btn.map:hover { color: var(--accent); border-color: var(--accent); background: rgba(74, 158, 255, 0.1); }
.icon-btn.delete { color: var(--danger); } .icon-btn.delete { color: var(--danger); }
.icon-btn.delete:hover { background: rgba(248, 81, 73, 0.12); border-color: var(--danger); } .icon-btn.delete:hover { background: rgba(248, 81, 73, 0.12); border-color: var(--danger); }
.icon-btn:disabled { opacity: 0.35; cursor: not-allowed; }
.icon-btn:disabled:hover { color: var(--text-muted); border-color: var(--panel-border); background: transparent; }
.route-map {
height: 220px;
border-radius: 8px;
border: 1px solid var(--panel-border);
background: var(--field-bg);
}
/* Leaflet's own UI (zoom buttons, attribution) is deliberately left in its
standard light styling - it sits on map tiles, not our page background. */
dialog.map-dialog {
border: 1px solid var(--panel-border);
border-radius: 10px;
background: var(--panel);
color: var(--text);
padding: 1rem;
width: min(560px, 92vw);
}
dialog.map-dialog::backdrop { background: rgba(0, 0, 0, 0.6); }
.map-dialog-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
margin-bottom: 0.75rem;
font-weight: 600;
font-size: 0.95rem;
}
.combo { position: relative; display: flex; flex-direction: column; } .combo { position: relative; display: flex; flex-direction: column; }
.combo-list { .combo-list {
@@ -194,6 +224,7 @@
.field-hint { font-size: 0.75rem; color: var(--text-muted); margin-top: 0.3rem; min-height: 1em; } .field-hint { font-size: 0.75rem; color: var(--text-muted); margin-top: 0.3rem; min-height: 1em; }
.field-hint.matched { color: var(--success); } .field-hint.matched { color: var(--success); }
</style> </style>
{% block head %}{% endblock %}
</head> </head>
<body> <body>
<header> <header>
+29
View File
@@ -1,5 +1,9 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Meine Fahrten PBT{% endblock %} {% block title %}Meine Fahrten PBT{% endblock %}
{% block head %}
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="">
{% endblock %}
{% block content %} {% block content %}
<h2 style="font-size: 1.1rem; font-weight: 600;">Meine Fahrten</h2> <h2 style="font-size: 1.1rem; font-weight: 600;">Meine Fahrten</h2>
@@ -36,6 +40,18 @@
<td class="stars">{{ "★" * f.rating }}{{ "☆" * (5 - f.rating) }}</td> <td class="stars">{{ "★" * f.rating }}{{ "☆" * (5 - f.rating) }}</td>
<td> <td>
<div class="row-actions"> <div class="row-actions">
<button type="button" class="icon-btn map" title="Route auf Karte anzeigen"
aria-label="Route auf Karte anzeigen" data-show-map
data-origin-label="{{ f.origin }}" data-destination-label="{{ f.destination }}"
{% if f.origin_stop %}data-origin-lat="{{ f.origin_stop.latitude }}" data-origin-lon="{{ f.origin_stop.longitude }}"{% endif %}
{% if f.destination_stop %}data-destination-lat="{{ f.destination_stop.latitude }}" data-destination-lon="{{ f.destination_stop.longitude }}"{% endif %}
{% if not f.origin_stop and not f.destination_stop %}disabled{% endif %}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"></path>
<circle cx="12" cy="10" r="3"></circle>
</svg>
</button>
<a class="icon-btn edit" href="{{ url_for('fahrt_bearbeiten', fahrt_id=f.id) }}" <a class="icon-btn edit" href="{{ url_for('fahrt_bearbeiten', fahrt_id=f.id) }}"
title="Fahrt bearbeiten" aria-label="Fahrt bearbeiten"> title="Fahrt bearbeiten" aria-label="Fahrt bearbeiten">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
@@ -66,4 +82,17 @@
</tbody> </tbody>
</table> </table>
{% endif %} {% endif %}
<dialog id="route-map-dialog" class="map-dialog">
<div class="map-dialog-header">
<span id="route-map-dialog-title"></span>
<button type="button" class="icon-btn" data-close-map-dialog aria-label="Schließen"></button>
</div>
<div id="dialog-route-map" class="route-map"></div>
</dialog>
{% endblock %}
{% block scripts %}
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin="" defer></script>
<script src="{{ url_for('static', filename='map.js') }}" defer></script>
{% endblock %} {% endblock %}