fix(webapp): fix & improve map substantially (#9481)

This commit is contained in:
Ulf Gebhardt 2026-04-02 20:59:49 +02:00 committed by GitHub
parent 4c539406bc
commit 0bf724f0c0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 1607 additions and 239 deletions

View File

@ -1301,13 +1301,123 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl']
})
// eslint-disable-next-line no-console
console.log('seed', 'users additional')
console.log('seed', 'users additional with map locations around Zwingenberg')
// Region Hessen (Mapbox-compatible hierarchy: place -> region -> country)
const Hessen = await Factory.build('location', {
id: 'region.8967011281068080',
name: 'Hessen',
type: 'region',
lng: 8.6528,
lat: 50.6521,
nameDE: 'Hessen',
nameEN: 'Hesse',
nameES: 'Hesse',
nameFR: 'Hesse',
nameIT: 'Assia',
namePT: 'Hessen',
nameNL: 'Hessen',
namePL: 'Hesja',
nameRU: 'Гессен',
})
await Hessen.relateTo(Germany, 'isIn')
// 50 villages around Zwingenberg (64673), Zwingenberg excluded
// Mapbox-compatible: type 'place', realistic IDs
const zwingenbergVillages = [
// Bergstraße (west)
{ id: 'place.8652241', name: 'Alsbach-Hähnlein', lat: 49.7389, lng: 8.6331 },
{ id: 'place.8652242', name: 'Bickenbach', lat: 49.7567, lng: 8.6178 },
{ id: 'place.8652243', name: 'Seeheim-Jugenheim', lat: 49.7631, lng: 8.6506 },
{ id: 'place.8652244', name: 'Bensheim', lat: 49.6812, lng: 8.6167 },
{ id: 'place.8652245', name: 'Auerbach', lat: 49.7053, lng: 8.6389 },
{ id: 'place.8652246', name: 'Heppenheim', lat: 49.6428, lng: 8.6392 },
{ id: 'place.8652247', name: 'Lorsch', lat: 49.6539, lng: 8.5678 },
{ id: 'place.8652248', name: 'Einhausen', lat: 49.6775, lng: 8.5578 },
{ id: 'place.8652249', name: 'Gernsheim', lat: 49.7528, lng: 8.4906 },
{ id: 'place.8652250', name: 'Pfungstadt', lat: 49.8056, lng: 8.6042 },
// Odenwald (east)
{ id: 'place.8652251', name: 'Reichenbach', lat: 49.725, lng: 8.67 },
{ id: 'place.8652252', name: 'Lautertal', lat: 49.7253, lng: 8.6914 },
{ id: 'place.8652253', name: 'Lindenfels', lat: 49.6836, lng: 8.7781 },
{ id: 'place.8652254', name: 'Modautal', lat: 49.7736, lng: 8.7258 },
{ id: 'place.8652255', name: 'Mühltal', lat: 49.8003, lng: 8.6917 },
{ id: 'place.8652256', name: 'Ober-Ramstadt', lat: 49.8306, lng: 8.7486 },
{ id: 'place.8652257', name: 'Reinheim', lat: 49.8289, lng: 8.8356 },
{ id: 'place.8652258', name: 'Groß-Bieberau', lat: 49.7906, lng: 8.8281 },
{ id: 'place.8652259', name: 'Fränkisch-Crumbach', lat: 49.745, lng: 8.8444 },
{ id: 'place.8652260', name: 'Brensbach', lat: 49.7742, lng: 8.8819 },
// Ried (west/southwest)
{ id: 'place.8652261', name: 'Bürstadt', lat: 49.6433, lng: 8.4506 },
{ id: 'place.8652262', name: 'Lampertheim', lat: 49.5978, lng: 8.47 },
{ id: 'place.8652263', name: 'Biblis', lat: 49.6878, lng: 8.4531 },
{ id: 'place.8652264', name: 'Groß-Rohrheim', lat: 49.7228, lng: 8.4822 },
{ id: 'place.8652265', name: 'Riedstadt', lat: 49.835, lng: 8.4944 },
{ id: 'place.8652266', name: 'Stockstadt am Rhein', lat: 49.8094, lng: 8.4656 },
{ id: 'place.8652267', name: 'Biebesheim', lat: 49.7806, lng: 8.4672 },
{ id: 'place.8652268', name: 'Trebur', lat: 49.9211, lng: 8.4081 },
{ id: 'place.8652269', name: 'Nauheim', lat: 49.9456, lng: 8.4494 },
{ id: 'place.8652270', name: 'Griesheim', lat: 49.8619, lng: 8.5722 },
// Darmstadt area (north)
{ id: 'place.8652271', name: 'Roßdorf', lat: 49.8572, lng: 8.7578 },
{ id: 'place.8652272', name: 'Messel', lat: 49.9333, lng: 8.75 },
{ id: 'place.8652273', name: 'Eppertshausen', lat: 49.95, lng: 8.85 },
{ id: 'place.8652274', name: 'Münster', lat: 49.9253, lng: 8.8653 },
{ id: 'place.8652275', name: 'Dieburg', lat: 49.8983, lng: 8.8467 },
{ id: 'place.8652276', name: 'Babenhausen', lat: 49.965, lng: 8.9511 },
{ id: 'place.8652277', name: 'Schaafheim', lat: 49.9244, lng: 8.9703 },
{ id: 'place.8652278', name: 'Groß-Umstadt', lat: 49.8667, lng: 8.9333 },
{ id: 'place.8652279', name: 'Otzberg', lat: 49.82, lng: 8.91 },
{ id: 'place.8652280', name: 'Höchst im Odenwald', lat: 49.7994, lng: 8.9986 },
// Further south
{ id: 'place.8652281', name: 'Mörlenbach', lat: 49.5969, lng: 8.7378 },
{ id: 'place.8652282', name: 'Rimbach', lat: 49.6256, lng: 8.7611 },
{ id: 'place.8652283', name: 'Fürth', lat: 49.6522, lng: 8.7789 },
{ id: 'place.8652284', name: 'Grasellenbach', lat: 49.6353, lng: 8.8531 },
{ id: 'place.8652285', name: 'Wald-Michelbach', lat: 49.57, lng: 8.83 },
{ id: 'place.8652286', name: 'Abtsteinach', lat: 49.5536, lng: 8.78 },
{ id: 'place.8652287', name: 'Gorxheimertal', lat: 49.5322, lng: 8.7322 },
{ id: 'place.8652288', name: 'Viernheim', lat: 49.5403, lng: 8.5783 },
{ id: 'place.8652289', name: 'Weinheim', lat: 49.5489, lng: 8.6639 },
{ id: 'place.8652290', name: 'Hemsbach', lat: 49.59, lng: 8.65 },
]
// Create village location nodes (one per village, shared by all users in that village)
const villageLocationNodes: (typeof Hamburg)[] = []
for (const village of zwingenbergVillages) {
const location = await Factory.build('location', {
id: village.id,
name: village.name,
type: 'place',
lng: village.lng,
lat: village.lat,
nameDE: village.name,
nameEN: village.name,
nameES: village.name,
nameFR: village.name,
nameIT: village.name,
namePT: village.name,
nameNL: village.name,
namePL: village.name,
nameRU: village.name,
})
await location.relateTo(Hessen, 'isIn')
villageLocationNodes.push(location)
}
// Create 1000 additional users with locations assigned during creation
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const additionalUsers: any[] = []
for (let i = 0; i < 1000; i++) {
if (i % 100 === 0) {
// eslint-disable-next-line no-console
console.log('seed', `additional users ${i}/1000`)
}
const user = await Factory.build('user')
await jennyRostock.relateTo(user, 'following')
await user.relateTo(jennyRostock, 'following')
// Assign village location (round-robin across 50 villages = ~20 users per village)
await user.relateTo(villageLocationNodes[i % villageLocationNodes.length], 'isIn')
additionalUsers.push(user)
const userObj = await user.toJson()
@ -1321,6 +1431,8 @@ const languages = ['de', 'en', 'es', 'fr', 'it', 'pt', 'pl']
},
})
}
// eslint-disable-next-line no-console
console.log('seed', 'additional users 1000/1000 done')
// Jenny's first 99 additional users all redeemed code ABCDEF
// eslint-disable-next-line no-console

View File

@ -1,56 +0,0 @@
<template>
<div>
<os-button
v-for="style in styles"
:key="style.title"
:appearance="actualStyle === style.url ? 'filled' : 'outline'"
variant="primary"
size="sm"
class="map-style-button"
@click="setStyle(style.url)"
>
{{ style.title }}
</os-button>
</div>
</template>
<script>
import { OsButton } from '@ocelot-social/ui'
export default {
components: { OsButton },
name: 'MapStylesButtons',
props: {
styles: { type: Array, required: true },
actualStyle: { type: String, required: true },
setStyle: { type: Function, required: true },
},
}
</script>
<style lang="scss">
// Outline (nicht ausgewählt) button styles
button.map-style-button.bg-transparent {
background-color: $background-color-softer !important;
color: $text-color-base !important;
}
button.map-style-button.bg-transparent:hover {
background-color: $color-primary-light !important;
border-color: $color-primary-light !important;
color: white !important;
}
button.map-style-button.bg-transparent:active {
background-color: $color-primary-dark !important;
border-color: $color-primary-dark !important;
color: white !important;
}
.map-style-button {
position: relative;
margin-left: 6px;
margin-bottom: 6px;
margin-top: 6px;
}
</style>

View File

@ -18,7 +18,7 @@ module.exports = {
],
coverageThreshold: {
global: {
lines: 86,
lines: 87,
},
},
coverageProvider: 'v8',

View File

@ -754,6 +754,7 @@
"event": "Veranstaltung",
"group": "Gruppe",
"theUser": "Meine Position",
"title": "Legende",
"user": "Nutzer"
},
"markerTypes": {
@ -767,7 +768,8 @@
"dark": "Dunkel",
"outdoors": "Landschaft",
"satellite": "Satellit",
"streets": "Straßen"
"streets": "Straßen",
"title": "Kartenstil"
}
},
"modals": {

View File

@ -754,6 +754,7 @@
"event": "Event",
"group": "Group",
"theUser": "My position",
"title": "Legend",
"user": "User"
},
"markerTypes": {
@ -767,7 +768,8 @@
"dark": "Dark",
"outdoors": "Outdoors",
"satellite": "Satellite",
"streets": "Streets"
"streets": "Streets",
"title": "Map style"
}
},
"modals": {

View File

@ -754,6 +754,7 @@
"event": "Evento",
"group": "Grupo",
"theUser": "Mi posición",
"title": "Leyenda",
"user": "Usuario"
},
"markerTypes": {
@ -767,7 +768,8 @@
"dark": "Oscuro",
"outdoors": "Exterior",
"satellite": "Satélite",
"streets": "Calles"
"streets": "Calles",
"title": "Estilo de mapa"
}
},
"modals": {

View File

@ -754,6 +754,7 @@
"event": "Événement",
"group": "Groupe",
"theUser": "Ma position",
"title": "Légende",
"user": "Utilisateur"
},
"markerTypes": {
@ -767,7 +768,8 @@
"dark": "Sombre",
"outdoors": "Plein air",
"satellite": "Satellite",
"streets": "Rues"
"streets": "Rues",
"title": "Style de carte"
}
},
"modals": {

View File

@ -754,6 +754,7 @@
"event": "Evento",
"group": "Gruppo",
"theUser": "La mia posizione",
"title": "Legenda",
"user": "Utente"
},
"markerTypes": {
@ -767,7 +768,8 @@
"dark": "Scuro",
"outdoors": "All'aperto",
"satellite": "Satellite",
"streets": "Strade"
"streets": "Strade",
"title": "Stile mappa"
}
},
"modals": {

View File

@ -754,6 +754,7 @@
"event": "Evenement",
"group": "Groep",
"theUser": "Mijn positie",
"title": "Legenda",
"user": "Gebruiker"
},
"markerTypes": {
@ -767,7 +768,8 @@
"dark": "Donker",
"outdoors": "Buiten",
"satellite": "Satelliet",
"streets": "Straten"
"streets": "Straten",
"title": "Kaartstijl"
}
},
"modals": {

View File

@ -754,6 +754,7 @@
"event": "Wydarzenie",
"group": "Grupa",
"theUser": "Moja pozycja",
"title": "Legenda",
"user": "Użytkownik"
},
"markerTypes": {
@ -767,7 +768,8 @@
"dark": "Ciemny",
"outdoors": "Na zewnątrz",
"satellite": "Satelita",
"streets": "Ulice"
"streets": "Ulice",
"title": "Styl mapy"
}
},
"modals": {

View File

@ -754,6 +754,7 @@
"event": "Evento",
"group": "Grupo",
"theUser": "Minha posição",
"title": "Legenda",
"user": "Usuário"
},
"markerTypes": {
@ -767,7 +768,8 @@
"dark": "Escuro",
"outdoors": "Ao ar livre",
"satellite": "Satélite",
"streets": "Ruas"
"streets": "Ruas",
"title": "Estilo do mapa"
}
},
"modals": {

View File

@ -754,6 +754,7 @@
"event": "Событие",
"group": "Группа",
"theUser": "Моя позиция",
"title": "Легенда",
"user": "Пользователь"
},
"markerTypes": {
@ -767,7 +768,8 @@
"dark": "Тёмная",
"outdoors": "Природа",
"satellite": "Спутник",
"streets": "Улицы"
"streets": "Улицы",
"title": "Стиль карты"
}
},
"modals": {

View File

@ -754,6 +754,7 @@
"event": "Ngjarje",
"group": "Grup",
"theUser": "Pozicioni im",
"title": "Legjenda",
"user": "Përdorues"
},
"markerTypes": {
@ -767,7 +768,8 @@
"dark": "E errët",
"outdoors": "Jashtë",
"satellite": "Satelit",
"streets": "Rrugë"
"streets": "Rrugë",
"title": "Stili i hartës"
}
},
"modals": {

View File

@ -754,6 +754,7 @@
"event": "Подія",
"group": "Група",
"theUser": "Моя позиція",
"title": "Легенда",
"user": "Користувач"
},
"markerTypes": {
@ -767,7 +768,8 @@
"dark": "Темна",
"outdoors": "На відкритому повітрі",
"satellite": "Супутник",
"streets": "Вулиці"
"streets": "Вулиці",
"title": "Стиль карти"
}
},
"modals": {

File diff suppressed because it is too large Load Diff

View File

@ -1,29 +1,7 @@
<!-- Example Reference: https://codesandbox.io/s/v-mapbox-with-nuxt-lbrt6?file=/pages/index.vue -->
<template>
<div class="map-page">
<div class="map-header">
<h1 class="ds-heading ds-heading-h1">{{ $t('map.pageTitle') }}</h1>
<small>
<div>
<span v-for="type in markers.types" :key="type.id">
<img
:alt="$t('map.legend.' + type.id)"
:src="'/img/mapbox/marker-icons/' + type.icon.legendName"
width="15"
/>
{{ $t('map.legend.' + type.id) }}
&nbsp;&nbsp;
</span>
</div>
</small>
</div>
<client-only v-if="!isEmpty($env.MAPBOX_TOKEN)">
<map-styles-buttons
v-if="isMobile"
:styles="styles"
:actualStyle="mapOptions.style"
:setStyle="setStyle"
/>
<mgl-map
:mapbox-gl="mapboxgl"
:access-token="mapOptions.accessToken"
@ -39,16 +17,37 @@
:max-pitch="60"
@load="onMapLoad"
>
<map-styles-buttons
v-if="!isMobile"
:styles="styles"
:actualStyle="mapOptions.style"
:setStyle="setStyle"
/>
<MglFullscreenControl />
<MglNavigationControl position="top-right" />
<MglGeolocateControl position="top-right" />
<MglScaleControl />
<div class="map-legend" :class="{ 'map-legend--open': legendOpen }">
<button
class="map-legend-toggle"
:aria-expanded="String(legendOpen)"
aria-controls="map-legend-content"
@click="legendOpen = !legendOpen"
>
{{ $t('map.legend.title') }}
<span class="map-legend-arrow" aria-hidden="true">{{ legendOpen ? '▼' : '▲' }}</span>
</button>
<div
id="map-legend-content"
v-show="legendOpen || !isMobile"
class="map-legend-content"
role="region"
:aria-label="$t('map.legend.title')"
>
<div v-for="type in markers.types" :key="type.id" class="map-legend-item">
<img
:alt="$t('map.legend.' + type.id)"
:src="'/img/mapbox/marker-icons/' + type.icon.legendName"
width="15"
/>
{{ $t('map.legend.' + type.id) }}
</div>
</div>
</div>
</mgl-map>
</client-only>
<empty v-else icon="alert" :message="$t('map.alertMessage')" />
@ -57,7 +56,7 @@
<!-- eslint-disable vue/no-reserved-component-names -->
<script>
import { isEmpty, toArray } from 'lodash'
import { isEmpty } from 'lodash'
import mapboxgl from 'mapbox-gl'
import MapboxGeocoder from '@mapbox/mapbox-gl-geocoder'
import '@mapbox/mapbox-gl-geocoder/dist/mapbox-gl-geocoder.css'
@ -66,7 +65,6 @@ import { profileUserQuery } from '~/graphql/User'
import { mapQuery } from '~/graphql/MapQuery'
import mobile from '~/mixins/mobile'
import Empty from '~/components/Empty/Empty'
import MapStylesButtons from '~/components/Map/MapStylesButtons'
const maxMobileWidth = 639 // on this width and smaller the mapbox 'MapboxGeocoder' search gets bigger
@ -75,7 +73,6 @@ export default {
mixins: [mobile(maxMobileWidth)],
components: {
Empty,
MapStylesButtons,
},
head() {
return {
@ -87,6 +84,7 @@ export default {
return {
isEmpty,
mapboxgl,
legendOpen: false,
activeStyle: null,
defaultCenter: [10.452764, 51.165707], // center of Germany: https://www.gpskoordinaten.de/karte/land/DE
currentUserLocation: null,
@ -140,12 +138,18 @@ export default {
}
},
async mounted() {
this.updateMapPosition()
window.addEventListener('resize', this.updateMapPosition)
this.currentUserLocation = await this.getUserLocation(this.currentUser.id)
this.currentUserCoordinates = this.currentUserLocation
? this.getCoordinates(this.currentUserLocation)
: null
this.addMarkersOnCheckPrepared()
},
beforeDestroy() {
window.removeEventListener('resize', this.updateMapPosition)
},
computed: {
...mapGetters({
currentUser: 'auth/user',
@ -160,9 +164,6 @@ export default {
this.posts
)
},
styles() {
return toArray(this.availableStyles)
},
availableStyles() {
// https://docs.mapbox.com/api/maps/styles/
const availableStyles = {
@ -181,7 +182,7 @@ export default {
url: 'mapbox://styles/mapbox/dark-v10?optimize=true',
},
}
Object.keys(availableStyles).map((key) => {
Object.keys(availableStyles).forEach((key) => {
availableStyles[key].title = this.$t('map.styles.' + key)
})
return availableStyles
@ -211,6 +212,19 @@ export default {
},
},
methods: {
updateMapPosition() {
const navbar = document.getElementById('navbar')
const footer = document.getElementById('footer')
const el = this.$el
if (navbar) {
el.style.top = navbar.offsetHeight + 'px'
}
if (footer && window.getComputedStyle(footer).display !== 'none') {
el.style.bottom = footer.offsetHeight + 'px'
} else {
el.style.bottom = '0px'
}
},
onMapLoad({ map }) {
this.map = map
@ -230,85 +244,203 @@ export default {
accessToken: this.$env.MAPBOX_TOKEN,
mapboxgl: this.mapboxgl,
marker: false,
collapsed: window.innerWidth <= 810,
}),
'top-right',
)
// example for popup: https://docs.mapbox.com/mapbox-gl-js/example/popup-on-hover/
// add style switcher control
let closePopoverHandler = null
const styleSwitcher = {
onAdd: () => {
const container = document.createElement('div')
container.className = 'mapboxgl-ctrl map-style-switcher'
// Icon button (layers icon as SVG)
const styleLabel = this.$t('map.styles.title') || 'Map style'
const toggle = document.createElement('button')
toggle.type = 'button'
toggle.className = 'map-style-switcher-toggle'
toggle.title = styleLabel
toggle.setAttribute('aria-label', styleLabel)
toggle.setAttribute('aria-expanded', 'false')
toggle.innerHTML =
'<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor" aria-hidden="true">' +
'<path d="M11.99 18.54l-7.37-5.73L3 14.07l9 7 9-7-1.63-1.27-7.38 5.74zM12 16l7.36-5.73L21 9l-9-7-9 7 1.63 1.27L12 16z"/>' +
'</svg>'
toggle.addEventListener('click', (e) => {
e.stopPropagation()
const isOpen = popover.classList.toggle('map-style-popover--open')
toggle.setAttribute('aria-expanded', String(isOpen))
})
container.appendChild(toggle)
// Popover with style options
const popover = document.createElement('div')
popover.className = 'map-style-popover'
popover.setAttribute('role', 'listbox')
popover.setAttribute('aria-label', styleLabel)
Object.entries(this.availableStyles).forEach(([key, style]) => {
const btn = document.createElement('button')
btn.type = 'button'
btn.title = style.title
btn.textContent = style.title
btn.className = 'map-style-popover-btn'
btn.setAttribute('role', 'option')
if (this.mapOptions.style === style.url) {
btn.classList.add('map-style-popover-btn--active')
btn.setAttribute('aria-selected', 'true')
}
btn.addEventListener('click', (e) => {
e.stopPropagation()
this.setStyle(style.url)
popover.querySelectorAll('.map-style-popover-btn').forEach((b) => {
b.classList.remove('map-style-popover-btn--active')
b.setAttribute('aria-selected', 'false')
})
btn.classList.add('map-style-popover-btn--active')
btn.setAttribute('aria-selected', 'true')
popover.classList.remove('map-style-popover--open')
toggle.setAttribute('aria-expanded', 'false')
})
popover.appendChild(btn)
})
container.appendChild(popover)
// Close popover when clicking elsewhere on the map
closePopoverHandler = () => {
popover.classList.remove('map-style-popover--open')
toggle.setAttribute('aria-expanded', 'false')
}
this.map.getContainer().addEventListener('click', closePopoverHandler)
return container
},
onRemove: () => {
if (closePopoverHandler) {
this.map.getContainer().removeEventListener('click', closePopoverHandler)
closePopoverHandler = null
}
},
}
this.map.addControl(styleSwitcher, 'top-right')
// create a popup, but don't add it to the map yet
this.markers.popup = new mapboxgl.Popup({
closeButton: false,
closeButton: true,
closeOnClick: true,
maxWidth: '300px',
})
this.map.on('mouseenter', 'markers', (e) => {
// if (e.features[0].properties.type !== 'theUser') {}
// show popup for given features at coordinates
const showPopup = (features, lngLat) => {
if (this.popupOnLeaveTimeoutId) {
clearTimeout(this.popupOnLeaveTimeoutId)
this.popupOnLeaveTimeoutId = null
}
if (this.markers.popup.isOpen()) {
this.map.getCanvas().style.cursor = ''
this.markers.popup.remove()
}
// Change the cursor style as a UI indicator.
this.map.getCanvas().style.cursor = 'pointer'
// Copy coordinates array.
const coordinates = e.features[0].geometry.coordinates.slice()
const markerTypeLabel = this.$t(`map.markerTypes.${e.features[0].properties.type}`)
const markerProfile = {
theUser: {
linkTitle: '@' + e.features[0].properties.slug,
link: `/profile/${e.features[0].properties.id}/${e.features[0].properties.slug}`,
},
user: {
linkTitle: '@' + e.features[0].properties.slug,
link: `/profile/${e.features[0].properties.id}/${e.features[0].properties.slug}`,
},
group: {
linkTitle: '&' + e.features[0].properties.slug,
link: `/groups/${e.features[0].properties.id}/${e.features[0].properties.slug}`,
},
event: {
linkTitle: e.features[0].properties.slug,
link: `/post/${e.features[0].properties.id}/${e.features[0].properties.slug}`,
},
}
const markerProfileLinkTitle = markerProfile[e.features[0].properties.type].linkTitle
const markerProfileLink = markerProfile[e.features[0].properties.type].link
let description = `
<div>
<div>
<b>${e.features[0].properties.name}</b> <i>(${markerTypeLabel})</i>
</div>
<div>
<a href="${markerProfileLink}" target="_blank">${markerProfileLinkTitle}</a>
</div>
</div>
`
description +=
e.features[0].properties.description && e.features[0].properties.description.length > 0
? `
<hr>
<div>
${e.features[0].properties.description}
</div>`
: ''
const coordinates = features[0].geometry.coordinates.slice()
// Ensure that if the map is zoomed out such that multiple
// copies of the feature are visible, the popup appears
// over the copy being pointed to.
while (Math.abs(e.lngLat.lng - coordinates[0]) > 180) {
coordinates[0] += e.lngLat.lng > coordinates[0] ? 360 : -360
// Ensure popup appears over the correct copy when map is zoomed out
while (Math.abs(lngLat.lng - coordinates[0]) > 180) {
coordinates[0] += lngLat.lng > coordinates[0] ? 360 : -360
}
// Populate the popup and set its coordinates
// based on the feature found.
this.markers.popup.setLngLat(coordinates).setHTML(description).addTo(this.map)
// Build popup content safely using DOM nodes (no raw HTML interpolation)
const container = document.createElement('div')
container.className = 'map-popup-container'
const locationName = features[0].properties.locationName
if (locationName) {
const header = document.createElement('div')
header.className = 'map-popup-header'
header.textContent = locationName
container.appendChild(header)
}
const body = document.createElement('div')
body.className = 'map-popup-body'
features.forEach((feature, index) => {
if (index > 0) {
body.appendChild(document.createElement('hr'))
}
const markerTypeLabel = this.$t(`map.markerTypes.${feature.properties.type}`)
const markerProfile = {
theUser: {
linkTitle: '@' + feature.properties.slug,
link: `/profile/${feature.properties.id}/${feature.properties.slug}`,
},
user: {
linkTitle: '@' + feature.properties.slug,
link: `/profile/${feature.properties.id}/${feature.properties.slug}`,
},
group: {
linkTitle: '&' + feature.properties.slug,
link: `/groups/${feature.properties.id}/${feature.properties.slug}`,
},
event: {
linkTitle: feature.properties.slug,
link: `/post/${feature.properties.id}/${feature.properties.slug}`,
},
}
const profile = markerProfile[feature.properties.type]
const item = document.createElement('div')
const nameRow = document.createElement('div')
const nameB = document.createElement('b')
nameB.textContent = feature.properties.name
const typeI = document.createElement('i')
typeI.textContent = ` (${markerTypeLabel})`
nameRow.appendChild(nameB)
nameRow.appendChild(typeI)
item.appendChild(nameRow)
const linkRow = document.createElement('div')
const link = document.createElement('a')
link.href = profile.link
link.target = '_blank'
link.rel = 'noopener noreferrer'
link.textContent = profile.linkTitle
linkRow.appendChild(link)
item.appendChild(linkRow)
body.appendChild(item)
if (feature.properties.description && feature.properties.description.length > 0) {
const desc = document.createElement('div')
desc.style.marginTop = '4px'
desc.textContent = feature.properties.description
body.appendChild(desc)
}
})
container.appendChild(body)
this.markers.popup.setLngLat(coordinates).setDOMContent(container).addTo(this.map)
}
// Query all features at the clicked/hovered point
const getFeaturesAtPoint = (point) => {
return this.map.queryRenderedFeatures(point, { layers: ['markers'] })
}
// Desktop: show popup on hover
this.map.on('mouseenter', 'markers', (e) => {
const features = getFeaturesAtPoint(e.point)
if (features.length > 0) {
showPopup(features, e.lngLat)
}
})
this.map.on('mouseleave', 'markers', (e) => {
this.map.on('mouseleave', 'markers', () => {
if (this.markers.popup.isOpen()) {
this.popupOnLeaveTimeoutId = setTimeout(() => {
this.map.getCanvas().style.cursor = ''
@ -317,6 +449,15 @@ export default {
}
})
// Mobile: show popup on click/tap
this.map.on('click', 'markers', (e) => {
const features = getFeaturesAtPoint(e.point)
if (features.length > 0) {
showPopup(features, e.lngLat)
e.originalEvent.stopPropagation()
}
})
this.loadMarkersIconsAndAddMarkers()
},
language(map) {
@ -370,6 +511,7 @@ export default {
id: user.id,
slug: user.slug,
name: user.name,
locationName: user.location.name,
description: user.about ? user.about : undefined,
},
geometry: {
@ -390,6 +532,7 @@ export default {
id: this.currentUser.id,
slug: this.currentUser.slug,
name: this.currentUser.name,
locationName: this.currentUserLocation.name,
description: this.currentUser.about ? this.currentUser.about : undefined,
},
geometry: {
@ -409,6 +552,7 @@ export default {
id: group.id,
slug: group.slug,
name: group.name,
locationName: group.location.name,
description: group.about ? group.about : undefined,
},
geometry: {
@ -428,6 +572,7 @@ export default {
id: post.id,
slug: post.slug,
name: post.title,
locationName: post.eventLocation.name,
description: this.$filters.removeHtml(post.content),
},
geometry: {
@ -437,6 +582,32 @@ export default {
})
})
// Nudge markers of different types sharing the same coordinates
const coordGroups = {}
this.markers.geoJSON.forEach((feature) => {
const key = feature.geometry.coordinates.join(',')
if (!coordGroups[key]) coordGroups[key] = []
coordGroups[key].push(feature)
})
const lngOffset = 0.0002 // small longitude offset (~15m at mid-latitudes)
Object.values(coordGroups).forEach((group) => {
// Deduplicate by type only offset distinct types
const uniqueTypes = [...new Set(group.map((f) => f.properties.type))]
if (uniqueTypes.length <= 1) return
const totalWidth = (uniqueTypes.length - 1) * lngOffset
uniqueTypes.forEach((type, index) => {
const offset = -totalWidth / 2 + index * lngOffset
group
.filter((f) => f.properties.type === type)
.forEach((feature) => {
feature.geometry.coordinates = [
feature.geometry.coordinates[0] + offset,
feature.geometry.coordinates[1],
]
})
})
})
this.markers.isGeoJSON = true
}
@ -542,15 +713,15 @@ export default {
@import 'v-mapbox/dist/v-mapbox.css';
.map-page {
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
display: flex;
flex-direction: column;
height: calc(100vh - 4rem - #{$space-x-small});
overflow: hidden;
}
.map-header {
flex-shrink: 0;
padding: 0 0 $space-xx-small;
z-index: 1;
}
.mgl-map-wrapper {
@ -558,9 +729,230 @@ export default {
min-height: 0;
}
@media (min-width: 811px) {
.map-page {
height: calc(100vh - 6rem - 8rem);
.mgl-map-wrapper {
overflow: hidden;
}
.mapboxgl-popup-content {
max-height: 40vh;
overflow: hidden;
padding: 10px;
}
.map-popup-container {
display: flex;
flex-direction: column;
max-height: calc(40vh - 20px);
overflow: hidden;
}
.mapboxgl-popup-close-button {
font-size: 1.2rem;
padding: 2px 6px;
z-index: 1;
}
.map-popup-header {
font-weight: bold;
font-size: 1.1em;
margin-bottom: 4px;
padding-right: 16px;
flex-shrink: 0;
}
.map-popup-body {
overflow-y: auto;
flex: 1;
min-height: 0;
}
// Smaller geocoder on mobile (expanded)
@media (max-width: 810px) {
.mapboxgl-ctrl-geocoder {
font-size: 13px;
line-height: 18px;
min-width: 180px;
max-width: 240px;
}
.mapboxgl-ctrl-geocoder--input {
height: 29px;
padding: 4px 28px;
}
.mapboxgl-ctrl-geocoder--icon-search {
width: 15px;
height: 15px;
top: 7px;
left: 7px;
}
.mapboxgl-ctrl-geocoder--button {
width: 22px;
height: 22px;
top: 4px;
right: 4px;
}
.mapboxgl-ctrl-geocoder--icon-close {
width: 14px;
height: 14px;
}
.mapboxgl-ctrl-geocoder.mapboxgl-ctrl-geocoder--collapsed {
width: 29px;
height: 29px;
min-width: 29px;
background-color: white;
border-radius: 4px;
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.1);
overflow: hidden;
.mapboxgl-ctrl-geocoder--icon-search {
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 17px;
height: 17px;
}
.mapboxgl-ctrl-geocoder--input {
display: none;
}
.mapboxgl-ctrl-geocoder--pin-right > * {
display: none;
}
}
}
.map-legend {
position: absolute;
bottom: 30px;
left: 10px;
background: rgba(255, 255, 255, 0.75);
backdrop-filter: blur(4px);
border-radius: 4px;
z-index: 1;
font-size: 0.8rem;
color: $color-neutral-10;
}
.map-legend-toggle {
display: none;
width: 100%;
padding: 4px 8px;
border: none;
background: rgba(0, 0, 0, 0.05);
color: $color-neutral-10;
border-radius: 4px;
cursor: pointer;
font-size: 0.8rem;
text-align: left;
order: 1;
&:hover,
&:active {
background: rgba(0, 0, 0, 0.1);
}
}
.map-legend-arrow {
float: right;
font-size: 0.7rem;
}
.map-legend-content {
padding: 4px 8px 2px;
}
.map-legend-item {
display: flex;
align-items: center;
gap: 4px;
}
@media (max-width: 639px) {
.map-legend {
display: flex;
flex-direction: column;
}
.map-legend-toggle {
display: block;
}
.map-legend-content {
order: 0;
}
.map-legend--open .map-legend-content {
border-bottom: 1px solid #eee;
}
}
.map-style-switcher {
position: relative;
background: white;
border-radius: 4px;
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.1);
}
.map-style-switcher-toggle {
display: flex;
align-items: center;
justify-content: center;
width: 29px;
height: 29px;
padding: 0;
border: none;
background: none;
cursor: pointer;
color: #333;
&:hover {
background: rgba(0, 0, 0, 0.05);
}
}
.map-style-popover {
display: none;
position: absolute;
top: 0;
right: 100%;
margin-right: 6px;
background: white;
border-radius: 4px;
box-shadow: 0 0 4px rgba(0, 0, 0, 0.3);
white-space: nowrap;
overflow: hidden;
&--open {
display: block;
}
}
.map-style-popover-btn {
display: block;
width: 100%;
padding: 6px 12px;
border: none;
background: none;
cursor: pointer;
font-size: 0.8rem;
text-align: left;
&:not(:last-child) {
border-bottom: 1px solid #eee;
}
&:hover {
background: rgba(0, 0, 0, 0.05);
}
&--active {
font-weight: bold;
background: rgba(0, 0, 0, 0.08);
}
}
</style>