(function (window) { // The leaflet-gesture-handling UMD build exposes its class as // window.leafletGestureHandling.GestureHandling instead of registering // itself on L (unlike the other Leaflet plugins loaded on this page), so // it has to be wired in manually before any L.map() call. if (window.L && window.leafletGestureHandling && !window.L.Map.prototype.options.gestureHandling) { window.L.Map.addInitHook('addHandler', 'gestureHandling', window.leafletGestureHandling.GestureHandling); } // Köln — last-resort fallback if a caller doesn't pass its own center; // both controllers (Home/Map) explicitly pass the same point themselves, // so this only matters if that ever gets forgotten. var DEFAULT_CENTER = [50.9375, 6.9603]; // Storage/search/GPS lookups live in window.VindnLocation (location.js, // loaded globally in base.html.twig) — shared with Browse Events' own // location filter too, so all three stay in sync by construction. // Mirrors whatever setUserLocation/setSource just resolved (a searched // place name, or nothing for GPS/a map click) into the location search // field, so it doesn't just sit there blank after the fact. function setLocationInputValue(name) { var input = document.getElementById('locationSearch'); if (input) input.value = name || ''; } function setLocateStatus(statusEl, text, isError) { if (!statusEl) return; statusEl.textContent = text; statusEl.classList.toggle('is-error', !!isError); } // Number formatting for what the panel shows (separators of the page // language) — mirrors LocaleFormatExtension::distance() on the server so a // distance reads the same on a card and in the panel. function formatKm(km) { return window.VindnI18n.number(km, km < 10 ? 1 : 0); } // 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 // for the legend swatches, so they match what's actually drawn on pins. var AWESOME_COLORS = ['red', 'darkred', 'orange', 'green', 'darkgreen', 'blue', 'darkblue', 'lightblue', 'purple', 'darkpurple', 'cadetblue', 'beige']; var AWESOME_COLOR_HEX = { red: '#d63e2a', darkred: '#a23336', orange: '#f69730', green: '#72b026', darkgreen: '#728224', blue: '#38aadd', darkblue: '#0067a3', lightblue: '#8adaff', purple: '#d252b9', darkpurple: '#5b396b', cadetblue: '#436978', beige: '#e0a458' }; // Real ODH category names are German tourism-taxonomy strings (80+ of // them) — this maps keywords found in a name to a Font Awesome 4 glyph, // first match wins, falling back to a generic calendar icon. var CATEGORY_ICON_RULES = [ { icon: 'tree', words: ['weihnacht'] }, { icon: 'music', words: ['musik', 'konzert', 'jazz', 'rock', 'pop', 'blues', 'soul', 'reggae', 'techno', 'house', 'elektro', 'punk', 'metal', 'folk', 'country', 'swing', 'gospel', 'klassisch', 'oper', 'musical', 'schlager', 'sakralmusik', 'volksmusik', 'dancehall', 'r\'n\'b'] }, { icon: 'comments', words: ['theater', 'bühne', 'schauspiel', 'kabarett', 'ballett', 'figurentheater'] }, { icon: 'film', words: ['film'] }, { icon: 'shopping-cart', words: ['markt', 'flohmarkt', 'messe'] }, { icon: 'flag', words: ['festival', 'fest', 'kirmes', 'jahrmarkt', 'stadtfest'] }, { icon: 'binoculars', words: ['führung', 'besichtigung', 'ausflug', 'exkursion'] }, { icon: 'bicycle', words: ['radtour', 'rad'] }, { icon: 'trophy', words: ['sport', 'turnier', 'wettbewerb', 'motorsport'] }, { icon: 'map-signs', words: ['wanderung'] }, { icon: 'cutlery', words: ['gastronomie', 'genuss', 'gourmet'] }, { icon: 'child', words: ['kinder', 'familie'] }, { icon: 'heartbeat', words: ['gesundheit', 'wellness'] }, { icon: 'university', words: ['kirche', 'sakral'] }, { icon: 'book', words: ['vortrag', 'lesung', 'kurs', 'seminar', 'workshop', 'hobby'] }, { icon: 'users', words: ['kongress', 'geselligkeit', 'spiele', 'treffen'] }, { icon: 'glass', words: ['party', 'nightlife'] }, { icon: 'picture-o', words: ['ausstellung'] } ]; var DEFAULT_ICON = 'calendar'; function hashHue(str) { var hash = 0; for (var i = 0; i < str.length; i++) { hash = (hash * 31 + str.charCodeAt(i)) >>> 0; } return hash; } function categoryColor(name) { if (!name) return 'hsl(220, 10%, 55%)'; return 'hsl(' + (hashHue(name) % 360) + ', 62%, 42%)'; } function categoryMarkerColorName(name) { if (!name) return 'cadetblue'; return AWESOME_COLORS[hashHue(name) % AWESOME_COLORS.length]; } function categoryMarkerHex(name) { return AWESOME_COLOR_HEX[categoryMarkerColorName(name)] || '#436978'; } function categoryIconName(name) { var lower = (name || '').toLowerCase(); for (var i = 0; i < CATEGORY_ICON_RULES.length; i++) { var rule = CATEGORY_ICON_RULES[i]; for (var j = 0; j < rule.words.length; j++) { if (lower.indexOf(rule.words[j]) !== -1) return rule.icon; } } return DEFAULT_ICON; } function ensurePulseStyle() { if (document.getElementById('vindn-pulse-style')) return; var styleTag = document.createElement('style'); styleTag.id = 'vindn-pulse-style'; styleTag.textContent = '@keyframes vindnPulse{0%{transform:scale(.6);opacity:.45}100%{transform:scale(2.4);opacity:0}}'; document.head.appendChild(styleTag); } // Plain pulsing dot — used only for the "you are here" user-location // marker, kept visually distinct from the category pins below. function dotIcon(color, big) { var size = big ? 18 : 14; var ring = big ? '' : ''; return L.divIcon({ className: 'pin-icon', html: '' + ring + '', iconSize: [size, size], iconAnchor: [size / 2, size / 2] }); } // Category pin — Font Awesome glyph in a colored teardrop marker, falling // back to a plain dot if the awesome-markers plugin didn't load. function categoryIcon(name) { if (window.L.AwesomeMarkers) { return L.AwesomeMarkers.icon({ icon: categoryIconName(name), prefix: 'fa', markerColor: categoryMarkerColorName(name) }); } return dotIcon(categoryColor(name), false); } // OpenFreeMap's "liberty" style labels everything with // coalesce(name_en, name) — English first — which shows "Cologne" // instead of "Köln" even though the whole map (and every event on it) // is German. Rewrites that one recurring sub-expression, wherever it // appears, to prefer name_de then the OSM-local `name` (which for // German places already *is* the German name) before falling back to // English. Layers that don't contain it (road-shield ref numbers, etc.) // are left alone. function preferGermanLabels(maplibreMap) { var EN_FIRST = '["coalesce",["get","name_en"],["get","name"]]'; var DE_FIRST = '["coalesce",["get","name_de"],["get","name"],["get","name_en"]]'; var style = maplibreMap.getStyle(); if (!style || !style.layers) return; style.layers.forEach(function (layer) { var textField = layer.layout && layer.layout['text-field']; if (!textField) return; var serialized = JSON.stringify(textField); if (serialized.indexOf(EN_FIRST) === -1) return; maplibreMap.setLayoutProperty(layer.id, 'text-field', JSON.parse(serialized.split(EN_FIRST).join(DE_FIRST))); }); } function readEmbeddedJson(elementId) { var el = document.getElementById(elementId); if (!el) return []; try { return JSON.parse(el.textContent) || []; } catch (e) { return []; } } function toRad(d) { return d * Math.PI / 180; } function distanceKm(lat1, lon1, lat2, lon2) { var R = 6371; var dLat = toRad(lat2 - lat1); var dLon = toRad(lon2 - lon1); var a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) * Math.sin(dLon / 2); return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); } // Returns the `count` events closest to `center`, each tagged with `_dist` (km). function nearest(center, events, count) { return events .map(function (ev) { ev._dist = distanceKm(center[0], center[1], ev.lat, ev.lon); return ev; }) .sort(function (a, b) { return a._dist - b._dist; }) .slice(0, count); } // Builds an (unattached) marker for an event — caller adds it to either // the map directly or a MarkerClusterGroup. function buildMarker(ev, onMarkerClick) { var hex = categoryMarkerHex(ev.category); var marker = L.marker([ev.lat, ev.lon], { icon: categoryIcon(ev.category) }); var popupHtml = '
' + (ev.category ? '' + ev.category + '' : '') + '

' + ev.title + '

' + (ev.venue ? '

' + ev.venue + '

' : '') + (ev.url ? '' + window.VindnI18n.t('map.view_event') + '' : '') + '
'; marker.bindPopup(popupHtml); if (onMarkerClick) marker.on('click', function () { onMarkerClick(ev, marker); }); return marker; } function fitToMarkers(map, markers) { var list = Object.keys(markers).map(function (id) { return markers[id]; }); if (!list.length) return; if (list.length === 1) { map.setView(list[0].getLatLng(), 13); return; } map.fitBounds(L.featureGroup(list).getBounds(), { padding: [40, 40], maxZoom: 14, animate: false }); } // Renders a Leaflet map of category pins (one per event) into // containerId, clustered via Leaflet.markercluster when it's loaded. // events: [{id, title, venue, category, lat, lon, url}] function renderPinMap(containerId, events, opts) { opts = opts || {}; var el = document.getElementById(containerId); if (!el || !window.L) return null; ensurePulseStyle(); var center = opts.center || (events.length ? [events[0].lat, events[0].lon] : DEFAULT_CENTER); var map = L.map(containerId, { zoomControl: false, // added below, so its tooltips can be translated attributionControl: !!opts.attributionControl, scrollWheelZoom: !!opts.scrollWheelZoom, // A raster L.tileLayer used to supply maxZoom implicitly; the // MapLibre GL bridge doesn't. minZoom/maxBounds are maplibre-gl- // leaflet's own documented recommendation, to avoid Leaflet/MapLibre // disagreeing about zoom 0 and latitudes MapLibre doesn't support. minZoom: 1, maxZoom: 19, maxBounds: [[-90, -Infinity], [90, Infinity]], maxBoundsViscosity: 1, // Requires two-finger drag / Ctrl+scroll to pan/zoom, with an // on-map hint, so an embedded map (home hero, mid-page /map) doesn't // trap page scroll/pinch-zoom on touch devices. gestureHandling: opts.gestureHandling !== false, gestureHandlingOptions: { text: { touch: window.VindnI18n.t('map.gesture.touch'), scroll: window.VindnI18n.t('map.gesture.scroll'), scrollMac: window.VindnI18n.t('map.gesture.scroll_mac') } } }).setView(center, opts.zoom || 13); if (opts.zoomControl) { L.control.zoom({ zoomInTitle: window.VindnI18n.t('map.zoom_in'), zoomOutTitle: window.VindnI18n.t('map.zoom_out') }).addTo(map); } if (L.maplibreGL) { // OpenFreeMap serves vector tiles only, rendered by MapLibre GL and // bridged into this Leaflet map as an ordinary layer — everything // else (markers, clustering, fullscreen) is untouched by this. var glLayer = L.maplibreGL({ style: 'https://tiles.openfreemap.org/styles/liberty', attribution: '© OpenFreeMap © OpenMapTiles Data from OpenStreetMap' }).addTo(map); var glMap = glLayer.getMaplibreMap && glLayer.getMaplibreMap(); if (glMap) { // 'load' waits for full tile loading and can take a while; // 'styledata' fires as soon as the style/layers themselves are // parsed, which is all setLayoutProperty needs. glMap.once('styledata', function () { preferGermanLabels(glMap); }); } } else { // Fallback if the MapLibre bridge failed to load for some reason. L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 18, attribution: opts.attributionControl ? '© OpenStreetMap contributors' : '' }).addTo(map); } if (opts.fullscreenControl !== false && L.control && L.control.fullscreen) { // Fullscreen the whole .stage (search bar, locate button, panel), not // just the bare map div — those overlay controls are DOM siblings of // #mapCanvas, not descendants, so they'd vanish in fullscreen otherwise. L.control.fullscreen({ title: window.VindnI18n.t('map.fullscreen'), titleCancel: window.VindnI18n.t('map.fullscreen_exit'), fullscreenElement: el.closest('.stage') || el }).addTo(map); // Fullscreen means the map owns the whole viewport, so the two- // finger/Ctrl+scroll gating gesture handling exists for (avoiding // trapped page scroll) no longer applies — switch back to normal // single-finger/scroll interaction while fullscreen is active. if (map.gestureHandling) { map.on('enterFullscreen', function () { map.gestureHandling.disable(); }); map.on('exitFullscreen', function () { map.gestureHandling.enable(); }); } } var clusterGroup = (opts.cluster !== false && L.markerClusterGroup) // animate:false — a fitBounds() called right after the initial // addLayer() (see fitToMarkers) otherwise races the cluster expand/ // collapse animation and leaves stale icon elements in the DOM. ? L.markerClusterGroup({ maxClusterRadius: 50, spiderfyOnMaxZoom: true, showCoverageOnHover: false, animate: false }) : null; if (clusterGroup) map.addLayer(clusterGroup); var markers = {}; events.forEach(function (ev) { var marker = buildMarker(ev, opts.onMarkerClick); markers[ev.id] = marker; if (clusterGroup) clusterGroup.addLayer(marker); else marker.addTo(map); }); return { map: map, markers: markers, clusterGroup: clusterGroup }; } /** * Wires up the full interactive map "stage" used on both the home page * and /map: pin map, category chips (#catChips), legend (#legend), * nearby-events panel (#panelList/#resultCount), and a "use my location" * button (#locateBtn). All element ids are optional — missing * ones are just skipped, so the same call works whether or not a given * piece of UI is present on the page. * * opts.limit turns on the "light" mode used by the home page: instead of * plotting every event, it shows only the `limit` nearest to a default * center (opts.defaultCenter) until the user shares their real location, * at which point it re-picks the `limit` nearest to *that* from the full * event list and refits the map to them. */ function initMapStage(opts) { opts = opts || {}; var allEvents = opts.events || []; var limit = opts.limit || null; var defaultCenter = opts.defaultCenter || DEFAULT_CENTER; var containerId = opts.containerId || 'mapCanvas'; var state = { cat: 'all', userLoc: null }; // getCurrentPosition() can take a few seconds; if the user searches for // a place before it resolves, its result must not overwrite whatever // they deliberately chose in the meantime. var manualLocationSet = false; var shown = limit ? nearest(defaultCenter, allEvents.slice(), limit) : allEvents; var result = renderPinMap(containerId, shown, { zoomControl: true, attributionControl: true, zoom: opts.zoom || (limit ? 11 : (allEvents.length ? 9 : 7)), center: limit ? defaultCenter : undefined, onMarkerClick: function (ev) { highlightRow(ev.id); } }); var map = result ? result.map : null; var markers = result ? result.markers : {}; var clusterGroup = result ? result.clusterGroup : null; var userMarker = null; 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 = []; allEvents.forEach(function (ev) { if (ev.category && seenCats.indexOf(ev.category) === -1) seenCats.push(ev.category); }); legendEl.innerHTML = seenCats.slice(0, 8).map(function (cat) { return '' + cat + ''; }).join(''); } function addMarker(marker) { if (clusterGroup) clusterGroup.addLayer(marker); else if (map) marker.addTo(map); } function removeMarker(marker) { if (clusterGroup) clusterGroup.removeLayer(marker); else if (map) map.removeLayer(marker); } function rebuildMarkers(list) { Object.keys(markers).forEach(function (id) { removeMarker(markers[id]); }); markers = {}; list.forEach(function (ev) { var marker = buildMarker(ev, function (clicked) { highlightRow(clicked.id); }); markers[ev.id] = marker; addMarker(marker); }); shown = list; } function highlightRow(id) { document.querySelectorAll('.map-row').forEach(function (r) { r.classList.remove('is-highlighted'); }); var row = document.querySelector('.map-row[data-id="' + id + '"]'); if (row) { row.classList.add('is-highlighted'); row.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); } } function currentList() { if (limit) { var list = shown.slice(); if (state.userLoc) { list = list.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; }); } return list; } 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; }); } Object.keys(markers).forEach(function (id) { var isVisible = visible.some(function (ev) { return String(ev.id) === String(id); }); var m = markers[id]; var hasLayer = clusterGroup ? clusterGroup.hasLayer(m) : (map && map.hasLayer(m)); if (isVisible && !hasLayer) addMarker(m); if (!isVisible && hasLayer) removeMarker(m); }); return visible; } function renderList() { var list = currentList(); var countEl = document.getElementById('resultCount'); if (countEl) countEl.textContent = window.VindnI18n.count('map.count', list.length); var panelEl = document.getElementById('panelList'); if (!panelEl) return; panelEl.innerHTML = list.map(function (ev) { var distHtml = ''; if (ev._dist != null) { var distLabel = ev._dist < 1 ? Math.round(ev._dist * 1000) : formatKm(ev._dist); var distUnit = ev._dist < 1 ? 'm' : 'km'; distHtml = '
' + distLabel + '' + distUnit + '
'; } return ( '
' + distHtml + '
' + ev.title + '
' + (ev.venue || '') + '
' + '
→
' + '
' ); }).join(''); panelEl.querySelectorAll('.map-row').forEach(function (row) { row.addEventListener('click', function () { var id = row.getAttribute('data-id'); var ev = allEvents.filter(function (e) { return String(e.id) === String(id); })[0]; if (!ev || !map) return; map.flyTo([ev.lat, ev.lon], 15); var marker = markers[id]; if (marker) { if (clusterGroup && clusterGroup.zoomToShowLayer) { clusterGroup.zoomToShowLayer(marker, function () { marker.openPopup(); }); } else { marker.openPopup(); } } highlightRow(id); }); }); } var chipsEl = document.getElementById('catChips'); if (chipsEl) { chipsEl.addEventListener('click', function (e) { var btn = e.target.closest('.chip-sm'); if (!btn) return; chipsEl.querySelectorAll('.chip-sm').forEach(function (c) { c.classList.remove('is-active'); }); btn.classList.add('is-active'); state.cat = btn.getAttribute('data-cat'); renderList(); }); } function statusForLimit() { return limit ? window.VindnI18n.t('map.status_nearest', { limit: limit }) : window.VindnI18n.t('map.status_near_you'); } function setUserLocation(lat, lon, statusText, placeName) { state.userLoc = [lat, lon]; window.VindnLocation.save(lat, lon, placeName); setLocationInputValue(placeName); if (limit) rebuildMarkers(nearest([lat, lon], poolEvents(), limit)); var statusEl = document.getElementById('locateStatus'); setLocateStatus(statusEl, statusText, false); if (map) { if (userMarker) map.removeLayer(userMarker); userMarker = L.marker([lat, lon], { icon: dotIcon('#2440FF', true), zIndexOffset: 1000 }).addTo(map); if (limit) fitToMarkers(map, markers); else map.setView([lat, lon], 13); } renderList(); } function requestLocation(isAutoCheck) { var statusEl = document.getElementById('locateStatus'); window.VindnLocation.useMyLocation({ onUnsupported: function () { setLocateStatus(statusEl, window.VindnI18n.t('location.unsupported'), true); }, onStart: function () { setLocateStatus(statusEl, window.VindnI18n.t('location.finding'), false); }, onFound: function (lat, lon, name) { // A manual search/click may have already happened while this // (silent, permission-check-triggered) lookup was still resolving. if (isAutoCheck && manualLocationSet) return; setUserLocation(lat, lon, statusForLimit(), name); }, onError: function () { setLocateStatus(statusEl, window.VindnI18n.t('location.failed'), true); } }); } var locateBtn = document.getElementById('locateBtn'); if (locateBtn) locateBtn.addEventListener('click', function () { manualLocationSet = true; requestLocation(false); }); var locationForm = document.getElementById('locationSearchForm'); if (locationForm) { locationForm.addEventListener('submit', function (e) { e.preventDefault(); var input = document.getElementById('locationSearch'); var query = (input.value || '').trim(); if (!query) return; manualLocationSet = true; var statusEl = document.getElementById('locateStatus'); window.VindnLocation.searchPlace(query, { onStart: function () { setLocateStatus(statusEl, window.VindnI18n.t('location.looking_up', { query: query }), false); }, onNotFound: function () { setLocateStatus(statusEl, window.VindnI18n.t('location.not_found', { query: query }), true); }, onFound: function (lat, lon, name) { setUserLocation(lat, lon, statusForLimit(), name); }, onError: function () { setLocateStatus(statusEl, window.VindnI18n.t('location.search_failed'), true); } }); }); } // A remembered search/geolocation takes priority over asking the // browser again — restoring it counts as the user's own manual choice, // so a slower-to-resolve auto-geolocation check below can't clobber it. var lastLocation = window.VindnLocation.load(); if (lastLocation) { manualLocationSet = true; setUserLocation(lastLocation.lat, lastLocation.lon, statusForLimit(), lastLocation.name); } else if (navigator.permissions && navigator.permissions.query) { // If the user already granted location access (on this or an earlier // visit), center on it automatically — this only *reads* the current // permission state, it never triggers the browser's permission prompt // itself, so it's safe to check unconditionally on load. navigator.permissions.query({ name: 'geolocation' }).then(function (status) { if (status.state === 'granted') requestLocation(true); }).catch(function () {}); } // 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 }; } /** * The /map page's full radius-based explorer — replaces a flat "first * N events" cap with "events within rangeKm of a source point", where * the source starts at the server-resolved center (Köln by default, or * ?lat/&lon) and can move via a map click/marker drag, a place search * (Nominatim), or "use my location". Assumes the full (non-compact) * _map_stage.html.twig markup — #catChips, #legend, #rangeSelect, * #locationSearchForm all present. * * opts.events is the full pool the server already limited to * opts.maxRangeKm around opts.center; moving the source further than * that away can't be served from the pool, so it triggers a fresh page * load centered on the new point instead of silently showing partial * results. */ function initRadiusMapStage(opts) { opts = opts || {}; var allEvents = opts.events || []; var originalCenter = opts.center || DEFAULT_CENTER; var maxRangeKm = opts.maxRangeKm || 100; 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. var manualLocationSet = false; var result = renderPinMap(containerId, [], { zoomControl: true, attributionControl: true, center: state.source, zoom: opts.zoom || 11, onMarkerClick: function (ev) { highlightRow(ev.id); } }); var map = result ? result.map : null; var markers = result ? result.markers : {}; var clusterGroup = result ? result.clusterGroup : null; var sourceMarker = map ? L.marker(state.source, { icon: dotIcon('#14140F', true), draggable: true, zIndexOffset: 1000 }).addTo(map) : null; function addMarker(marker) { if (clusterGroup) clusterGroup.addLayer(marker); else if (map) marker.addTo(map); } function removeMarker(marker) { if (clusterGroup) clusterGroup.removeLayer(marker); else if (map) map.removeLayer(marker); } function rebuildMarkers(list) { Object.keys(markers).forEach(function (id) { removeMarker(markers[id]); }); markers = {}; list.forEach(function (ev) { var marker = buildMarker(ev, function (clicked) { highlightRow(clicked.id); }); markers[ev.id] = marker; addMarker(marker); }); } function highlightRow(id) { document.querySelectorAll('.map-row').forEach(function (r) { r.classList.remove('is-highlighted'); }); var row = document.querySelector('.map-row[data-id="' + id + '"]'); if (row) { row.classList.add('is-highlighted'); row.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); } } 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; }); } function updateUrl() { if (!window.history || !window.history.replaceState || !window.URL) return; var url = new URL(window.location.href); 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()); } function render() { var visible = inRange(); rebuildMarkers(visible); var legendEl = document.getElementById('legend'); if (legendEl) { var seenCats = []; visible.forEach(function (ev) { if (ev.category && seenCats.indexOf(ev.category) === -1) seenCats.push(ev.category); }); legendEl.innerHTML = seenCats.slice(0, 8).map(function (cat) { return '' + cat + ''; }).join(''); } var statusEl = document.getElementById('locateStatus'); setLocateStatus(statusEl, window.VindnI18n.t('map.status_within', { km: state.rangeKm }), false); var countEl = document.getElementById('resultCount'); if (countEl) countEl.textContent = window.VindnI18n.count('map.count', visible.length); var panelEl = document.getElementById('panelList'); if (panelEl) { panelEl.innerHTML = visible.map(function (ev) { var distLabel = ev._dist < 1 ? Math.round(ev._dist * 1000) : formatKm(ev._dist); var distUnit = ev._dist < 1 ? 'm' : 'km'; return ( '
' + '
' + distLabel + '' + distUnit + '
' + '
' + ev.title + '
' + (ev.venue || '') + '
' + '
→
' + '
' ); }).join(''); panelEl.querySelectorAll('.map-row').forEach(function (row) { row.addEventListener('click', function () { var id = row.getAttribute('data-id'); var ev = visible.filter(function (e) { return String(e.id) === String(id); })[0]; if (!ev || !map) return; map.flyTo([ev.lat, ev.lon], 15); var marker = markers[id]; if (marker) { if (clusterGroup && clusterGroup.zoomToShowLayer) { clusterGroup.zoomToShowLayer(marker, function () { marker.openPopup(); }); } else { marker.openPopup(); } } highlightRow(id); }); }); } if (map) fitToMarkers(map, markers); updateUrl(); } // Moving further than the pool's own coverage (maxRangeKm around the // server-resolved center) can't be answered from what's already // loaded — that combination might reach past maxRangeKm even if the // new point itself is inside it, so the check is deliberately // conservative: reload whenever full coverage isn't guaranteed, // rather than risk silently showing an incomplete result. function setSource(lat, lon, placeName) { window.VindnLocation.save(lat, lon, placeName); setLocationInputValue(placeName); var distFromOriginal = distanceKm(originalCenter[0], originalCenter[1], lat, lon); if (distFromOriginal + state.rangeKm > maxRangeKm) { var url = new URL(window.location.href); url.searchParams.set('lat', lat.toFixed(5)); url.searchParams.set('lon', lon.toFixed(5)); url.searchParams.set('range', state.rangeKm); window.location.href = url.toString(); return; } state.source = [lat, lon]; if (sourceMarker) sourceMarker.setLatLng(state.source); if (map) map.panTo(state.source); render(); } if (map) { // A clicked point has no place name — clears any stale searched name // that would otherwise sit next to coordinates it no longer describes. map.on('click', function (e) { manualLocationSet = true; setSource(e.latlng.lat, e.latlng.lng, null); }); } if (sourceMarker) { sourceMarker.on('dragend', function () { manualLocationSet = true; var ll = sourceMarker.getLatLng(); setSource(ll.lat, ll.lng, null); }); } var rangeSelect = document.getElementById('rangeSelect'); if (rangeSelect) { rangeSelect.addEventListener('change', function () { state.rangeKm = parseInt(rangeSelect.value, 10) || state.rangeKm; render(); }); } var chipsEl = document.getElementById('catChips'); if (chipsEl) { chipsEl.addEventListener('click', function (e) { var btn = e.target.closest('.chip-sm'); if (!btn) return; chipsEl.querySelectorAll('.chip-sm').forEach(function (c) { c.classList.remove('is-active'); }); btn.classList.add('is-active'); state.cat = btn.getAttribute('data-cat'); render(); }); } var locationForm = document.getElementById('locationSearchForm'); if (locationForm) { locationForm.addEventListener('submit', function (e) { e.preventDefault(); var input = document.getElementById('locationSearch'); var query = (input.value || '').trim(); if (!query) return; manualLocationSet = true; var statusEl = document.getElementById('locateStatus'); window.VindnLocation.searchPlace(query, { onStart: function () { setLocateStatus(statusEl, window.VindnI18n.t('location.looking_up', { query: query }), false); }, onNotFound: function () { setLocateStatus(statusEl, window.VindnI18n.t('location.not_found', { query: query }), true); }, onFound: function (lat, lon, name) { setSource(lat, lon, name); }, onError: function () { setLocateStatus(statusEl, window.VindnI18n.t('location.search_failed'), true); } }); }); } function requestLocation(isAutoCheck) { var statusEl = document.getElementById('locateStatus'); window.VindnLocation.useMyLocation({ onUnsupported: function () { setLocateStatus(statusEl, window.VindnI18n.t('location.unsupported'), true); }, onStart: function () { setLocateStatus(statusEl, window.VindnI18n.t('location.finding'), false); }, onFound: function (lat, lon, name) { // A manual search/click/drag may have already happened while this // (silent, permission-check-triggered) lookup was still resolving. if (isAutoCheck && manualLocationSet) return; setSource(lat, lon, name); }, onError: function () { setLocateStatus(statusEl, window.VindnI18n.t('location.failed'), true); } }); } var locateBtn = document.getElementById('locateBtn'); if (locateBtn) locateBtn.addEventListener('click', function () { manualLocationSet = true; requestLocation(false); }); // A page load landing with an explicit ?lat= (e.g. after setSource's // own reload-threshold redirect, or a shared link) can't carry the // place name in the URL — fill the field back in from localStorage // regardless of whether that load also needs the redirect below. var savedForName = window.VindnLocation.load(); if (savedForName && savedForName.name) setLocationInputValue(savedForName.name); // An explicit ?lat=/&lon= in the URL (e.g. a shared link, or this same // page reloading itself after setSource's own redirect) always wins — // and, crucially, also skips the auto-geolocation check below entirely. // That check used to run on *every* fresh page load regardless of the // URL, which meant searching for a place far enough to trigger the // redirect immediately got overwritten by GPS again the moment the // reloaded page re-ran this same check with a clean (unset) // manualLocationSet. Otherwise, a remembered search/geolocation takes // priority over asking the browser again — restoring it counts as the // user's own manual choice, so a slower-to-resolve auto-geolocation // check can't clobber it. var urlHasExplicitLocation = /[?&]lat=/.test(window.location.search); var lastLocation = urlHasExplicitLocation ? null : savedForName; if (lastLocation) { manualLocationSet = true; setSource(lastLocation.lat, lastLocation.lon, lastLocation.name); } else if (!urlHasExplicitLocation && navigator.permissions && navigator.permissions.query) { navigator.permissions.query({ name: 'geolocation' }).then(function (status) { if (status.state === 'granted') requestLocation(true); }).catch(function () {}); } render(); return { map: map }; } window.Vindn = window.Vindn || {}; window.Vindn.categoryColor = categoryColor; window.Vindn.categoryMarkerHex = categoryMarkerHex; window.Vindn.dotIcon = dotIcon; window.Vindn.distanceKm = distanceKm; window.Vindn.renderPinMap = renderPinMap; window.Vindn.readEmbeddedJson = readEmbeddedJson; window.Vindn.initMapStage = initMapStage; window.Vindn.initRadiusMapStage = initRadiusMapStage; })(window);