import * as L from 'leaflet'; import 'leaflet/dist/leaflet.css'; interface LocatorLocation { id: number; name: string; url: string; address: string; phone: string; lat: number | null; lng: number | null; categories: string[]; } interface Point { lat: number; lng: number; } /** The map behind the list, whichever service draws it. */ interface LocatorMap { /** Shows the pins of these locations and nobody else's, framed with the visitor when known. */ show(ids: Set, origin: Point | null): void; /** Brings one location into view with its popup open. */ focus(id: number): void; } /** The part of the Google Maps JavaScript API the locator uses. */ interface GoogleLatLng { lat(): number; lng(): number; } interface GoogleBounds { extend(point: Point | GoogleLatLng): void; } interface GoogleInfoWindow { setContent(content: string): void; open(options: { map: GoogleMap; anchor: GoogleMarker }): void; close(): void; } interface GoogleMarker { setMap(map: GoogleMap | null): void; addListener(event: string, handler: () => void): void; getPosition(): GoogleLatLng | null | undefined; } interface GoogleMap { fitBounds(bounds: GoogleBounds, padding?: number): void; setCenter(point: Point | GoogleLatLng): void; setZoom(zoom: number): void; getZoom(): number | undefined; } interface GoogleMapsApi { Map: new (element: HTMLElement, options: object) => GoogleMap; Marker: new (options: object) => GoogleMarker; InfoWindow: new (options: object) => GoogleInfoWindow; LatLngBounds: new () => GoogleBounds; } declare global { interface Window { google?: { maps: GoogleMapsApi }; recomazeMapsReady?: () => void; } } const TILES = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'; const ATTRIBUTION = '© OpenStreetMap contributors'; /** Great-circle distance in kilometres. */ const distance = (a: Point, b: Point): number => { const rad = Math.PI / 180; const dLat = (b.lat - a.lat) * rad; const dLng = (b.lng - a.lng) * rad; const h = Math.sin(dLat / 2) ** 2 + Math.cos(a.lat * rad) * Math.cos(b.lat * rad) * Math.sin(dLng / 2) ** 2; return 6371 * 2 * Math.atan2(Math.sqrt(h), Math.sqrt(1 - h)); }; const hasPoint = ( location: LocatorLocation ): location is LocatorLocation & Point => location.lat !== null && location.lng !== null; const escapeHtml = (text: string): string => text.replace( /[&<>"']/g, char => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[ char ] ?? char ); const popupHtml = (location: LocatorLocation): string => `${escapeHtml( location.name )}${ location.address ? `
${escapeHtml(location.address)}` : '' }`; /** OpenStreetMap tiles drawn by Leaflet: no key, no account. */ const leafletMap = ( element: HTMLElement, locations: (LocatorLocation & Point)[], onPick: (id: number) => void ): LocatorMap => { const map = L.map(element, { scrollWheelZoom: false }); L.tileLayer(TILES, { attribution: ATTRIBUTION, maxZoom: 19 }).addTo(map); const icon = L.divIcon({ className: 'recomaze-locator__pin', iconSize: [18, 18], iconAnchor: [9, 18], popupAnchor: [0, -18], }); const markers = new Map(); locations.forEach(location => { const marker = L.marker([location.lat, location.lng], { icon }).bindPopup( popupHtml(location) ); marker.on('click', () => onPick(location.id)); markers.set(location.id, marker); }); return { show(ids, origin) { const bounds: L.LatLngExpression[] = []; markers.forEach((marker, id) => { if (ids.has(id)) { marker.addTo(map); bounds.push(marker.getLatLng()); } else { marker.remove(); } }); if (origin) { bounds.push([origin.lat, origin.lng]); } if (bounds.length > 0) { map.fitBounds(L.latLngBounds(bounds), { padding: [24, 24], maxZoom: 15, }); } else { map.setView([20, 0], 2); } }, focus(id) { const marker = markers.get(id); if (marker) { map.setView(marker.getLatLng(), Math.max(map.getZoom(), 14)); marker.openPopup(); } }, }; }; /** The Maps JavaScript API, loaded once with the site's own browser key. */ const loadGoogleMaps = (key: string): Promise => new Promise((resolve, reject) => { if (window.google?.maps) { resolve(window.google.maps); return; } window.recomazeMapsReady = () => { if (window.google?.maps) { resolve(window.google.maps); } else { reject(new Error('Google Maps did not load')); } }; const script = document.createElement('script'); script.src = `https://maps.googleapis.com/maps/api/js?key=${encodeURIComponent( key )}&loading=async&callback=recomazeMapsReady`; script.async = true; script.onerror = () => reject(new Error('Google Maps did not load')); document.head.appendChild(script); }); const googleMap = ( api: GoogleMapsApi, element: HTMLElement, locations: (LocatorLocation & Point)[], onPick: (id: number) => void ): LocatorMap => { const map = new api.Map(element, { center: { lat: 20, lng: 0 }, zoom: 2, scrollwheel: false, mapTypeControl: false, streetViewControl: false, }); const info = new api.InfoWindow({}); const markers = new Map(); const open = (id: number): void => { const marker = markers.get(id); const location = locations.find(entry => entry.id === id); if (marker && location) { info.close(); info.setContent(popupHtml(location)); info.open({ map, anchor: marker }); } }; locations.forEach(location => { const marker = new api.Marker({ position: { lat: location.lat, lng: location.lng }, title: location.name, }); marker.addListener('click', () => { open(location.id); onPick(location.id); }); markers.set(location.id, marker); }); return { show(ids, origin) { const bounds = new api.LatLngBounds(); let count = 0; markers.forEach((marker, id) => { const position = marker.getPosition(); if (ids.has(id) && position) { marker.setMap(map); bounds.extend(position); count += 1; } else { marker.setMap(null); } }); if (origin) { bounds.extend(origin); count += 1; } if (count > 0) { map.fitBounds(bounds, 24); if ((map.getZoom() ?? 0) > 15) { map.setZoom(15); } } else { map.setCenter({ lat: 20, lng: 0 }); map.setZoom(2); } }, focus(id) { const position = markers.get(id)?.getPosition(); if (position) { map.setCenter(position); map.setZoom(Math.max(map.getZoom() ?? 0, 14)); open(id); } }, }; }; /** * One store locator on the page: the list rendered by PHP gets pins on a * map, a text filter and, on request, the order by distance from the * visitor. The map comes from Google when the settings hold a key for it, * from OpenStreetMap otherwise. */ const setup = (root: HTMLElement): void => { const locations: LocatorLocation[] = JSON.parse( root.dataset.recomazeLocator ?? '[]' ); const list = root.querySelector('.recomaze-locator__list'); const mapElement = root.querySelector('.recomaze-locator__map'); const query = root.querySelector( '.recomaze-locator__query' ); const near = root.querySelector('.recomaze-locator__near'); if (!list) { return; } const items = new Map(); list.querySelectorAll('li[data-id]').forEach(item => { items.set(Number(item.dataset.id), item); }); let map: LocatorMap | null = null; let shown = new Set(locations.map(location => location.id)); let origin: Point | null = null; const apply = (text: string): void => { const needle = text.trim().toLowerCase(); const visible = locations.filter( location => needle === '' || [location.name, location.address, ...location.categories] .join(' ') .toLowerCase() .includes(needle) ); const ordered = origin ? [...visible].sort((a, b) => { const da = hasPoint(a) ? distance(origin as Point, a) : Infinity; const db = hasPoint(b) ? distance(origin as Point, b) : Infinity; return da - db; }) : visible; shown = new Set(ordered.map(location => location.id)); items.forEach((item, id) => { item.hidden = !shown.has(id); }); ordered.forEach(location => { const item = items.get(location.id); if (!item) { return; } list.appendChild(item); const label = item.querySelector( '.recomaze-locator__distance' ); if (label) { const km = origin && hasPoint(location) ? distance(origin, location) : null; label.hidden = km === null; label.textContent = km === null ? '' : `${km < 10 ? km.toFixed(1) : Math.round(km)} km away`; } }); map?.show(shown, origin); }; if (mapElement) { const pinned = locations.filter(hasPoint); const scrollTo = (id: number): void => items.get(id)?.scrollIntoView({ block: 'nearest' }); const ready = (built: LocatorMap): void => { map = built; map.show(shown, origin); }; if (mapElement.dataset.provider === 'google' && mapElement.dataset.key) { loadGoogleMaps(mapElement.dataset.key) .then(api => ready(googleMap(api, mapElement, pinned, scrollTo))) .catch(() => ready(leafletMap(mapElement, pinned, scrollTo))); } else { ready(leafletMap(mapElement, pinned, scrollTo)); } items.forEach((item, id) => { item.addEventListener('click', event => { if ((event.target as HTMLElement).closest('a')) { return; } map?.focus(id); }); }); } apply(''); query?.addEventListener('input', () => apply(query.value)); near?.addEventListener('click', () => { if (!navigator.geolocation) { near.disabled = true; return; } near.disabled = true; navigator.geolocation.getCurrentPosition( position => { origin = { lat: position.coords.latitude, lng: position.coords.longitude, }; near.disabled = false; apply(query?.value ?? ''); }, () => { near.disabled = false; }, { timeout: 10000 } ); }); }; document .querySelectorAll('.recomaze-locator') .forEach(root => setup(root));