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 @@ + +vind \ 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; } +}