UNPKG

scribo-maps

Version:

Embeddable map widget for store locators

1 lines 15.9 kB
class ScriboMap{constructor(t,e){this.container="string"==typeof t?document.querySelector(t):t,this.container?(this.config=e,this.locale=e.locale||"en-US",this.initializeLocalization(),this.locations=e.locations||[],this.markers=[],this.markerCache=new Map,this.visibleMarkers=new Set,this.infoWindow=new google.maps.InfoWindow,this.markerClusterer=null,this.MAX_DISTANCE_KM=100,this.isSearchActive=!1,this.debounceTimer=null,this.markerUpdateTimer=null,this.isInitialized=!1,this.VIEWPORT_BUFFER=.1,this.placeholderImage="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' height='24px' viewBox='0 -960 960 960' width='24px' fill='%23e3e3e3'%3E%3Cpath d='M841-518v318q0 33-23.5 56.5T761-120H201q-33 0-56.5-23.5T121-200v-318q-23-21-35.5-54t-.5-72l42-136q8-26 28.5-43t47.5-17h556q27 0 47 16.5t29 43.5l42 136q12 39-.5 71T841-518Zm-272-42q27 0 41-18.5t11-41.5l-22-140h-78v148q0 21 14 36.5t34 15.5Zm-180 0q23 0 37.5-15.5T441-612v-148h-78l-22 140q-4 24 10.5 42t37.5 18Zm-178 0q18 0 31.5-13t16.5-33l22-154h-78l-40 134q-6 20 6.5 43t41.5 23Zm540 0q29 0 42-23t6-43l-42-134h-76l22 154q3 20 16.5 33t31.5 13ZM201-200h560v-282q-5 2-6.5 2H751q-27 0-47.5-9T663-518q-18 18-41 28t-49 10q-27 0-50.5-10T481-518q-17 18-39.5 28T393-480q-29 0-52.5-10T299-518q-21 21-41.5 29.5T211-480h-4.5q-2.5 0-5.5-2v282Zm560 0H201h560Z'/%3E%3C/svg%3E",this.init()):console.error(this.t("errors.containerNotFound"))}initializeLocalization(){this.translations={"en-US":{errors:{containerNotFound:"Scribo Maps: Container element not found.",geocodingFailed:"Failed to geocode address",geocodingLimit:"Geocoding service limit exceeded"},search:{placeholder:"Search for a location...",getDirections:"Get directions"},mobile:{showList:"List",showMap:"Map"},messages:{noStoresNear:"No stores found near this location.",noStoresArea:"No stores found in this area.",locationCount:t=>`${t} location${1!==t?"s":""}`,zeroLocations:"0 locations",geocodingLocation:"Finding coordinates for location..."}},"pt-BR":{errors:{containerNotFound:"Scribo Maps: Elemento container não encontrado.",geocodingFailed:"Falha ao geocodificar endereço",geocodingLimit:"Limite do serviço de geocodificação excedido"},search:{placeholder:"Pesquisar por um local...",getDirections:"Como chegar"},mobile:{showList:"Lista",showMap:"Mapa"},messages:{noStoresNear:"Nenhuma loja encontrada próxima a este local.",noStoresArea:"Nenhuma loja encontrada nesta área.",locationCount:t=>`${t} ${1!==t?"resultados":"resultado"}`,zeroLocations:"0 locais",geocodingLocation:"Encontrando coordenadas para o local..."}}}}t(t,...e){const i=t.split(".");let o=this.translations[this.locale];for(const e of i){if(!o||"object"!=typeof o){o=this.translations["en-US"];for(const e of i){if(!o||"object"!=typeof o)return t;o=o[e]}break}o=o[e]}return"function"==typeof o?o(...e):o||t}getLocationImage(t){return t.image&&""!==t.image.trim()?t.image:this.placeholderImage}hasCoordinates(t){return void 0!==t.lat&&void 0!==t.lng&&null!==t.lat&&null!==t.lng&&"number"==typeof t.lat&&"number"==typeof t.lng}filterValidLocations(){this.locations=this.config.locations.filter(t=>!!this.hasCoordinates(t)||(console.warn(`Scribo Maps: Location "${t.name}" has no coordinates. Skipping.`),!1));const t=this.config.locations.length-this.locations.length;t>0&&console.warn(`Scribo Maps: ${t} location(s) without coordinates were excluded.`)}createInfoWindowContent(t){return`\n <div class="scribo-map-infowindow">\n <div class="infowindow-content">\n <h4>${t.name}</h4>\n <p>${t.address||""}</p>\n <div class="infowindow-actions">\n <a href="https://www.google.com/maps/dir/?api=1&destination=${t.lat},${t.lng}" \n target="_blank" \n class="infowindow-directions-btn">\n <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">\n <path d="M9 11a3 3 0 1 0 6 0a3 3 0 0 0 -6 0"></path>\n <path d="M17.657 16.657l-4.243 4.243a2 2 0 0 1 -2.827 0l-4.244 -4.243a8 8 0 1 1 11.314 0z"></path>\n </svg>\n ${this.t("search.getDirections")}\n </a>\n </div>\n </div>\n </div>\n `}setLocale(t){this.translations[t]?(this.locale=t,this.refreshLocalization()):console.warn(`Scribo Maps: Locale '${t}' not supported. Available locales: ${Object.keys(this.translations).join(", ")}`)}refreshLocalization(){if(this.searchInput&&(this.searchInput.placeholder=this.t("search.placeholder")),this.mobileToggleBtn){const t=this.mobileToggleBtn.querySelector(".toggle-text");t&&(t.textContent=this.isMobileListView?this.t("mobile.showMap"):this.t("mobile.showList"))}const t=this.isSearchActive?this.getCurrentlyDisplayedLocations():this.config.locations;this.locationCount&&t&&(this.locationCount.textContent=this.t("messages.locationCount",t.length)),this.updateSidebar(t||this.locations)}getCurrentlyDisplayedLocations(){const t=this.map.getBounds();return t?this.locations.filter(e=>t.contains({lat:e.lat,lng:e.lng})):this.locations}async init(){this.container.innerHTML=`\n <div class="scribo-map-container">\n <div class="scribo-map-sidebar">\n <div class="search-card">\n <div class="search-container">\n <div class="search-input-wrapper">\n <input type="text" name="location" class="scribo-map-search" placeholder="">\n <button class="scribo-map-clear-search clear-search-btn" style="display: none;">&times;</button>\n </div>\n <button class="scribo-map-geolocation geolocation-btn">\n <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-map-pin-icon lucide-map-pin"><path d="M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0"/><circle cx="12" cy="10" r="3"/></svg>\n </button>\n </div>\n </div>\n <div class="scribo-map-location-count location-count"></div>\n <ul class="scribo-map-locations-list"></ul>\n </div>\n <div class="scribo-map-map"></div>\n <button class="mobile-toggle-btn">\n <span class="toggle-icon list-icon">\n <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">\n <line x1="8" y1="6" x2="21" y2="6"></line>\n <line x1="8" y1="12" x2="21" y2="12"></line>\n <line x1="8" y1="18" x2="21" y2="18"></line>\n <line x1="3" y1="6" x2="3.01" y2="6"></line>\n <line x1="3" y1="12" x2="3.01" y2="12"></line>\n <line x1="3" y1="18" x2="3.01" y2="18"></line>\n </svg>\n </span>\n <span class="toggle-icon map-icon" style="display: none;">\n <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">\n <polygon points="3 6 9 1 15 6 21 1 21 18 15 23 9 18 3 23"></polygon>\n <line x1="9" y1="1" x2="9" y2="18"></line>\n <line x1="15" y1="6" x2="15" y2="23"></line>\n </svg>\n </span>\n <span class="toggle-text">${this.t("mobile.showList")}</span>\n </button>\n </div>\n `,this.map=new google.maps.Map(document.querySelector(".scribo-map-map"),{center:{lat:0,lng:0},zoom:2,fullscreenControl:!1,mapTypeControl:!1,streetViewControl:!1}),this.locationsList=document.querySelector(".scribo-map-locations-list"),this.searchInput=document.querySelector(".scribo-map-search"),this.clearSearchBtn=document.querySelector(".scribo-map-clear-search"),this.geolocationBtn=document.querySelector(".scribo-map-geolocation"),this.locationCount=document.querySelector(".scribo-map-location-count"),this.mobileToggleBtn=document.querySelector(".mobile-toggle-btn"),this.sidebar=document.querySelector(".scribo-map-sidebar"),this.mapContainer=document.querySelector(".scribo-map-map"),this.isMobileListView=!1,this.searchInput.placeholder=this.t("search.placeholder");const t={render:({count:t,position:e})=>{const i=window.btoa(`\n <svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 40 40">\n <circle cx="20" cy="20" r="18" fill="#EA4434" stroke="white" stroke-width="2"/>\n <text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" font-size="16" font-weight="bold" fill="white">${t}</text>\n </svg>\n `);return new google.maps.Marker({position:e,icon:{url:`data:image/svg+xml;base64,${i}`,scaledSize:new google.maps.Size(40,40)},label:{text:" ",color:"rgba(0,0,0,0)"}})}};this.markerClusterer=new markerClusterer.MarkerClusterer({map:this.map,renderer:t,gridSize:40}),this.filterValidLocations(),this.finishInitialization()}finishInitialization(){this.renderMarkers(),this.updateSidebar(this.locations),this.attachEventListeners(),this.zoomToFirstLocation(),this.findUserLocation(),this.isInitialized=!0}getLocationId(t){return`${t.lat}-${t.lng}-${t.name}`.replace(/[^a-zA-Z0-9\-]/g,"")}getExpandedBounds(t){if(!t)return null;const e=t.getNorthEast(),i=t.getSouthWest(),o=e.lat()-i.lat(),s=e.lng()-i.lng(),n=o*this.VIEWPORT_BUFFER,a=s*this.VIEWPORT_BUFFER;return new google.maps.LatLngBounds(new google.maps.LatLng(i.lat()-n,i.lng()-a),new google.maps.LatLng(e.lat()+n,e.lng()+a))}createMarker(t){const e=this.getLocationId(t);if(this.markerCache.has(e))return this.markerCache.get(e);const i={lat:t.lat,lng:t.lng},o=new google.maps.Marker({position:i,title:t.name});return o.addListener("click",()=>{const e=this.createInfoWindowContent(t);this.infoWindow.setContent(e),this.infoWindow.open(this.map,o);const i=Array.from(this.locationsList.children).find(e=>e.querySelector("h4")&&e.querySelector("h4").textContent===t.name);i&&this.highlightListItem(i)}),this.markerCache.set(e,o),o}updateMarkersForViewport(){const t=this.map.getBounds();if(!t)return;const e=this.getExpandedBounds(t);if(!e)return;const i=this.locations.filter(t=>{const i=new google.maps.LatLng(t.lat,t.lng);return e.contains(i)}),o=new Set(i.map(t=>this.getLocationId(t))),s=[];this.visibleMarkers.forEach(t=>{if(!o.has(t)){const e=this.markerCache.get(t);e&&(s.push(e),this.visibleMarkers.delete(t))}}),s.length>0&&this.markerClusterer.removeMarkers(s);const n=[];i.forEach(t=>{const e=this.getLocationId(t);if(!this.visibleMarkers.has(e)){const i=this.createMarker(t);n.push(i),this.visibleMarkers.add(e)}}),n.length>0&&this.markerClusterer.addMarkers(n),this.markers=Array.from(this.visibleMarkers).map(t=>this.markerCache.get(t)).filter(Boolean)}renderMarkers(){this.markerClusterer.clearMarkers(),this.markers=[],this.visibleMarkers.clear(),setTimeout(()=>{this.updateMarkersForViewport()},100)}updateSidebar(t){this.locationCount.textContent=this.t("messages.locationCount",t.length),this.locationsList.innerHTML="",0===t.length&&this.isSearchActive?this.locationsList.innerHTML=`<li class="no-stores-message">${this.t("messages.noStoresNear")}</li>`:0!==t.length?t.forEach(t=>{const e={lat:t.lat,lng:t.lng},i=this.getLocationId(t),o=this.markerCache.get(i),s=document.createElement("li");s.innerHTML=`\n <div class="location-item-sidebar">\n <img src="${this.getLocationImage(t)}" class="location-image" alt="${t.name}">\n <div class="location-details">\n <h4>${t.name}</h4>\n <p>${t.address||""}</p>\n <a href="https://www.google.com/maps/dir/?api=1&destination=${t.lat},${t.lng}" target="_blank">${this.t("search.getDirections")}</a>\n </div>\n </div>\n `,s.addEventListener("click",()=>{this.map.setCenter(e),this.map.setZoom(17);let n=o;if(n||(n=this.createMarker(t),this.markerClusterer.addMarker(n),this.visibleMarkers.add(i)),n){const e=this.createInfoWindowContent(t);this.infoWindow.setContent(e),this.infoWindow.open(this.map,n)}this.highlightListItem(s)}),this.locationsList.appendChild(s)}):this.locationsList.innerHTML=`<li class="no-stores-message">${this.t("messages.noStoresArea")}</li>`}findUserLocation(){navigator.geolocation&&navigator.geolocation.getCurrentPosition(t=>{const e={lat:t.coords.latitude,lng:t.coords.longitude};this.handleLocationUpdate(e,!1)},()=>{})}handleLocationUpdate(t,e){this.isSearchActive=e;const i=this.locations.map(e=>({...e,distance:this.getDistance(t,e)})).filter(t=>t.distance<=this.MAX_DISTANCE_KM).sort((t,e)=>t.distance-e.distance);0===i.length?(this.locationCount.textContent=this.t("messages.zeroLocations"),this.locationsList.innerHTML=`<li class="no-stores-message">${this.t("messages.noStoresNear")}</li>`,e&&this.zoomToAllLocations()):(this.updateSidebar(i),this.zoomToFirstLocation(i))}zoomToFirstLocation(t=this.locations){if(t.length>0){const e=t[0];this.map.setCenter({lat:e.lat,lng:e.lng}),this.map.setZoom(12)}}zoomToAllLocations(){const t=new google.maps.LatLngBounds;0!==this.markers.length&&(this.markers.forEach(e=>t.extend(e.getPosition())),this.map.fitBounds(t))}getDistance(t,e){const i=t=>t*Math.PI/180,o=i(e.lat-t.lat),s=i(e.lng-t.lng),n=i(t.lat),a=i(e.lat),r=Math.sin(o/2)*Math.sin(o/2)+Math.sin(s/2)*Math.sin(s/2)*Math.cos(n)*Math.cos(a);return 6371*(2*Math.atan2(Math.sqrt(r),Math.sqrt(1-r)))}highlightListItem(t){this.locationsList.childNodes.forEach(e=>{e.style.backgroundColor=e===t?"#f0f0f0":""})}toggleMobileView(){this.isMobileListView=!this.isMobileListView;const t=this.mobileToggleBtn.querySelector(".list-icon"),e=this.mobileToggleBtn.querySelector(".map-icon"),i=this.mobileToggleBtn.querySelector(".toggle-text");this.isMobileListView?(this.container.classList.add("mobile-list-view"),t.style.display="none",e.style.display="inline",i.textContent=this.t("mobile.showMap")):(this.container.classList.remove("mobile-list-view"),t.style.display="inline",e.style.display="none",i.textContent=this.t("mobile.showList")),setTimeout(()=>{google.maps.event.trigger(this.map,"resize")},300)}attachEventListeners(){const t=new google.maps.places.Autocomplete(this.searchInput);t.setFields(["geometry"]),t.addListener("place_changed",()=>{const e=t.getPlace();if(e.geometry&&e.geometry.location){const t={lat:e.geometry.location.lat(),lng:e.geometry.location.lng()};this.handleLocationUpdate(t,!0)}}),this.searchInput.addEventListener("input",()=>{if(""===this.searchInput.value){this.isSearchActive=!1,this.clearSearchBtn.style.display="none";const t=this.map.getBounds();if(!t)return;const e=this.locations.filter(e=>t.contains({lat:e.lat,lng:e.lng}));this.updateSidebar(e)}else this.clearSearchBtn.style.display="block"}),this.clearSearchBtn.addEventListener("click",()=>{this.searchInput.value="",this.searchInput.dispatchEvent(new Event("input"))}),this.geolocationBtn.addEventListener("click",()=>{this.findUserLocation()}),this.mobileToggleBtn.addEventListener("click",()=>{this.toggleMobileView()}),this.map.addListener("bounds_changed",()=>{clearTimeout(this.markerUpdateTimer),this.markerUpdateTimer=setTimeout(()=>{this.updateMarkersForViewport()},150),this.isSearchActive||(clearTimeout(this.debounceTimer),this.debounceTimer=setTimeout(()=>{const t=this.map.getBounds();if(!t)return;const e=this.locations.filter(e=>t.contains({lat:e.lat,lng:e.lng}));this.updateSidebar(e)},300))}),this.map.addListener("zoom_changed",()=>{this.infoWindow.close(),this.highlightListItem(null)})}}