diff --git a/public/css/app.css b/public/css/app.css
index e2bcf41..479866c 100644
--- a/public/css/app.css
+++ b/public/css/app.css
@@ -106,6 +106,70 @@ button, audio {
background: var(--notice);
}
+.route-section + .route-section {
+ margin-top: 2rem;
+ padding-top: 1.25rem;
+ border-top: 1px solid #c8d3ca;
+}
+
+.section-heading-row {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) minmax(14rem, 22rem);
+ gap: 1rem;
+ align-items: end;
+ margin-bottom: .75rem;
+}
+
+.section-heading-row h2 {
+ margin: 0;
+}
+
+.route-search-controls label {
+ display: block;
+ margin-bottom: .35rem;
+ color: var(--muted);
+ font-size: .9rem;
+ font-weight: 600;
+}
+
+.route-search-row {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: .35rem;
+}
+
+#route-search {
+ width: 100%;
+ min-height: 2.5rem;
+ box-sizing: border-box;
+ padding: .5rem .65rem;
+ border: 1px solid #aebcaf;
+ border-radius: .3rem;
+ background: var(--surface);
+ color: var(--text);
+}
+
+#route-search:focus {
+ outline: .2rem solid var(--focus);
+ outline-offset: .1rem;
+}
+
+#clear-route-search.ui-button {
+ margin: 0;
+}
+
+.route-list-summary {
+ margin: 0 0 .75rem;
+ color: var(--muted);
+ font-size: .9rem;
+}
+
+.ui-autocomplete {
+ max-height: 16rem;
+ overflow-y: auto;
+ overflow-x: hidden;
+}
+
.card-list {
display: grid;
gap: .75rem;
@@ -259,6 +323,13 @@ button, audio {
outline-offset: .12rem;
}
+@media (max-width: 620px) {
+ .section-heading-row {
+ grid-template-columns: 1fr;
+ align-items: stretch;
+ }
+}
+
@media (max-width: 420px) {
.route-facts {
grid-template-columns: 1fr;
diff --git a/public/index.html b/public/index.html
index cc59ffd..0c14e34 100644
--- a/public/index.html
+++ b/public/index.html
@@ -34,8 +34,27 @@
Standort wird ermittelt.
-
Routen in deiner Nähe
-
+
+
+ Routen in deiner Nähe
+
+
+
+
+
+
Alle Routen
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/js/app.js b/public/js/app.js
index 4cd0694..5067d17 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -30,14 +30,30 @@
else if (previous !== pageSelector) history.pushState(historyState, '', pageSelector);
}
- function renderRoutes() {
- const list = $('#route-list').empty();
- if (!state.routes.length) {
- list.append('Keine Route in der Nähe gefunden.');
+ function normalizeSearch(value) {
+ return String(value ?? '')
+ .normalize('NFKD')
+ .replace(/[\u0300-\u036f]/g, '')
+ .toLocaleLowerCase('de');
+ }
+
+ function routeMatchesSearch(route, searchTerm) {
+ if (!searchTerm) return true;
+ return normalizeSearch([
+ route.name,
+ route.schoolName,
+ route.description
+ ].filter(Boolean).join(' ')).includes(searchTerm);
+ }
+
+ function renderRouteItems(listSelector, routes, emptyText) {
+ const list = $(listSelector).empty();
+ if (!routes.length) {
+ list.append(`${escapeHtml(emptyText)}`);
return;
}
- state.routes.forEach(route => {
+ routes.forEach(route => {
const proximity = route.proximityM == null
? ''
: `${escapeHtml(ns.Distance.format(route.proximityM))} entfernt`;
@@ -53,6 +69,54 @@
});
}
+ function renderRoutes() {
+ const radiusM = ns.Config.routeRadiusKm * 1000;
+ const nearbyRoutes = state.position
+ ? state.routes
+ .filter(route => route.proximityM != null && route.proximityM <= radiusM)
+ .sort((a, b) => a.proximityM - b.proximityM)
+ : [];
+
+ const searchTerm = normalizeSearch($('#route-search').val());
+ const allRoutes = state.routes
+ .filter(route => routeMatchesSearch(route, searchTerm))
+ .slice()
+ .sort((a, b) => a.name.localeCompare(b.name, 'de'));
+
+ const nearbyEmptyText = state.position
+ ? `Keine Route im Umkreis von ${ns.Config.routeRadiusKm} km gefunden. Wähle unten eine beliebige Route aus.`
+ : 'Für Empfehlungen in der Nähe ist ein Standort erforderlich. Alle Routen bleiben unten auswählbar.';
+
+ renderRouteItems('#nearby-route-list', nearbyRoutes, nearbyEmptyText);
+ renderRouteItems('#all-route-list', allRoutes,
+ searchTerm ? 'Keine Route entspricht dem Suchbegriff.' : 'Es sind noch keine Routen vorhanden.');
+
+ $('#all-routes-summary').text(
+ searchTerm
+ ? `${allRoutes.length} von ${state.routes.length} Routen gefunden.`
+ : `${state.routes.length} Routen verfügbar.`
+ );
+ }
+
+ function initializeRouteSearch() {
+ const input = $('#route-search');
+ input.autocomplete({
+ minLength: 0,
+ source(request, response) {
+ const term = normalizeSearch(request.term);
+ response(state.routes
+ .filter(route => routeMatchesSearch(route, term))
+ .slice(0, 12)
+ .map(route => ({ label: route.name, value: route.name })));
+ },
+ select(event, ui) {
+ input.val(ui.item.value);
+ renderRoutes();
+ return false;
+ }
+ });
+ }
+
function renderRoute(route) {
state.route = route;
state.triggered.clear();
@@ -121,6 +185,12 @@
}
function bindEvents() {
+ $(document).on('input', '#route-search', renderRoutes);
+
+ $(document).on('click', '#clear-route-search', function () {
+ $('#route-search').val('').trigger('input').trigger('focus');
+ });
+
$(document).on('click', '[data-route-id]', async function () {
const button = $(this);
button.prop('disabled', true);
@@ -178,19 +248,22 @@
try {
const position = await ns.Geo.current();
state.position = position;
- $('#location-hint').text(`Standort ermittelt (Genauigkeit ${Math.round(position.accuracy)} m).`);
- state.routes = state.routes
- .map(route => ({
- ...route,
- proximityM: route.start ? ns.Distance.meters(position, route.start) : null
- }))
- .filter(route => route.proximityM == null || route.proximityM <= ns.Config.routeRadiusKm * 1000)
- .sort((a, b) => (a.proximityM ?? Number.MAX_VALUE) - (b.proximityM ?? Number.MAX_VALUE));
+ $('#location-hint').text(
+ `Standort ermittelt (Genauigkeit ${Math.round(position.accuracy)} m). ` +
+ `Nahe Routen werden bis ${ns.Config.routeRadiusKm} km empfohlen.`
+ );
+ state.routes = state.routes.map(route => ({
+ ...route,
+ proximityM: route.start ? ns.Distance.meters(position, route.start) : null
+ }));
} catch (error) {
- $('#location-hint').text(`Standort nicht verfügbar: ${error.message}. Es werden alle Routen angezeigt.`);
+ $('#location-hint').text(
+ `Standort nicht verfügbar: ${error.message}. Alle Routen können weiterhin ausgewählt werden.`
+ );
}
context.markStep('location', 'done');
+ initializeRouteSearch();
renderRoutes();
updateTrackingButton();
}
diff --git a/public/js/bootstrap-loader.js b/public/js/bootstrap-loader.js
index 4c4c039..6d13d76 100644
--- a/public/js/bootstrap-loader.js
+++ b/public/js/bootstrap-loader.js
@@ -45,6 +45,7 @@
$('#bootstrap-retry').button({ icon: 'ui-icon-refresh' });
$('.back-button').button({ icon: 'ui-icon-caret-1-w' });
$('#start-tracking').button({ icon: 'ui-icon-pin-s' });
+ $('#clear-route-search').button({ icon: 'ui-icon-close', showLabel: false });
$('#slide-prev').button({ icon: 'ui-icon-caret-1-w', showLabel: false });
$('#slide-next').button({ icon: 'ui-icon-caret-1-e', showLabel: false });
$('#slide-controls').controlgroup();