// useMapEvents.js - Map Event Handlers Composable
// Handles click handlers, popups, cursor changes, and hover effects
// Vue 3 Composition API - ES Module with NPM packages
// Import Vue functions from NPM package
import { ref, inject } from 'vue';
import { useI18n } from 'vue-i18n';
import { engineFor } from './useMapProvider.js';
/**
* useMapEvents composable - manages Mapbox layer event handlers
*
* @param {Object} options - Configuration options
* @param {Function} options.getMap - Function that returns the map instance
* @param {string} options.mapId - Unique map identifier for logging
* @param {Function} options.getLanguageFamilyColor - Color lookup function for language families
*
* @returns {Object} Event management functions
*/
export function useMapEvents(options = {}) {
const {
getMap = () => null,
mapId = 'unknown',
getLanguageFamilyColor = () => '#999999',
getNormalizedPeopleGroups = () => []
} = options;
// ── CRITICAL: capture store reference NOW, during Vue setup context ──────
// inject('uiStore') resolves to THIS web-component instance's Pinia store,
// provided by ProfileLoader.vue which runs inside the correct Vue app.
// Using inject() here (synchronously during setup) guarantees isolation
// when multiple map elements share a page.
const uiStore = inject('uiStore');
// ── i18n ─────────────────────────────────────────────────────────────────
// Locale parity (feedback #8 /fr/research): the country-click detail popup
// (WAGF Region / Block / Country / UUPG count) was hardcoded English, so on
// /fr the country geography click rendered an all-English card. Route the
// labels through i18n so every locale behaves identically. Missing-key
// behavior falls back to English (see src/i18n/index.js), so locales without
// a `popup` catalog keep the prior English text rather than breaking.
const { t, locale } = useI18n();
// Track registered event handlers for cleanup
const registeredHandlers = ref([]);
/**
* Generate popup content for a people group pin
* @param {Object} properties - Feature properties
* @returns {string} HTML content
*/
function generatePinPopupContent(properties) {
const familyColor = getLanguageFamilyColor(properties.languageFamily);
// Get image URL - use imageUrl from data, fallback to placeholder
const placeholderImage = "https://www.peoplegroups.org/images/pgphotosearch/NoImageAvailable_search.jpg";
let imageUrl = properties.imageUrl;
if (!imageUrl || imageUrl.includes('NoImageAvailable')) {
imageUrl = placeholderImage;
}
// Generate avatar based on first letter
const firstLetter = (properties.name || 'U')[0].toUpperCase();
const avatarColors = [
'#e74c3c', '#3498db', '#2ecc71', '#f39c12',
'#9b59b6', '#1abc9c', '#e67e22', '#34495e'
];
const colorIndex = firstLetter.charCodeAt(0) % avatarColors.length;
const avatarColor = avatarColors[colorIndex];
// TODO: domain-specific — `https://uupg.doxa.life/people-groups/${properties.rop3}`
// Override the "Learn more" URL via a profile-level callback if you need a different host.
return `
${firstLetter}
${properties.name}
${properties.country || 'Unknown'} • ${properties.status || 'Unknown Status'}
Language: ${properties.language || 'Unknown'}
Family:
${properties.languageFamily || 'Unknown'}
${properties.population ? `
Population: ${properties.population}
` : ''}
${properties.description ? `
${properties.description}
` : ''}
`;
}
/**
* Generate popup content for a region/country
* @param {Object} properties - Feature properties
* @returns {string} HTML content
*/
function generateRegionPopupContent(tileProps) {
const iso3 = tileProps.iso_3166_1_alpha_3 || ''
// Prefer the active locale's tile name field when present, then the
// canonical English name, then the bare name, then the ISO code. The
// country-boundaries tileset may not carry a per-locale name for every
// country, so the coalesce keeps the popup populated in every locale.
const localeName = tileProps['name_' + (locale?.value || 'en')]
const countryName = localeName || tileProps.name_en || tileProps.name || iso3
const pgs = getNormalizedPeopleGroups()
// Match by countryIso (pray-tools) OR country (doxa-api uses `country` for ISO alpha-3)
const iso3Upper = iso3.toUpperCase()
const matching = pgs.filter(pg => {
const pgIso = (pg.countryIso || pg.country || '').toUpperCase()
return pgIso && pgIso === iso3Upper
})
const uupgCount = matching.length
const first = matching[0]
const wagfRegion = first?.wagfRegionLabel || first?.doxaRegionLabel || first?.doxaRegion || first?.wagfRegion || '—'
const wagfBlock = first?.wagfBlockLabel || first?.wagfBlock || '—'
return `
${countryName}
${t('popup.country.wagfRegion')}: ${wagfRegion}
${t('popup.country.wagfBlock')}: ${wagfBlock}
${t('popup.country.country')}: ${countryName} (${iso3})
${t('popup.country.uupgCount')}: ${uupgCount}
`;
}
/**
* Attach click handler for language family pins
* Shows popup with people group details
*/
function attachPinClickHandler(overrideLayerId) {
const map = getMap();
if (!map) return;
const layerId = overrideLayerId || 'language-family-pins';
if (!map.getLayer(layerId)) {
return;
}
const handler = (e) => {
try {
const feature = e.features[0];
const properties = feature.properties;
const coordinates = feature.geometry.coordinates.slice();
// Ensure popup appears even if wrapped coordinates
while (Math.abs(e.lngLat.lng - coordinates[0]) > 180) {
coordinates[0] += e.lngLat.lng > coordinates[0] ? 360 : -360;
}
// Route pin click to legend (prayer mode) — show people group detail in side panel
if (uiStore && uiStore.legendMode !== undefined) {
// Select people group → switches legend to detail mode on both mobile and desktop
uiStore.selectPeopleGroup(feature);
// On mobile: expand legend to 40% if currently collapsed
if (uiStore.isMobile && uiStore.legendState === 'collapsed') {
uiStore.setLegendState('open');
}
// On desktop: if legend is collapsed, open it
if (!uiStore.isMobile) {
// Signal via custom event that the desktop legend can listen to
window.dispatchEvent(new CustomEvent('dxm:openDesktopLegend', { detail: { mapId } }));
}
return; // Don't show popup — info is in the legend panel
}
// Fallback: show popup (for non-prayer legend types)
new (engineFor(map).Popup)({
maxWidth: '320px',
className: 'people-group-popup'
})
.setLngLat(coordinates)
.setHTML(generatePinPopupContent(properties))
.addTo(map);
} catch (err) {
console.error(` [${mapId}] Pin click handler error:`, err);
}
};
map.on('click', layerId, handler);
registeredHandlers.value.push({ event: 'click', layer: layerId, handler });
}
/**
* Attach click handler for regions layer
* Shows popup with country/region details
*/
function attachRegionClickHandler() {
const map = getMap();
if (!map) return;
const layerId = 'regions-fill';
if (!map.getLayer(layerId)) {
return;
}
const handler = (e) => {
// Check if we clicked on a people group pin - if so, don't show country popup
const pinLayers = ['language-family-pins', 'language-family-pins-hitbox', 'people-groups-pins'];
for (const pinLayer of pinLayers) {
if (map.getLayer(pinLayer)) {
const pinFeatures = map.queryRenderedFeatures(e.point, { layers: [pinLayer] });
if (pinFeatures && pinFeatures.length > 0) {
// There's a pin at this location, don't show country popup
return;
}
}
}
if (e.features && e.features.length > 0) {
const properties = e.features[0].properties;
new (engineFor(map).Popup)()
.setLngLat(e.lngLat)
.setHTML(generateRegionPopupContent(properties))
.addTo(map);
}
};
map.on('click', layerId, handler);
registeredHandlers.value.push({ event: 'click', layer: layerId, handler });
}
/**
* Attach click handler for cluster expansion
*/
function attachClusterClickHandler() {
const map = getMap();
if (!map) return;
const layerId = 'clusters';
if (!map.getLayer(layerId)) {
// Clusters layer may not exist - that's ok
return;
}
const handler = (e) => {
const features = map.queryRenderedFeatures(e.point, { layers: [layerId] });
if (!features.length) return;
const clusterId = features[0].properties.cluster_id;
const sourceId = features[0].source;
map.getSource(sourceId).getClusterExpansionZoom(
clusterId,
(err, zoom) => {
if (err) return;
map.easeTo({
center: features[0].geometry.coordinates,
zoom: zoom
});
}
);
};
map.on('click', layerId, handler);
registeredHandlers.value.push({ event: 'click', layer: layerId, handler });
}
/**
* Attach cursor change handlers for a layer
*/
function attachCursorHandlers(layerId) {
const map = getMap();
if (!map || !map.getLayer(layerId)) return;
const enterHandler = () => {
map.getCanvas().style.cursor = 'pointer';
};
const leaveHandler = () => {
map.getCanvas().style.cursor = '';
};
map.on('mouseenter', layerId, enterHandler);
map.on('mouseleave', layerId, leaveHandler);
registeredHandlers.value.push({ event: 'mouseenter', layer: layerId, handler: enterHandler });
registeredHandlers.value.push({ event: 'mouseleave', layer: layerId, handler: leaveHandler });
}
/**
* Attach all event handlers for language family layer
*/
function attachLanguageFamilyEvents() {
attachPinClickHandler();
attachCursorHandlers('language-family-pins');
attachPinClickHandler('language-family-pins-hitbox');
attachCursorHandlers('language-family-pins-hitbox');
attachClusterClickHandler();
attachCursorHandlers('clusters');
}
/**
* Attach all event handlers for regions layer
*/
function attachRegionEvents() {
attachRegionClickHandler();
attachCursorHandlers('regions-fill');
}
/**
* Attach all standard event handlers
* Call this after layers are added
*/
function attachAllEventHandlers() {
const map = getMap();
if (!map) {
return;
}
// Language family pins
if (map.getLayer('language-family-pins')) {
attachLanguageFamilyEvents();
}
// Regions
if (map.getLayer('regions-fill')) {
attachRegionEvents();
}
// People groups pins (alternate layer name)
if (map.getLayer('people-groups-pins')) {
attachPinClickHandler(); // Reuse same popup logic
attachCursorHandlers('people-groups-pins');
}
}
/**
* Remove all registered event handlers
* Call this on component unmount or when re-adding layers
*/
function detachAllEventHandlers() {
const map = getMap();
if (!map) return;
for (const { event, layer, handler } of registeredHandlers.value) {
try {
map.off(event, layer, handler);
} catch (err) {
// Layer might have been removed already
}
}
registeredHandlers.value = [];
}
return {
// Event attachment
attachAllEventHandlers,
attachLanguageFamilyEvents,
attachRegionEvents,
attachPinClickHandler,
attachRegionClickHandler,
attachClusterClickHandler,
attachCursorHandlers,
// Cleanup
detachAllEventHandlers,
// Popup content generators (for customization)
generatePinPopupContent,
generateRegionPopupContent,
// State
registeredHandlers
};
}
// ES Module export