939 lines
41 KiB
JavaScript
939 lines
41 KiB
JavaScript
(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
|
|
? '<span style="position:absolute;inset:-8px;border-radius:50%;background:' + color + ';opacity:.25;animation:vindnPulse 2.2s ease-out infinite;"></span>'
|
|
: '';
|
|
return L.divIcon({
|
|
className: 'pin-icon',
|
|
html: '<span style="position:relative;display:block;width:' + size + 'px;height:' + size + 'px;">' + ring +
|
|
'<span style="position:absolute;inset:0;border-radius:50%;background:' + color + ';border:2px solid #fff;box-shadow:0 1px 4px rgba(0,0,0,.35);"></span></span>',
|
|
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 = '<div class="pin-pop">' +
|
|
(ev.category ? '<span class="cat" style="color:' + hex + ';">' + ev.category + '</span>' : '') +
|
|
'<h4>' + ev.title + '</h4>' +
|
|
(ev.venue ? '<p>' + ev.venue + '</p>' : '') +
|
|
(ev.url ? '<a href="' + ev.url + '">' + window.VindnI18n.t('map.view_event') + '</a>' : '') +
|
|
'</div>';
|
|
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: '© <a href="https://openfreemap.org" target="_blank">OpenFreeMap</a> <a href="https://www.openmaptiles.org/" target="_blank">© OpenMapTiles</a> Data from <a href="https://www.openstreetmap.org/copyright" target="_blank">OpenStreetMap</a>'
|
|
}).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 '<span><span class="dot" style="background:' + categoryMarkerHex(cat) + ';"></span>' + cat + '</span>';
|
|
}).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 = '<div class="dist">' + distLabel + '<span class="u">' + distUnit + '</span></div>';
|
|
}
|
|
return (
|
|
'<div class="map-row' + (ev._dist != null ? '' : ' no-dist') + '" data-id="' + ev.id + '" tabindex="0" role="button" aria-label="' + ev.title + '">' +
|
|
distHtml +
|
|
'<div><div class="name">' + ev.title + '</div><span class="when">' + (ev.venue || '') + '</span></div>' +
|
|
'<div class="go">→</div>' +
|
|
'</div>'
|
|
);
|
|
}).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 '<span><span class="dot" style="background:' + categoryMarkerHex(cat) + ';"></span>' + cat + '</span>';
|
|
}).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 (
|
|
'<div class="map-row" data-id="' + ev.id + '" tabindex="0" role="button" aria-label="' + ev.title + '">' +
|
|
'<div class="dist">' + distLabel + '<span class="u">' + distUnit + '</span></div>' +
|
|
'<div><div class="name">' + ev.title + '</div><span class="when">' + (ev.venue || '') + '</span></div>' +
|
|
'<div class="go">→</div>' +
|
|
'</div>'
|
|
);
|
|
}).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);
|