added date selection and filter popup

This commit is contained in:
Hendrik Behme committed 2026-10-09 16:26:56 +02:00
1 parent 739a1eee28
commit 36ed3880e8
16 files changed
+741 -180

No files matched your search

+136
View File
@@ -0,0 +1,136 @@
// Shared date-range handling for Browse Events and both maps — remembered
// across pages the way location.js remembers the place, validated in one
// spot, and bound to German-format (dd.mm.yyyy) date pickers. Pages still
// own what a range *does* (a server-side filter on Browse, a client-side
// one on the maps); this only owns the range itself.
(function (window) {
var KEY = 'vindn:dateRange';
var DAY_RE = /^\d{4}-\d{2}-\d{2}$/;
// "Y-m-d" for today in Germany — the site's audience and data are
// German-local, so "today" must not flip at UTC midnight. sv-SE happens
// to format dates as ISO, which is the only reason it's used here.
function today() {
return new Date().toLocaleDateString('sv-SE', { timeZone: 'Europe/Berlin' });
}
// Valid "Y-m-d" strings or null for each end, and reversed ends swapped.
function normalize(from, to) {
from = DAY_RE.test(from || '') ? from : null;
to = DAY_RE.test(to || '') ? to : null;
if (from && to && from > to) {
var swap = from;
from = to;
to = swap;
}
return { from: from, to: to };
}
// The remembered range, or null. A range that already ended is dropped
// (nothing it could match is shown anymore), and a start in the past is
// moved up to today so the pickers never show a date they can't select.
function load() {
try {
var raw = window.localStorage && localStorage.getItem(KEY);
if (!raw) return null;
var parsed = JSON.parse(raw);
var range = normalize(parsed.from, parsed.to);
if (!range.from && !range.to) return null;
var now = today();
if (range.to && range.to < now) {
clear();
return null;
}
if (range.from && range.from < now) range.from = now;
return range;
} catch (e) { /* localStorage disabled/unavailable, or corrupt value — just skip it */ }
return null;
}
function save(from, to) {
var range = normalize(from, to);
if (!range.from && !range.to) {
clear();
return;
}
try {
if (window.localStorage) localStorage.setItem(KEY, JSON.stringify(range));
} catch (e) { /* localStorage disabled/unavailable — nothing to persist to */ }
}
function clear() {
try {
if (window.localStorage) localStorage.removeItem(KEY);
} catch (e) { /* localStorage disabled/unavailable */ }
}
// Turns a pair of <input type="date"> into German-format pickers when
// flatpickr is loaded. A native date input displays in the *browser's*
// locale and can't be forced to dd.mm.yyyy, so the visible field is
// flatpickr's alt input (d.m.Y, German calendar) while the original input
// keeps holding the machine-readable "Y-m-d" value everything else reads.
// Without flatpickr (e.g. its CDN is unreachable) the native inputs keep
// working as they are.
//
// onChange(from, to) fires when the *user* edits either field — never for
// updates made through the returned setter, so applying a remembered
// range doesn't trigger the page's own change handling.
function bindInputs(fromEl, toEl, onChange) {
var pickers = [];
if (window.flatpickr) {
var options = {
locale: 'de',
dateFormat: 'Y-m-d',
altInput: true,
altFormat: 'd.m.Y',
allowInput: true,
disableMobile: true, // the native mobile picker would bring the device locale back
static: true, // rendered inside the card, so it also shows in a fullscreen map
minDate: 'today',
// The popup renders inside its card/dialog; in a scrolling container
// (the Browse filter popup) it can open partly out of view.
onOpen: function (selected, str, instance) {
setTimeout(function () {
instance.calendarContainer.scrollIntoView({ block: 'nearest' });
}, 0);
}
};
pickers = [fromEl, toEl].map(function (el) {
var picker = window.flatpickr(el, options);
// The original input turns into a hidden one, so the visible alt
// input has to take over its id and label — otherwise the <label for>
// points at something that can't be focused or clicked.
var alt = picker.altInput;
var label = el.id && document.querySelector('label[for="' + el.id + '"]');
alt.id = el.id + 'Display';
if (label) label.setAttribute('for', alt.id);
alt.setAttribute('aria-label', el.getAttribute('aria-label') || '');
alt.setAttribute('placeholder', 'TT.MM.JJJJ');
return picker;
});
}
function handle() { onChange(fromEl.value, toEl.value); }
fromEl.addEventListener('change', handle);
toEl.addEventListener('change', handle);
return function set(from, to) {
if (pickers.length) {
pickers[0].setDate(from || null, false);
pickers[1].setDate(to || null, false);
} else {
fromEl.value = from || '';
toEl.value = to || '';
}
};
}
window.VindnDates = {
today: today,
normalize: normalize,
load: load,
save: save,
clear: clear,
bindInputs: bindInputs
};
})(window);
+97 -7
View File
@@ -30,6 +30,63 @@
statusEl.classList.toggle('is-error', !!isError);
}
// German number formatting for what the panel shows (decimal comma, dot
// as thousands separator) — mirrors GermanFormatExtension::distance() on
// the server so a distance reads the same on a card and in the panel.
var DE_INT = new Intl.NumberFormat('de-DE', { maximumFractionDigits: 0 });
var DE_ONE_DECIMAL = new Intl.NumberFormat('de-DE', { maximumFractionDigits: 1 });
function formatKm(km) {
return km < 10 ? DE_ONE_DECIMAL.format(km) : DE_INT.format(km);
}
// Day-range filtering for the maps — the same "event overlaps [from, to]"
// rule as EventRepository::filterByDateRange on the server, applied to the
// Y-m-d prefixes the serializer ships, so it's a plain string comparison.
function eventInDateRange(ev, range) {
if (!range.from && !range.to) return true;
if (!ev.start) return false; // undated events can't match a date window
var end = ev.end || ev.start;
return (!range.to || ev.start <= range.to) && (!range.from || end >= range.from);
}
// Binds the shared #dateFrom/#dateTo/#dateClear controls (German-format
// pickers, remembered range — see dates.js) and returns the live range
// object ({from, to}, either may be null) the caller filters by; onChange
// runs after every user edit. `initial` seeds it (a range from the URL or
// from storage) without firing onChange.
function initDateRange(initial, onChange) {
var fromEl = document.getElementById('dateFrom');
var toEl = document.getElementById('dateTo');
var clearEl = document.getElementById('dateClear');
var range = { from: null, to: null };
var setInputs = function () {};
function apply(from, to) {
var normalized = window.VindnDates.normalize(from, to);
range.from = normalized.from;
range.to = normalized.to;
setInputs(range.from, range.to);
if (clearEl) clearEl.hidden = !(range.from || range.to);
}
function edited(from, to) {
apply(from, to);
window.VindnDates.save(range.from, range.to);
onChange();
}
if (fromEl && toEl) setInputs = window.VindnDates.bindInputs(fromEl, toEl, edited);
if (clearEl) clearEl.addEventListener('click', function () { edited(null, null); });
apply(initial && initial.from, initial && initial.to);
// A range that arrived via the URL should be remembered too, so the
// other pages pick it up like they would one picked here.
if (range.from || range.to) window.VindnDates.save(range.from, range.to);
return range;
}
// Leaflet.awesome-markers only ships a fixed palette of named colors (no
// arbitrary hex/hsl) — curated to the ones that read well against the
// grayscale-filtered basemap. Approximate hex values below are only used
@@ -350,6 +407,23 @@
if (limit && map) fitToMarkers(map, markers);
var dates = initDateRange(window.VindnDates.load(), onDatesChange);
// Candidate pool for the date window; in limit mode ("nearest N") the
// N nearest are picked *from this*, so a narrow window still shows up
// to N events instead of N nearest-overall minus the ones outside it.
function poolEvents() {
return allEvents.filter(function (ev) { return eventInDateRange(ev, dates); });
}
function onDatesChange() {
if (limit) {
rebuildMarkers(nearest(state.userLoc || defaultCenter, poolEvents(), limit));
if (map) fitToMarkers(map, markers);
}
renderList();
}
var legendEl = document.getElementById('legend');
if (legendEl) {
var seenCats = [];
@@ -398,7 +472,7 @@
return list;
}
var visible = allEvents.filter(function (ev) { return state.cat === 'all' || ev.category === state.cat; });
var visible = allEvents.filter(function (ev) { return (state.cat === 'all' || ev.category === state.cat) && eventInDateRange(ev, dates); });
if (state.userLoc) {
visible = visible.map(function (ev) { ev._dist = distanceKm(state.userLoc[0], state.userLoc[1], ev.lat, ev.lon); return ev; })
.sort(function (a, b) { return a._dist - b._dist; });
@@ -419,7 +493,7 @@
var list = currentList();
var countEl = document.getElementById('resultCount');
if (countEl) countEl.textContent = list.length + (list.length === 1 ? ' event' : ' events');
if (countEl) countEl.textContent = DE_INT.format(list.length) + (list.length === 1 ? ' event' : ' events');
var panelEl = document.getElementById('panelList');
if (!panelEl) return;
@@ -427,7 +501,7 @@
panelEl.innerHTML = list.map(function (ev) {
var distHtml = '';
if (ev._dist != null) {
var distLabel = ev._dist < 1 ? Math.round(ev._dist * 1000) : ev._dist.toFixed(1);
var distLabel = ev._dist < 1 ? Math.round(ev._dist * 1000) : formatKm(ev._dist);
var distUnit = ev._dist < 1 ? 'm' : 'km';
distHtml = '<div class="dist">' + distLabel + '<span class="u">' + distUnit + '</span></div>';
}
@@ -480,7 +554,7 @@
window.VindnLocation.save(lat, lon, placeName);
setLocationInputValue(placeName);
if (limit) rebuildMarkers(nearest([lat, lon], allEvents.slice(), limit));
if (limit) rebuildMarkers(nearest([lat, lon], poolEvents(), limit));
var statusEl = document.getElementById('locateStatus');
setLocateStatus(statusEl, statusText, false);
@@ -548,7 +622,10 @@
}).catch(function () {});
}
renderList();
// The first pins/list were built before the date range was known — a
// remembered range has to reshape them (onDatesChange also renders).
if (dates.from || dates.to) onDatesChange();
else renderList();
return { map: map, markers: markers };
}
@@ -576,6 +653,14 @@
var containerId = opts.containerId || 'mapCanvas';
var state = { source: originalCenter.slice(), rangeKm: opts.rangeKm || 25, cat: 'all' };
// A from/to in the URL (shared link, or this page's own reload) wins
// over the remembered range, the same way an explicit lat/lon does.
var urlParams = new URLSearchParams(window.location.search);
var urlHasDates = urlParams.has('from') || urlParams.has('to');
var dates = initDateRange(
urlHasDates ? { from: urlParams.get('from'), to: urlParams.get('to') } : window.VindnDates.load(),
function () { render(); }
);
// getCurrentPosition() can take a few seconds; if the user searches or
// clicks the map before it resolves, its result must not overwrite
// whatever they deliberately chose in the meantime.
@@ -625,6 +710,7 @@
function inRange() {
return allEvents
.filter(function (ev) { return state.cat === 'all' || ev.category === state.cat; })
.filter(function (ev) { return eventInDateRange(ev, dates); })
.map(function (ev) { ev._dist = distanceKm(state.source[0], state.source[1], ev.lat, ev.lon); return ev; })
.filter(function (ev) { return ev._dist <= state.rangeKm; })
.sort(function (a, b) { return a._dist - b._dist; });
@@ -636,6 +722,10 @@
url.searchParams.set('lat', state.source[0].toFixed(5));
url.searchParams.set('lon', state.source[1].toFixed(5));
url.searchParams.set('range', state.rangeKm);
['from', 'to'].forEach(function (key) {
if (dates[key]) url.searchParams.set(key, dates[key]);
else url.searchParams.delete(key);
});
window.history.replaceState(null, '', url.toString());
}
@@ -658,12 +748,12 @@
setLocateStatus(statusEl, 'Showing events within ' + state.rangeKm + ' km', false);
var countEl = document.getElementById('resultCount');
if (countEl) countEl.textContent = visible.length + (visible.length === 1 ? ' event' : ' events');
if (countEl) countEl.textContent = DE_INT.format(visible.length) + (visible.length === 1 ? ' event' : ' events');
var panelEl = document.getElementById('panelList');
if (panelEl) {
panelEl.innerHTML = visible.map(function (ev) {
var distLabel = ev._dist < 1 ? Math.round(ev._dist * 1000) : ev._dist.toFixed(1);
var distLabel = ev._dist < 1 ? Math.round(ev._dist * 1000) : formatKm(ev._dist);
var distUnit = ev._dist < 1 ? 'm' : 'km';
return (
'<div class="map-row" data-id="' + ev.id + '" tabindex="0" role="button" aria-label="' + ev.title + '">' +