// useSelectedPin.js - Selected Pin Highlight + Animated GO Marker // When a people group is selected, turns the dot mandarin neon orange and // floats an animated "GO" location pin above it with a pulsing light glow. import { watch, inject } from 'vue'; import { emissivePaint, isMapboxMap, engineFor } from './useMapProvider.js'; // ───────────────────────────────────────────── // Constants // ───────────────────────────────────────────── const GO_ORANGE = '#1a1a1a'; // Black indicator (was '#FF6600' — qa: 2026-05-06 user wants simple black/white pin) const HIGHLIGHT_LAYER_ID = 'selected-pin-highlight'; /** * Create the custom HTML element for the "GO" map pin marker. * Self-contained inline `; return el; } /** * useSelectedPin composable * * @param {Object} options * @param {Function} options.getMap - Returns the mapboxgl.Map instance (or null) */ export function useSelectedPin(options = {}) { const { getMap = () => null, getActivePinColor: getActivePinColorOpt = null } = options; const uiStore = inject('uiStore'); let _markerEl = null; let _marker = null; // Dynamic pin color (qa: 2026-05-06 — pin matches the active tab's // dot color so a click on a green dot shows a green pin, red shows red, // etc.). Falls back to the literal GO_ORANGE constant if the parent // profile didn't provide a resolver. // Prefer the resolver passed in via options (from the profile's setup). A Vue 3 // component CANNOT inject('getActivePinColor') against its OWN provide — inject // resolves against the parent/app provide chain, never the instance's own provides // — and no ancestor (ProfileLoader) supplies this key, so the inject below always // returned null. That made _currentColor fall through to GO_ORANGE ('#1a1a1a', // black) on every selection (the dot/marker rendered black). Passing the resolver // as an option (like getMap) is the only path that actually resolves at runtime; // inject is kept purely as a no-op fallback for callers that don't pass it. const getActivePinColor = getActivePinColorOpt ?? inject("getActivePinColor", null); let _currentColor = GO_ORANGE; function _addHighlightLayer() { const map = getMap(); if (!map) return; if (map.getLayer(HIGHLIGHT_LAYER_ID)) return; if (!map.getSource('language-families')) return; map.addLayer({ id: HIGHLIGHT_LAYER_ID, type: 'circle', source: 'language-families', paint: { 'circle-radius': [ 'interpolate', ['linear'], ['zoom'], 0, 3, 2, 3.5, 4, 4, 5, 5, 6, 6.5, 7, 8, 8, 10, 10, 14, 12, 18, 14, 22 ], 'circle-color' : _currentColor, // Mapbox Standard v3 lights custom circle layers by the lightPreset; WITHOUT an // emissive strength the circle is modulated by the scene light and gets darkened // to near-black (night preset, and the not-yet-settled boot-time lighting right // after a page reload). Setting it to 1 makes the selected-pin highlight render // its OWN colour identically in day + night. Mirror of language-family-pins // (useMapLayers.js:248) — without this the selected dot turned black on first // click after a reload until a tab switch re-lit the layer. ...emissivePaint(map), 'circle-opacity' : 1, 'circle-stroke-width' : 2, 'circle-stroke-color' : '#FFFFFF', 'circle-stroke-opacity': 1 }, filter: ['==', ['get', 'uniqueId'], '__none__'] }); } function _ensureMarkerEl() { // The floating GO / popup-indicator "plan" pin is ALWAYS black (coder wants the // indicator pin black). ONLY the on-map selected-DOT highlight circle follows the // active strategy colour (_currentColor, set in updateSelectedPin) — the marker // deliberately does NOT, so it stays black regardless of the selected dot's colour. if (!_markerEl) { _markerEl = createGoMarkerElement(GO_ORANGE); } } function _showMarker(lng, lat) { const map = getMap(); if (!map) return; _ensureMarkerEl(); if (_marker) { _marker.remove(); } // anchor:'bottom' pins the marker's bottom edge to the lat/lng. // After the wrapper resize to match the SVG (43px), the bottom edge // IS the pin's tip — no offset compensation needed. // engineFor, not the bare `mapboxgl` global: under MapLibre there may be no Mapbox global _marker = new (engineFor(map).Marker)({ element : _markerEl, anchor : 'bottom', offset : [0, 0] }) .setLngLat([lng, lat]) .addTo(map); } function _hideMarker() { if (_marker) { _marker.remove(); _marker = null; } } function updateSelectedPin(feature) { const map = getMap(); if (!map) return; // Resolve the pin color from the active tab strategy each call — but ONLY when // there is a feature. On DESELECT (feature == null, e.g. closing the detail modal) // the highlight is hidden below, so the colour is irrelevant; calling the strategy // resolver with undefined properties throws (getPrayerLevel reads // properties.peoplePraying), and that thrown error aborts the reactive update, // which is what left the people-group detail modal unable to close (its X handler // sets selectedPeopleGroup=null → this watcher → crash). Guarding here fixes that. if (feature) { _currentColor = getActivePinColor?.(feature.properties) ?? GO_ORANGE; } // Ensure the layer exists, then ALWAYS (re)assert BOTH the colour and the // emissive strength on every selection. circle-emissive-strength:1 is // re-asserted defensively here (not only in _addHighlightLayer) because the // layer is first added at boot under not-yet-settled Standard lighting; the // first click after a page reload could otherwise render the emissive-less // circle near-black until a tab switch re-lit it. Re-asserting on every // selection makes the highlight show its own colour regardless of when the // style finished loading. (selected-dot-turns-black-on-first-click fix.) _addHighlightLayer(); if (map.getLayer(HIGHLIGHT_LAYER_ID)) { map.setPaintProperty(HIGHLIGHT_LAYER_ID, "circle-color", _currentColor); // Mapbox-only lighting prop — MapLibre logs an error for unknown paint props. if (isMapboxMap(map)) map.setPaintProperty(HIGHLIGHT_LAYER_ID, "circle-emissive-strength", 1); } if (!feature) { if (map.getLayer(HIGHLIGHT_LAYER_ID)) { map.setFilter(HIGHLIGHT_LAYER_ID, ['==', ['get', 'uniqueId'], '__none__']); } _hideMarker(); return; } const uniqueId = feature.properties?.uniqueId || '__none__'; const coords = feature.geometry?.coordinates; if (!coords) { return; } if (map.getLayer(HIGHLIGHT_LAYER_ID)) { map.setFilter(HIGHLIGHT_LAYER_ID, ['==', ['get', 'uniqueId'], uniqueId]); } const [lng, lat] = coords; _showMarker(lng, lat); } watch( () => uiStore.selectedPeopleGroup, (newFeature) => { updateSelectedPin(newFeature); } ); function initialize() { _addHighlightLayer(); } function cleanup() { _hideMarker(); _markerEl = null; } return { initialize, cleanup, updateSelectedPin }; }