import React, { useEffect, useRef, useState, useCallback, useId } from 'react'; import maplibregl from 'maplibre-gl'; import 'maplibre-gl/dist/maplibre-gl.css'; import type { MapMouseEvent, Map as MapLibreMap, Marker as MapLibreMarker } from 'maplibre-gl'; import { getDigiPin, getLatLngFromDigiPin, formatDigiPin, isWithinIndia } from '../core/digipin'; import { autocompleteAddress, looksLikeDigiPin, couldBeDigiPinInput } from '../core/api'; import type { AutocompleteSuggestion } from '../core/api'; import { useGeolocation } from '../hooks/useGeolocation'; import type { MapPinSelectorProps } from '../core/types'; import { DEFAULT_MAP_STYLE_URL, waitForExternalMapRuntime, type MapRuntime, } from '../core/map-runtime'; // ─── Constants ─────────────────────────────────────────────────────────────── /** India's geographic center – shown when no default location is given */ const INDIA_CENTER = { lat: 13.004270, lng: 77.589291 }; const MIN_ZOOM = 6; // users cannot zoom out beyond a useful India-level view const OVERVIEW_ZOOM = 9; const STREET_ZOOM = 18; type MapLoadState = 'loading' | 'ready' | 'unavailable' | 'error'; // ─── Pin dimensions ─────────────────────────────────────────────────────────── // MUST match the SVG width/height exactly. // MapLibre reads element.offsetWidth/offsetHeight to compute `anchor:'bottom'`. // The pin tip lives at SVG coordinate (20, 52) = bottom-center of the element. const PIN_W = 40; const PIN_H = 52; // ─── Marker helper ─────────────────────────────────────────────────────────── function computeDigiPinSafe(la: number, lo: number): string | null { if (!isWithinIndia(la, lo)) return null; try { return getDigiPin(la, lo); } catch { return null; } } const DEFAULT_BRAND = '#0ea5e9'; function createPinElement(brandColor: string = DEFAULT_BRAND): HTMLDivElement { const el = document.createElement('div'); el.className = 'qr-pin'; el.setAttribute('aria-label', 'Drag to adjust your location'); // ─ Set explicit inline dimensions ───────────────────────────────────────── // This is critical: without them, the browser may add inline-baseline // whitespace below the tag, making offsetHeight > PIN_H and causing // the anchor='bottom' point to land below the actual pin tip. el.style.width = `${PIN_W}px`; el.style.height = `${PIN_H}px`; el.innerHTML = ` `; return el; } function collapseCompactAttribution(container: HTMLElement | null): void { const attribEl = container?.querySelector( '.maplibregl-ctrl-attrib.maplibregl-compact' ); if (attribEl instanceof HTMLElement) { attribEl.classList.remove('maplibregl-compact-show'); attribEl.setAttribute('open', ''); } } function initMapWithRuntime( runtime: MapRuntime, container: HTMLDivElement, markerRef: React.MutableRefObject, options: { styleUrl: string; initLat: number; initLng: number; initZoom: number; brandColor: string; indiaBoundaryUrl?: string; refreshDigipin: (la: number, lo: number) => void; onReady: () => void; onError: (message: string) => void; } ): { map: MapLibreMap; cleanup: () => void } { const { styleUrl, initLat, initLng, initZoom, brandColor, indiaBoundaryUrl, refreshDigipin, onReady, onError, } = options; const boundaryPromise: Promise = indiaBoundaryUrl ? fetch(indiaBoundaryUrl) .then((r) => (r.ok ? r.json() : null)) .catch(() => null) : Promise.resolve(null); const map = new runtime.Map({ container, style: styleUrl, center: [initLng, initLat], zoom: initZoom, minZoom: MIN_ZOOM, attributionControl: false, touchZoomRotate: true, }); if (typeof runtime.AttributionControl === 'function') { map.addControl( new runtime.AttributionControl({ compact: true, customAttribution: '© OpenStreetMap contributors  © CARTO', }), 'bottom-right' ); } if (typeof runtime.NavigationControl === 'function') { map.addControl( new runtime.NavigationControl({ showCompass: false }), 'top-right' ); } let marker: MapLibreMarker | null = null; map.on('load', () => { collapseCompactAttribution(container); onReady(); boundaryPromise.then((geoJson) => { if (!geoJson || !map.isStyleLoaded()) return; try { map.addSource('india-boundary', { type: 'geojson', // eslint-disable-next-line @typescript-eslint/no-explicit-any data: geoJson as any, }); map.addLayer({ id: 'india-boundary-line', type: 'line', source: 'india-boundary', paint: { 'line-color': '#94a3b8', 'line-width': 1.5, 'line-opacity': 0.65, }, }); } catch { // Silent: duplicate source/layer on hot-reload is not an error } }); marker = new runtime.Marker({ element: createPinElement(brandColor), draggable: true, anchor: 'bottom', }) .setLngLat([initLng, initLat]) .addTo(map); markerRef.current = marker; marker.on('drag', () => { const pos = marker!.getLngLat(); refreshDigipin(pos.lat, pos.lng); }); marker.on('dragend', () => { const pos = marker!.getLngLat(); map.easeTo({ center: [pos.lng, pos.lat], duration: 250 }); }); }); map.on('error', () => { onError('Map tiles could not load. Please try again.'); }); map.on('click', (e: MapMouseEvent) => { const pos = e.lngLat; marker?.setLngLat([pos.lng, pos.lat]); refreshDigipin(pos.lat, pos.lng); map.easeTo({ center: [pos.lng, pos.lat], duration: 200 }); }); return { map, cleanup: () => { markerRef.current = null; marker = null; map.remove(); }, }; } // ─── Component ─────────────────────────────────────────────────────────────── const MapPinSelector: React.FC = ({ onLocationConfirm, defaultLat, defaultLng, mapHeight = '380px', theme: _theme = 'light', indiaBoundaryUrl, enableSearch = true, apiKey, apiBaseUrl, brandColor = DEFAULT_BRAND, mapStyle = DEFAULT_MAP_STYLE_URL, embedMode = 'bundled', }) => { const containerRef = useRef(null); const mapRef = useRef(null); const markerRef = useRef(null); const initLat = defaultLat ?? INDIA_CENTER.lat; const initLng = defaultLng ?? INDIA_CENTER.lng; const hasDefault = defaultLat !== undefined && defaultLng !== undefined; const [lat, setLat] = useState(initLat); const [lng, setLng] = useState(initLng); const [digipin, setDigipin] = useState(() => computeDigiPinSafe(initLat, initLng) ); const [mapLoaded, setMapLoaded] = useState(false); const [mapState, setMapState] = useState('loading'); const [mapError, setMapError] = useState(null); // ── Search state ───────────────────────────────────────────────────────── const [searchQuery, setSearchQuery] = useState(''); const [suggestions, setSuggestions] = useState([]); const [searchLoading, setSearchLoading] = useState(false); const [searchError, setSearchError] = useState(null); const [dropdownOpen, setDropdownOpen] = useState(false); const debounceRef = useRef | null>(null); const searchInputRef = useRef(null); const listboxId = useId(); const { loading: geoLoading, error: geoError, locate, clearError } = useGeolocation(); const refreshDigipin = useCallback((la: number, lo: number) => { setLat(la); setLng(lo); setDigipin(computeDigiPinSafe(la, lo)); }, []); // ── Fly map + move pin to a coord ──────────────────────────────────────── const flyToCoord = useCallback((la: number, lo: number) => { refreshDigipin(la, lo); mapRef.current?.flyTo({ center: [lo, la], zoom: STREET_ZOOM, duration: 900, essential: true }); markerRef.current?.setLngLat([lo, la]); }, [refreshDigipin]); // ── Search input change handler ────────────────────────────────────────── const handleSearchChange = useCallback((e: React.ChangeEvent) => { const value = e.target.value; setSearchQuery(value); setSearchError(null); if (debounceRef.current) clearTimeout(debounceRef.current); const trimmed = value.trim(); if (!trimmed) { setSuggestions([]); setDropdownOpen(false); setSearchLoading(false); return; } if (looksLikeDigiPin(trimmed)) { try { const coords = getLatLngFromDigiPin(trimmed); const la = parseFloat(coords.latitude); const lo = parseFloat(coords.longitude); flyToCoord(la, lo); setSuggestions([]); setDropdownOpen(false); setSearchQuery(formatDigiPin(trimmed)); } catch { setSearchError('Invalid DigiPin code'); } return; } if (couldBeDigiPinInput(trimmed)) { setSuggestions([]); setDropdownOpen(false); setSearchLoading(false); return; } if (trimmed.length < 3) { setSuggestions([]); setDropdownOpen(false); return; } setSearchLoading(true); debounceRef.current = setTimeout(async () => { try { const key = apiKey ?? ''; const results = await autocompleteAddress(trimmed, key, apiBaseUrl, 5, lat, lng); setSuggestions(results); setDropdownOpen(results.length > 0); } catch (err) { setSearchError(err instanceof Error ? err.message : 'Search failed'); setSuggestions([]); setDropdownOpen(false); } finally { setSearchLoading(false); } }, 400); }, [apiKey, apiBaseUrl, flyToCoord, lat, lng]); const handleSuggestionSelect = useCallback((s: AutocompleteSuggestion) => { flyToCoord(s.coordinates.latitude, s.coordinates.longitude); setSearchQuery(''); setSuggestions([]); setDropdownOpen(false); searchInputRef.current?.blur(); }, [flyToCoord]); const handleSearchKeyDown = useCallback((e: React.KeyboardEvent) => { if (e.key === 'Escape') { setSuggestions([]); setDropdownOpen(false); setSearchQuery(''); } }, []); // ── Map initialization ──────────────────────────────────────────────────── useEffect(() => { if (!containerRef.current) return; let cancelled = false; let cancelWait: (() => void) | null = null; let mapCleanup: (() => void) | null = null; const initZoom = hasDefault ? STREET_ZOOM : OVERVIEW_ZOOM; const container = containerRef.current; const mountMap = (runtime: MapRuntime) => { if (cancelled || !containerRef.current) return; try { const { map, cleanup } = initMapWithRuntime(runtime, container, markerRef, { styleUrl: mapStyle, initLat, initLng, initZoom, brandColor, indiaBoundaryUrl, refreshDigipin, onReady: () => { if (cancelled) return; setMapLoaded(true); setMapState('ready'); setMapError(null); }, onError: (message) => { if (cancelled) return; setMapState((state) => (state === 'ready' ? state : 'error')); setMapError(message); }, }); mapRef.current = map; mapCleanup = cleanup; } catch (err) { setMapState('error'); setMapError(err instanceof Error ? err.message : 'Map failed to initialize.'); } }; const handleUnavailable = () => { if (cancelled) return; setMapState('unavailable'); setMapError('Map could not load. Check your connection and try again.'); }; if (embedMode === 'external') { cancelWait = waitForExternalMapRuntime(mountMap, handleUnavailable); } else { mountMap(maplibregl as MapRuntime); } return () => { cancelled = true; cancelWait?.(); if (debounceRef.current) clearTimeout(debounceRef.current); markerRef.current = null; mapRef.current = null; mapCleanup?.(); }; }, [ brandColor, embedMode, hasDefault, indiaBoundaryUrl, initLat, initLng, mapStyle, refreshDigipin, ]); // ── Geolocation handler ─────────────────────────────────────────────────── const handleLocateMe = useCallback(() => { clearError(); locate((userLat, userLng) => { refreshDigipin(userLat, userLng); mapRef.current?.flyTo({ center: [userLng, userLat], zoom: STREET_ZOOM, duration: 1400, essential: true, }); markerRef.current?.setLngLat([userLng, userLat]); }); }, [locate, clearError, refreshDigipin]); const handleConfirm = useCallback(() => { if (digipin) { onLocationConfirm(lat, lng, digipin); } }, [lat, lng, digipin, onLocationConfirm]); const isInIndia = digipin !== null; const canConfirm = isInIndia && mapLoaded && mapState === 'ready'; const showMapStatus = mapState !== 'ready'; return (
1
Pin Your Location Tap the map or drag the pin to your exact home / office
{enableSearch && (
{searchLoading && (
{searchError && (

{searchError}

)} {dropdownOpen && suggestions.length > 0 && (
    {suggestions.map((s, i) => (
  • { e.preventDefault(); handleSuggestionSelect(s); }} >
    {s.displayName} {s.address && s.address !== s.displayName && ( {s.address} )}
  • ))}
)}

DigiPin Type your DigiPin code for instant offline lookup — no API call

)}
{showMapStatus && (
{mapState === 'loading' ? ( <>
)} {digipin && (
DigiPin {digipin}
)} {!isInIndia && mapLoaded && (
Move map to India to get a DigiPin
)} {geoError && (
{geoError}
)}
{mapLoaded && (
{lat.toFixed(5)}° N · {lng.toFixed(5)}° E
)}
{!isInIndia && mapLoaded && (

Move the pin to a location in India to continue

)} {mapState !== 'ready' && (

{mapState === 'loading' ? 'Loading the map before location confirmation.' : 'Map is unavailable, so location confirmation is paused.'}

)}
); }; export default MapPinSelector;