diff --git a/.gitignore b/.gitignore
index fbcded1..6f889ef 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,4 @@
.env
.env-frontend
import_files/
-public
\ No newline at end of file
+/backend/public
\ No newline at end of file
diff --git a/frontend/public/images/vindn_logo.png b/frontend/public/images/vindn_logo.png
new file mode 100644
index 0000000..6b458df
Binary files /dev/null and b/frontend/public/images/vindn_logo.png differ
diff --git a/frontend/public/images/vindn_logo_adobe.svg b/frontend/public/images/vindn_logo_adobe.svg
new file mode 100644
index 0000000..9c9b452
--- /dev/null
+++ b/frontend/public/images/vindn_logo_adobe.svg
@@ -0,0 +1,183 @@
+
\ No newline at end of file
diff --git a/frontend/public/images/vindn_logo_path_based.svg b/frontend/public/images/vindn_logo_path_based.svg
new file mode 100644
index 0000000..09f0706
--- /dev/null
+++ b/frontend/public/images/vindn_logo_path_based.svg
@@ -0,0 +1,2 @@
+
+
\ No newline at end of file
diff --git a/frontend/public/images/vindn_logo_text_with_icon.svg b/frontend/public/images/vindn_logo_text_with_icon.svg
new file mode 100644
index 0000000..00dfb35
--- /dev/null
+++ b/frontend/public/images/vindn_logo_text_with_icon.svg
@@ -0,0 +1,2 @@
+
+
\ No newline at end of file
diff --git a/frontend/public/index.php b/frontend/public/index.php
new file mode 100644
index 0000000..9982c21
--- /dev/null
+++ b/frontend/public/index.php
@@ -0,0 +1,9 @@
+>> 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 ? '
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: !!opts.zoomControl,
+ 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
+ }).setView(center, opts.zoom || 13);
+
+ 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: 'View fullscreen',
+ titleCancel: 'Exit fullscreen',
+ 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 };
+
+ 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 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; });
+ 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 = list.length + (list.length === 1 ? ' event' : ' events');
+
+ 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) : ev._dist.toFixed(1);
+ 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 requestLocation() {
+ var statusEl = document.getElementById('locateStatus');
+ if (!navigator.geolocation) {
+ if (statusEl) statusEl.textContent = 'Location not supported by your browser';
+ return;
+ }
+ if (statusEl) statusEl.textContent = 'Finding you…';
+ navigator.geolocation.getCurrentPosition(function (pos) {
+ var lat = pos.coords.latitude;
+ var lon = pos.coords.longitude;
+ state.userLoc = [lat, lon];
+
+ if (limit) {
+ rebuildMarkers(nearest([lat, lon], allEvents.slice(), limit));
+ if (statusEl) statusEl.textContent = 'Showing the ' + limit + ' nearest events';
+ } else if (statusEl) {
+ statusEl.textContent = 'Showing events near you';
+ }
+
+ 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 () {
+ if (statusEl) statusEl.textContent = "Couldn't get your location";
+ }, { timeout: 8000 });
+ }
+
+ var locateBtn = document.getElementById('locateBtn');
+ if (locateBtn) locateBtn.addEventListener('click', requestLocation);
+
+ // 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.
+ if (navigator.permissions && navigator.permissions.query) {
+ navigator.permissions.query({ name: 'geolocation' }).then(function (status) {
+ if (status.state === 'granted') requestLocation();
+ }).catch(function () {});
+ }
+
+ 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' };
+
+ var result = renderPinMap(containerId, [], {
+ zoomControl: true,
+ attributionControl: true,
+ center: state.source,
+ zoom: opts.zoom || 11,
+ // This map already owns the whole viewport (no surrounding page to
+ // protect from trapped scroll/pinch), so gesture handling would only
+ // make one-finger dragging feel unresponsive for no benefit.
+ gestureHandling: false,
+ 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; })
+ .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);
+ 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');
+ if (statusEl) statusEl.textContent = 'Showing events within ' + state.rangeKm + ' km';
+
+ var countEl = document.getElementById('resultCount');
+ if (countEl) countEl.textContent = 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 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) {
+ 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) {
+ map.on('click', function (e) { setSource(e.latlng.lat, e.latlng.lng); });
+ }
+ if (sourceMarker) {
+ sourceMarker.on('dragend', function () {
+ var ll = sourceMarker.getLatLng();
+ setSource(ll.lat, ll.lng);
+ });
+ }
+
+ 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();
+ });
+ }
+
+ // Nominatim: explicit submit only, never as-you-type — required by
+ // its usage policy (max ~1 req/s, no autocomplete-style querying).
+ 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;
+ var statusEl = document.getElementById('locateStatus');
+ if (statusEl) statusEl.textContent = 'Looking up "' + query + '"…';
+ fetch('https://nominatim.openstreetmap.org/search?format=json&limit=1&q=' + encodeURIComponent(query))
+ .then(function (res) { return res.json(); })
+ .then(function (results) {
+ if (!results || !results.length) {
+ if (statusEl) statusEl.textContent = 'Could not find "' + query + '"';
+ return;
+ }
+ setSource(parseFloat(results[0].lat), parseFloat(results[0].lon));
+ })
+ .catch(function () {
+ if (statusEl) statusEl.textContent = 'Location search failed';
+ });
+ });
+ }
+
+ function requestLocation() {
+ var statusEl = document.getElementById('locateStatus');
+ if (!navigator.geolocation) {
+ if (statusEl) statusEl.textContent = 'Location not supported by your browser';
+ return;
+ }
+ if (statusEl) statusEl.textContent = 'Finding you…';
+ navigator.geolocation.getCurrentPosition(function (pos) {
+ setSource(pos.coords.latitude, pos.coords.longitude);
+ }, function () {
+ if (statusEl) statusEl.textContent = "Couldn't get your location";
+ }, { timeout: 8000 });
+ }
+
+ var locateBtn = document.getElementById('locateBtn');
+ if (locateBtn) locateBtn.addEventListener('click', requestLocation);
+
+ if (navigator.permissions && navigator.permissions.query) {
+ navigator.permissions.query({ name: 'geolocation' }).then(function (status) {
+ if (status.state === 'granted') requestLocation();
+ }).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);
diff --git a/frontend/public/js/nav.js b/frontend/public/js/nav.js
new file mode 100644
index 0000000..f9872ae
--- /dev/null
+++ b/frontend/public/js/nav.js
@@ -0,0 +1,52 @@
+(function () {
+ var burger = document.querySelector('.nav-burger');
+ var menu = document.getElementById('mobileMenu');
+ if (!burger || !menu) return;
+
+ function closeMenu() {
+ menu.hidden = true;
+ burger.classList.remove('is-open');
+ burger.setAttribute('aria-expanded', 'false');
+ }
+
+ function openMenu() {
+ menu.hidden = false;
+ burger.classList.add('is-open');
+ burger.setAttribute('aria-expanded', 'true');
+ }
+
+ burger.addEventListener('click', function () {
+ if (menu.hidden) {
+ openMenu();
+ } else {
+ closeMenu();
+ }
+ });
+
+ menu.addEventListener('click', function (e) {
+ if (e.target.tagName === 'A') {
+ closeMenu();
+ }
+ });
+
+ document.addEventListener('keydown', function (e) {
+ if (e.key === 'Escape') {
+ closeMenu();
+ }
+ });
+
+ document.addEventListener('click', function (e) {
+ if (!menu.hidden && !menu.contains(e.target) && !burger.contains(e.target)) {
+ closeMenu();
+ }
+ });
+
+ // Desktop CSS hides .mobile-menu entirely, but if the menu was left open
+ // on mobile and the viewport grows past the breakpoint (resize, rotate,
+ // devtools), reset state so it doesn't reopen collapsed at mobile width.
+ window.matchMedia('(min-width: 761px)').addEventListener('change', function (e) {
+ if (e.matches) {
+ closeMenu();
+ }
+ });
+})();
diff --git a/frontend/public/styles.css b/frontend/public/styles.css
new file mode 100644
index 0000000..624fa5d
--- /dev/null
+++ b/frontend/public/styles.css
@@ -0,0 +1,429 @@
+/* ============================================================
+ VINDN — Events platform
+ Design concept: the "board" — every event is treated like an
+ entry on a departure board (date / time / place rendered in
+ tabular mono type on a dark strip). It is the one motif that
+ repeats across hero, listings, search results and detail page,
+ tying "find an event" to the universal feeling of checking
+ what's on, right now, near you.
+ ============================================================ */
+
+@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700&family=Inter:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500;600&display=swap');
+
+:root{
+ /* ---- color ---- */
+ --paper: #F7F7F4;
+ --paper-dim: #EFEFE9;
+ --ink: #14140F;
+ --ink-soft: #3C3C34;
+ --muted: #74746A;
+ --line: #DEDED5;
+ --line-strong: #C7C7BC;
+
+ --accent: #2440FF;
+ --accent-deep: #1530CC;
+ --accent-soft: #E7EAFF;
+
+ --warn: #C97A00;
+ --warn-soft: #FFEFD2;
+
+ --board-bg: #14140F;
+ --board-bg-2: #1D1D16;
+ --board-text: #F4F3EC;
+ --board-muted: #9A9A8C;
+ --board-accent: #6E86FF;
+ --board-warn: #FFC04D;
+
+ /* ---- type ---- */
+ --f-display: 'Space Grotesk', sans-serif;
+ --f-body: 'Inter', sans-serif;
+ --f-mono: 'IBM Plex Mono', monospace;
+
+ /* ---- shape / space ---- */
+ --r-card: 10px;
+ --r-pill: 999px;
+ --r-flat: 2px;
+ --gutter: clamp(20px, 5vw, 64px);
+ --maxw: 1240px;
+}
+
+*{ box-sizing: border-box; }
+html{ scroll-behavior: smooth; }
+body{
+ margin:0;
+ background: var(--paper);
+ color: var(--ink);
+ font-family: var(--f-body);
+ font-size: 16px;
+ line-height: 1.5;
+ -webkit-font-smoothing: antialiased;
+}
+img,svg{ max-width:100%; display:block; }
+a{ color:inherit; text-decoration:none; }
+ul{ margin:0; padding:0; list-style:none; }
+button{ font-family:inherit; cursor:pointer; }
+.wrap{ max-width: var(--maxw); margin:0 auto; padding:0 var(--gutter); }
+
+/* focus visibility — accessibility floor */
+a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible{
+ outline: 2px solid var(--accent);
+ outline-offset: 2px;
+ border-radius: 3px;
+}
+@media (prefers-reduced-motion: reduce){
+ *{ animation-duration: .001ms !important; transition-duration: .001ms !important; }
+}
+
+/* ============ type scale ============ */
+h1,h2,h3{ font-family: var(--f-display); font-weight:700; letter-spacing:-0.01em; margin:0; }
+.eyebrow{
+ font-family: var(--f-mono);
+ font-size: 12px;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--muted);
+}
+.mono{ font-family: var(--f-mono); font-variant-numeric: tabular-nums; }
+
+/* ============ top navigation ============ */
+.site-header{
+ position: sticky; top:0; z-index: 50;
+ background: rgba(247, 247, 244, 0.85);
+ backdrop-filter: blur(5px);
+ -webkit-backdrop-filter: blur(5px);
+ border-bottom: 1px solid var(--line);
+}
+.site-header .wrap{
+ display:flex; align-items:center; justify-content:space-between;
+ height: 76px;
+}
+.brand{ display:flex; align-items:center; gap:10px; }
+.brand img{ height: 76px; width:auto; }
+.main-nav{ display:flex; align-items:center; gap: 30px; }
+.main-nav a{
+ font-size: 14px; font-weight:600; color: var(--ink-soft);
+ padding: 8px 2px; position:relative;
+}
+.main-nav a.is-active{ color: var(--ink); }
+.main-nav a.is-active::after{
+ content:""; position:absolute; left:0; right:0; bottom:-2px; height:2px; background: var(--accent);
+}
+.nav-search{
+ display:flex; align-items:center; gap:8px;
+ border:1px solid var(--line-strong); border-radius: var(--r-pill);
+ padding: 8px 14px; font-size:13px; color:var(--muted);
+ min-width: 220px;
+}
+.nav-search svg{ width:15px; height:15px; flex:none; }
+.nav-search:focus-within{ border-color:var(--accent); }
+.nav-search button{ background:none; border:none; padding:0; display:flex; color:inherit; flex:none; }
+.nav-search input{ border:none; background:none; outline:none; padding:0; font-family:inherit; font-size:13px; color:var(--ink); flex:1; min-width:0; }
+.nav-search input::placeholder{ color:var(--muted); }
+.nav-cta{
+ background: var(--ink); color: var(--paper);
+ font-size: 13px; font-weight:600;
+ padding: 11px 18px; border-radius: var(--r-pill);
+ border:none;
+}
+.nav-actions{ display:flex; align-items:center; gap:16px; }
+.nav-burger{ display:none; background:none; border:none; padding:6px; }
+.nav-burger span{ display:block; width:22px; height:2px; background:var(--ink); margin:5px 0; transition:transform .2s ease, opacity .2s ease; }
+.nav-burger.is-open span:nth-child(1){ transform:translateY(7px) rotate(45deg); }
+.nav-burger.is-open span:nth-child(2){ opacity:0; }
+.nav-burger.is-open span:nth-child(3){ transform:translateY(-7px) rotate(-45deg); }
+
+.mobile-menu{ display:none; }
+
+/* ============ buttons ============ */
+.btn{
+ display:inline-flex; align-items:center; gap:8px;
+ font-family: var(--f-body); font-weight:600; font-size:14px;
+ padding: 13px 22px; border-radius: var(--r-pill); border:1px solid transparent;
+ transition: transform .15s ease, background .15s ease;
+}
+.btn-primary{ background: var(--ink); color:var(--paper); }
+.btn-primary:hover{ background: var(--accent-deep); }
+.btn-ghost{ background: transparent; color:var(--ink); border-color: var(--line-strong); }
+.btn-ghost:hover{ border-color: var(--ink); }
+.btn-accent{ background: var(--accent); color:#fff; }
+.btn-accent:hover{ background: var(--accent-deep); }
+.btn:active{ transform: translateY(1px); }
+.btn-sm{ padding:9px 16px; font-size:13px; }
+
+/* ============ chips / tags ============ */
+.chip-row{ display:flex; flex-wrap:wrap; gap:10px; }
+
+/* collapsible chip-row: CSS-only checkbox toggle, no JS needed */
+.chip-row-collapsible{ max-height:100px; overflow:hidden; }
+.chip-toggle-checkbox{ position:absolute; opacity:0; pointer-events:none; }
+.chip-toggle-checkbox:checked ~ .chip-row-collapsible{ max-height:none; }
+.chip-toggle-btn{
+ display:inline-flex; margin-top:12px; cursor:pointer;
+ font-family:var(--f-mono); font-size:12px; font-weight:600; color:var(--accent);
+}
+.chip-toggle-btn:hover{ color:var(--accent-deep); }
+.chip-toggle-btn .show-less{ display:none; }
+.chip-toggle-checkbox:checked ~ .chip-toggle-btn .show-more{ display:none; }
+.chip-toggle-checkbox:checked ~ .chip-toggle-btn .show-less{ display:inline; }
+
+.chip{
+ font-family: var(--f-mono); font-size: 12px; letter-spacing:.02em;
+ padding: 7px 14px; border-radius: var(--r-pill);
+ border:1px solid var(--line-strong); color: var(--ink-soft);
+ background: var(--paper); white-space:nowrap;
+}
+.chip.is-active{ background: var(--ink); color: var(--paper); border-color: var(--ink); }
+
+/* ============ pagination (first/prev/current/next/last) ============ */
+.pager{ display:flex; justify-content:center; align-items:center; gap:8px; margin-top:36px; flex-wrap:wrap; }
+.pager a, .pager span{
+ padding:9px 16px; border:1px solid var(--line); border-radius:8px;
+ font-family:var(--f-mono); font-size:13px; white-space:nowrap;
+}
+.pager a:hover{ border-color:var(--line-strong); }
+.pager span.is-active{ background:var(--ink); color:var(--paper); border-color:var(--ink); font-weight:600; }
+
+.tag{
+ font-family: var(--f-mono); font-size:11px; letter-spacing:.04em; text-transform:uppercase;
+ padding: 4px 9px; border-radius: var(--r-flat); background: var(--accent-soft); color: var(--accent-deep);
+}
+.tag.tag-warn{ background: var(--warn-soft); color: var(--warn); }
+.tag.tag-dark{ background: var(--ink); color: var(--paper); }
+
+/* ============ category glyph (replaces stock photography) ============
+ Small abstract line-glyphs per category drawn with currentColor,
+ set on a soft tinted tile — keeps the system illustrative and
+ consistent instead of relying on photography. */
+.glyph-tile{
+ display:flex; align-items:center; justify-content:center;
+ border-radius: var(--r-card);
+ aspect-ratio: 4/3;
+ position:relative; overflow:hidden;
+}
+.glyph-tile svg{ width:34%; height:34%; }
+.glyph-tile::after{
+ content:""; position:absolute; inset:0;
+ background-image: radial-gradient(circle at 20% 20%, rgba(255,255,255,.5), transparent 45%);
+}
+.t-music{ background:#E7EAFF; color:#2440FF; }
+.t-tech{ background:#E4F3EC; color:#1F8A56; }
+.t-community{ background:#FFEFD2; color:#C97A00; }
+.t-sports{ background:#FDE3E0; color:#D14B3A; }
+.t-arts{ background:#F1E6FB; color:#7A3FC9; }
+.t-food{ background:#FFF1DC; color:#B9740B; }
+
+/* ============ departure board — the signature motif ============ */
+.board{
+ background: var(--board-bg);
+ border-radius: var(--r-card);
+ color: var(--board-text);
+ overflow:hidden;
+}
+.board-head{
+ display:flex; align-items:center; justify-content:space-between;
+ padding: 16px 22px; border-bottom:1px solid #2B2B22;
+}
+.board-head .eyebrow{ color: var(--board-muted); }
+.board-clock{ font-family:var(--f-mono); font-size:13px; color: var(--board-accent); }
+.board-row{
+ display:grid;
+ grid-template-columns: 96px 1fr 140px 90px;
+ align-items:center;
+ gap: 18px;
+ padding: 16px 22px;
+ border-bottom: 1px solid #232319;
+ font-family: var(--f-mono);
+}
+.board-row:last-child{ border-bottom:none; }
+.board-row:hover{ background: var(--board-bg-2); }
+.board-date{ color: var(--board-accent); font-size:13px; letter-spacing:.03em; }
+.board-date .dow{ display:block; color: var(--board-muted); font-size:11px; margin-top:2px; }
+.board-name{ font-family: var(--f-body); font-weight:600; font-size:15px; color: var(--board-text); }
+.board-name .where{ display:block; font-family:var(--f-mono); font-weight:400; font-size:12px; color: var(--board-muted); margin-top:3px; letter-spacing:.02em; }
+.board-status{ font-size:12px; text-align:right; color: var(--board-muted); }
+.board-status.is-soon{ color: var(--board-warn); }
+.board-status.is-live{ color: #6CE3A0; }
+.board-go{ text-align:right; font-size:12px; color: var(--board-accent); }
+
+/* ============ section scaffolding ============ */
+.section{ padding: 64px 0; }
+.section-head{
+ display:flex; align-items:flex-end; justify-content:space-between;
+ gap: 20px; margin-bottom: 28px; flex-wrap:wrap;
+}
+.section-head h2{ font-size: clamp(24px,3vw,32px); }
+.section-link{ font-size:13px; font-weight:600; color:var(--accent); display:flex; gap:6px; align-items:center; }
+
+/* ============ card grid ============ */
+.card-grid{ display:grid; grid-template-columns: repeat(3,1fr); gap: 22px; }
+.event-card{
+ border:1px solid var(--line); border-radius: var(--r-card); background:#fff;
+ overflow:hidden; display:flex; flex-direction:column;
+ transition: border-color .15s ease, transform .15s ease;
+}
+.event-card:hover{ border-color: var(--line-strong); transform: translateY(-2px); }
+.event-card .body{ padding: 16px 18px 18px; display:flex; flex-direction:column; gap:8px; flex:1; }
+.event-card .meta-row{ display:flex; align-items:center; gap:10px; }
+.event-card h3{ font-size:16px; font-family: var(--f-body); font-weight:600; line-height:1.3; }
+.event-card .when{ font-family: var(--f-mono); font-size:12px; color: var(--muted); }
+.event-card .place{ font-size:13px; color: var(--muted); margin-top:auto; }
+.event-card .price{ font-family:var(--f-mono); font-weight:600; font-size:13px; }
+
+/* ============ footer ============ */
+.site-footer{ border-top:1px solid var(--line); padding: 48px 0 28px; margin-top:40px; }
+.footer-grid{ display:grid; grid-template-columns: 1.4fr 1fr 1fr 1fr; gap: 32px; }
+.footer-grid h4{ font-family:var(--f-mono); font-size:12px; text-transform:uppercase; letter-spacing:.06em; color:var(--muted); margin:0 0 14px; }
+.footer-grid li{ margin-bottom:10px; font-size:14px; }
+.footer-grid li a:hover{ color: var(--accent); }
+.footer-bottom{ display:flex; justify-content:space-between; align-items:center; margin-top:40px; padding-top:20px; border-top:1px solid var(--line); font-size:12px; color:var(--muted); font-family:var(--f-mono); }
+
+/* ============ misc utility ============ */
+.divider{ height:1px; background:var(--line); border:none; margin: 0; }
+.skip-link{ position:absolute; left:-999px; }
+.skip-link:focus{ left: 12px; top: 12px; background: var(--ink); color:#fff; padding:8px 14px; border-radius:6px; z-index:100; }
+
+/* ============ responsive ============ */
+@media (max-width: 980px){
+ .card-grid{ grid-template-columns: repeat(2,1fr); }
+ .footer-grid{ grid-template-columns: 1.4fr 1fr 1fr; }
+ .board-row{ grid-template-columns: 80px 1fr 76px; }
+ .board-go{ display:none; }
+}
+@media (max-width: 760px){
+ .main-nav, .nav-search{ display:none; }
+ .nav-burger{ display:block; }
+ .card-grid{ grid-template-columns: 1fr 1fr; }
+ .footer-grid{ grid-template-columns: 1fr 1fr; }
+ .section{ padding: 44px 0; }
+
+ .mobile-menu{
+ display:block;
+ position:absolute; top:100%; left:0; right:0; z-index:49;
+ background:var(--paper); border-bottom:1px solid var(--line);
+ padding:8px 20px 20px;
+ box-shadow:0 10px 30px rgba(20,20,15,.08);
+ }
+ .mobile-menu[hidden]{ display:none; }
+ .mobile-menu nav{ display:flex; flex-direction:column; }
+ .mobile-menu nav a{
+ padding:14px 0; font-size:15px; font-weight:600; color:var(--ink-soft);
+ border-bottom:1px solid var(--line);
+ }
+ .mobile-menu nav a.is-active{ color:var(--ink); }
+ .mobile-menu .mobile-search{ display:flex; gap:8px; margin-top:14px; }
+ .mobile-menu .mobile-search input{
+ flex:1; min-width:0; border:1px solid var(--line-strong); border-radius:var(--r-pill);
+ padding:10px 14px; font-size:14px; font-family:inherit; color:var(--ink); background:none;
+ }
+ .mobile-menu .mobile-search button{
+ background:var(--ink); color:var(--paper); border:none; border-radius:var(--r-pill);
+ padding:10px 16px; font-size:13px; font-weight:600;
+ }
+}
+@media (max-width: 520px){
+ .card-grid{ grid-template-columns: 1fr; }
+ .board-row{ grid-template-columns: 70px 1fr; }
+ .board-status{ display:none; }
+}
+
+/* ============ interactive map stage (home hero + /map) ============ */
+.stage{ position:relative; height: calc(100vh - 76px); min-height:520px; }
+.stage-compact{ height:560px; min-height:420px; border-radius:var(--r-card); overflow:hidden; box-shadow:0 10px 30px rgba(20,20,15,.08); }
+.stage-compact .panel{ width:300px; }
+.stage:fullscreen{ height:100vh; border-radius:0; }
+.stage:-webkit-full-screen{ height:100vh; border-radius:0; }
+#mapCanvas{ position:absolute; inset:0; background: var(--paper-dim); z-index: 10;}
+.leaflet-tile-pane, .maplibregl-map{ filter: grayscale(0.85) brightness(1.08) contrast(0.92); }
+.leaflet-popup-content-wrapper{ border-radius:10px; box-shadow:0 10px 30px rgba(20,20,15,.18); }
+.leaflet-popup-content{ margin:14px 16px; font-family:var(--f-body); }
+.leaflet-control-attribution{ font-size:10px; }
+
+.map-overlay-top{
+ position:absolute; top:5px; left:50px; right:10px; z-index:30;
+ display:flex; align-items:flex-start; justify-content:space-between; gap:14px; flex-wrap:wrap;
+ pointer-events:none;
+}
+.float-card{
+ background: rgba(247,247,244,0.85);
+ backdrop-filter: blur(5px);
+ -webkit-backdrop-filter: blur(5px);
+ border:1px solid var(--line); border-radius:14px;
+ box-shadow: 0 8px 24px rgba(20,20,15,.10);
+ pointer-events:auto;
+}
+.search-card{ padding:14px 16px; flex:1; max-width:560px; }
+.search-card form{ display:flex; gap:10px; margin-bottom:12px; }
+.search-card input{ flex:1; padding:11px 14px; border:1px solid var(--line-strong); border-radius:var(--r-pill); font-size:14px; }
+.chip-row-tight{ display:flex; gap:8px; flex-wrap:wrap; max-height:64px; overflow-y:auto; }
+.chip-sm{ font-family:var(--f-mono); font-size:11px; padding:6px 12px; border-radius:var(--r-pill); border:1px solid var(--line-strong); background:#fff; color:var(--ink-soft); }
+.chip-sm.is-active{ background:var(--ink); color:var(--paper); border-color:var(--ink); }
+.locate-card{ padding:10px; flex:none; width:220px; }
+#locationSearchForm{ display:flex; gap:6px; margin-bottom:8px; }
+#locationSearchForm input{ flex:1; min-width:0; padding:9px 12px; border:1px solid var(--line-strong); border-radius:var(--r-pill); font-size:12px; }
+.locate-btn{
+ display:flex; align-items:center; gap:8px; background:var(--ink); color:var(--paper);
+ border:none; border-radius:var(--r-pill); padding:11px 16px; font-size:13px; font-weight:600; width:100%;
+}
+.range-label{
+ display:flex; align-items:center; gap:8px; margin-top:10px;
+ font-family:var(--f-mono); font-size:11px; color:var(--muted);
+}
+.range-label select{
+ flex:1; padding:6px 10px; border:1px solid var(--line-strong); border-radius:6px;
+ font-family:var(--f-mono); font-size:12px; background:#fff; color:var(--ink);
+}
+.locate-status{ font-family:var(--f-mono); font-size:11px; color:var(--muted); margin-top:8px; max-width:200px; }
+
+.legend{
+ position:absolute; left:18px; bottom:18px; z-index:30; padding:10px 14px;
+ display:flex; gap:14px; flex-wrap:wrap; max-width:30vw; flex-direction: column;
+}
+.legend span{ display:flex; align-items:center; gap:6px; font-family:var(--f-mono); font-size:11px; color:var(--ink-soft); }
+.legend .dot{ width:9px; height:9px; border-radius:50%; }
+
+.panel{
+ position:absolute; top:200px; right:10px; bottom:18px; width:360px; z-index:25;
+ display:flex; flex-direction:column; overflow:hidden;
+}
+/* Overrides .board's solid background just for the map's floating events
+ panel, to match the other map overlay elements — other .board usages
+ (event cards, home hero board) keep their solid background. */
+.panel.board{
+ background: rgba(20,20,15,0.85);
+ backdrop-filter: blur(5px);
+ -webkit-backdrop-filter: blur(5px);
+}
+.panel-head{ padding:16px 18px; border-bottom:1px solid var(--line); display:flex; justify-content:space-between; align-items:center; }
+.panel-head .count{ font-family:var(--f-mono); font-size:12px; color:var(--muted); }
+.panel-list{ overflow-y:auto; flex:1; }
+.map-row{ display:grid; grid-template-columns: 58px 1fr 22px; gap:12px; align-items:center; padding:13px 18px; border-bottom:1px solid #232319; cursor:pointer; }
+.map-row.no-dist{ grid-template-columns: 1fr 22px; }
+.map-row:last-child{ border-bottom:none; }
+.map-row:hover, .map-row.is-highlighted{ background:var(--board-bg-2); }
+.map-row .dist{ font-family:var(--f-mono); font-size:13px; color:var(--board-accent); }
+.map-row .dist .u{ display:block; font-size:10px; color:var(--board-muted); }
+.map-row .name{ font-family:var(--f-body); font-weight:600; font-size:13.5px; color:var(--board-text); line-height:1.3; }
+.map-row .when{ display:block; font-family:var(--f-mono); font-size:11px; color:var(--board-muted); margin-top:3px; }
+.map-row .go{ color:var(--board-accent); text-align:right; }
+.pin-pop{ min-width:170px; }
+.pin-pop .cat{ font-family:var(--f-mono); font-size:10px; text-transform:uppercase; letter-spacing:.05em; }
+.pin-pop h4{ font-size:14px; margin:6px 0 4px; font-family:var(--f-body); font-weight:600; }
+.pin-pop p{ font-family:var(--f-mono); font-size:11.5px; color:var(--muted); margin:0 0 8px; }
+.pin-pop a{ font-size:12px; font-weight:600; color:var(--accent); }
+
+/* reskin markercluster's default green/orange bubbles to the site's ink tone */
+/* !important: MarkerCluster.Default.css loads after this file (in each
+ page's head_extra block), so an equal-specificity override would lose
+ the tie without it. */
+.marker-cluster-small, .marker-cluster-medium, .marker-cluster-large{ background-color: rgba(20,20,15,.18) !important; }
+.marker-cluster-small div, .marker-cluster-medium div, .marker-cluster-large div{ background-color: var(--ink) !important; }
+.marker-cluster span{ color:#fff !important; font-family:var(--f-mono); font-weight:600; }
+
+@media (max-width: 900px){
+ .stage{ height:auto; }
+ .stage-compact{ height:auto; }
+ #mapCanvas{ position:static; height:62vh; }
+ .panel, .stage-compact .panel{ position:static; width:auto; height:auto; max-height:50vh; margin-top:14px; }
+ .map-overlay-top{ position:absolute; }
+ .legend{ display:none; }
+}