UNPKG

scribo-maps

Version:

Embeddable map widget for store locators

773 lines (681 loc) 28.1 kB
class ScriboMap { constructor(selector, config) { this.container = typeof selector === 'string' ? document.querySelector(selector) : selector; if (!this.container) { console.error(this.t("errors.containerNotFound")); return; } this.config = config; // Set up localization this.locale = config.locale || "en-US"; this.initializeLocalization(); this.locations = config.locations || []; this.markers = []; this.markerCache = new Map(); // Cache for created markers by location ID this.visibleMarkers = new Set(); // Track currently visible markers this.infoWindow = new google.maps.InfoWindow(); this.markerClusterer = null; this.MAX_DISTANCE_KM = 100; // Max distance in kilometers to be considered "near" this.isSearchActive = false; this.debounceTimer = null; this.markerUpdateTimer = null; // Separate timer for marker updates // TODO: Geo API is expensive - commenting out geocoder for now // this.geocoder = new google.maps.Geocoder(); this.isInitialized = false; // Lazy loading configuration this.VIEWPORT_BUFFER = 0.1; // 10% buffer around viewport bounds // Placeholder SVG for locations without images 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(); } // Localization dictionaries 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: (count) => `${count} location${count !== 1 ? "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: (count) => `${count} ${count !== 1 ? "resultados" : "resultado"}`, zeroLocations: "0 locais", geocodingLocation: "Encontrando coordenadas para o local...", }, }, }; } // Helper method to get localized text t(key, ...args) { const keys = key.split("."); let translation = this.translations[this.locale]; for (const k of keys) { if (translation && typeof translation === "object") { translation = translation[k]; } else { // Fallback to en-US if translation not found translation = this.translations["en-US"]; for (const fallbackKey of keys) { if (translation && typeof translation === "object") { translation = translation[fallbackKey]; } else { return key; // Return key if no translation found } } break; } } // If translation is a function, call it with arguments if (typeof translation === "function") { return translation(...args); } return translation || key; } // Helper method to get image URL with fallback getLocationImage(location) { return location.image && location.image.trim() !== "" ? location.image : this.placeholderImage; } // Check if location has coordinates hasCoordinates(location) { return location.lat !== undefined && location.lng !== undefined && location.lat !== null && location.lng !== null && typeof location.lat === 'number' && typeof location.lng === 'number'; } // TODO: Geo API is expensive - commenting out geocoding functionality // Geocode a single location // async geocodeLocation(location) { // if (this.hasCoordinates(location)) { // return location; // Already has coordinates // } // if (!location.address || location.address.trim() === '') { // console.warn(`Scribo Maps: Location "${location.name}" has no address or coordinates. Skipping.`); // return null; // } // try { // const results = await new Promise((resolve, reject) => { // this.geocoder.geocode({ address: location.address }, (results, status) => { // if (status === 'OK' && results && results.length > 0) { // resolve(results); // } else { // reject(new Error(`${this.t("errors.geocodingFailed")}: ${status}`)); // } // }); // }); // const geocodedLocation = { // ...location, // lat: results[0].geometry.location.lat(), // lng: results[0].geometry.location.lng(), // geocoded: true // Flag to indicate this was geocoded // }; // return geocodedLocation; // } catch (error) { // console.warn(`Scribo Maps: ${error.message} for "${location.name}" (${location.address})`); // return null; // } // } // TODO: Geo API is expensive - commenting out geocoding functionality // Geocode all locations that need it // async geocodeAllLocations() { // const geocodePromises = this.config.locations.map(location => this.geocodeLocation(location)); // const results = await Promise.all(geocodePromises); // // // Filter out null results (failed geocoding) // this.locations = results.filter(location => location !== null); // console.log(this.locations) // // const failedCount = this.config.locations.length - this.locations.length; // if (failedCount > 0) { // console.warn(`Scribo Maps: ${failedCount} location(s) could not be geocoded and were excluded.`); // } // } // Filter locations to only include those with valid coordinates filterValidLocations() { this.locations = this.config.locations.filter(location => { if (this.hasCoordinates(location)) { return true; } else { console.warn(`Scribo Maps: Location "${location.name}" has no coordinates. Skipping.`); return false; } }); const excludedCount = this.config.locations.length - this.locations.length; if (excludedCount > 0) { console.warn(`Scribo Maps: ${excludedCount} location(s) without coordinates were excluded.`); } } // Helper method to create InfoWindow content createInfoWindowContent(location) { return ` <div class="scribo-map-infowindow"> <div class="infowindow-content"> <h4>${location.name}</h4> <p>${location.address || ""}</p> <div class="infowindow-actions"> <a href="https://www.google.com/maps/dir/?api=1&destination=${ location.lat },${location.lng}" target="_blank" class="infowindow-directions-btn"> <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"> <path d="M9 11a3 3 0 1 0 6 0a3 3 0 0 0 -6 0"></path> <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> </svg> ${this.t("search.getDirections")} </a> </div> </div> </div> `; } // Method to change locale dynamically setLocale(newLocale) { if (this.translations[newLocale]) { this.locale = newLocale; this.refreshLocalization(); } else { console.warn( `Scribo Maps: Locale '${newLocale}' not supported. Available locales: ${Object.keys( this.translations ).join(", ")}` ); } } // Method to refresh all localized UI elements refreshLocalization() { // Update search placeholder if (this.searchInput) { this.searchInput.placeholder = this.t("search.placeholder"); } // Update mobile toggle button text if (this.mobileToggleBtn) { const toggleText = this.mobileToggleBtn.querySelector('.toggle-text'); if (toggleText) { toggleText.textContent = this.isMobileListView ? this.t("mobile.showMap") : this.t("mobile.showList"); } } // Update location count const currentLocations = this.isSearchActive ? this.getCurrentlyDisplayedLocations() : this.config.locations; if (this.locationCount && currentLocations) { this.locationCount.textContent = this.t( "messages.locationCount", currentLocations.length ); } // Refresh sidebar to update "Get directions" links and messages this.updateSidebar(currentLocations || this.locations); } // Helper method to get currently displayed locations getCurrentlyDisplayedLocations() { // If search is active, return the locations that match the current filter // This is a simplified version - in a real implementation you might want to store this state const bounds = this.map.getBounds(); if (!bounds) return this.locations; return this.locations.filter((loc) => { return bounds.contains({ lat: loc.lat, lng: loc.lng }); }); } async init() { this.container.innerHTML = ` <div class="scribo-map-container"> <div class="scribo-map-sidebar"> <div class="search-card"> <div class="search-container"> <div class="search-input-wrapper"> <input type="text" name="location" class="scribo-map-search" placeholder=""> <button class="scribo-map-clear-search clear-search-btn" style="display: none;">&times;</button> </div> <button class="scribo-map-geolocation geolocation-btn"> <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> </button> </div> </div> <div class="scribo-map-location-count location-count"></div> <ul class="scribo-map-locations-list"></ul> </div> <div class="scribo-map-map"></div> <button class="mobile-toggle-btn"> <span class="toggle-icon list-icon"> <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"> <line x1="8" y1="6" x2="21" y2="6"></line> <line x1="8" y1="12" x2="21" y2="12"></line> <line x1="8" y1="18" x2="21" y2="18"></line> <line x1="3" y1="6" x2="3.01" y2="6"></line> <line x1="3" y1="12" x2="3.01" y2="12"></line> <line x1="3" y1="18" x2="3.01" y2="18"></line> </svg> </span> <span class="toggle-icon map-icon" style="display: none;"> <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"> <polygon points="3 6 9 1 15 6 21 1 21 18 15 23 9 18 3 23"></polygon> <line x1="9" y1="1" x2="9" y2="18"></line> <line x1="15" y1="6" x2="15" y2="23"></line> </svg> </span> <span class="toggle-text">${this.t("mobile.showList")}</span> </button> </div> `; this.map = new google.maps.Map(document.querySelector(".scribo-map-map"), { center: { lat: 0, lng: 0 }, zoom: 2, fullscreenControl: false, mapTypeControl: false, streetViewControl: false, }); 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"); // Mobile state this.isMobileListView = false; // Set localized placeholder after elements are created this.searchInput.placeholder = this.t("search.placeholder"); const renderer = { render: ({ count, position }) => { const svg = window.btoa(` <svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 40 40"> <circle cx="20" cy="20" r="18" fill="#EA4434" stroke="white" stroke-width="2"/> <text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" font-size="16" font-weight="bold" fill="white">${count}</text> </svg> `); return new google.maps.Marker({ position, icon: { url: `data:image/svg+xml;base64,${svg}`, scaledSize: new google.maps.Size(40, 40), }, label: { text: " ", color: "rgba(0,0,0,0)" }, }); }, }; this.markerClusterer = new markerClusterer.MarkerClusterer({ map: this.map, renderer, gridSize: 40, }); // TODO: Geo API is expensive - commenting out geocoding logic // Show loading message if geocoding is needed // const needsGeocoding = this.config.locations.some(location => !this.hasCoordinates(location)); // if (needsGeocoding) { // this.showLoadingMessage(); // } // Geocode locations that need it // await this.geocodeAllLocations(); // Filter locations to only include those with valid coordinates this.filterValidLocations(); // Initialize the map with valid locations this.finishInitialization(); } // TODO: Geo API is expensive - commenting out loading message for geocoding // showLoadingMessage() { // this.locationCount.textContent = this.t("messages.geocodingLocation"); // } finishInitialization() { this.renderMarkers(); this.updateSidebar(this.locations); this.attachEventListeners(); this.zoomToFirstLocation(); this.findUserLocation(); this.isInitialized = true; } // Generate unique ID for location getLocationId(location) { return `${location.lat}-${location.lng}-${location.name}`.replace(/[^a-zA-Z0-9\-]/g, ''); } // Create expanded bounds with buffer for smoother panning getExpandedBounds(bounds) { if (!bounds) return null; const ne = bounds.getNorthEast(); const sw = bounds.getSouthWest(); const latRange = ne.lat() - sw.lat(); const lngRange = ne.lng() - sw.lng(); const latBuffer = latRange * this.VIEWPORT_BUFFER; const lngBuffer = lngRange * this.VIEWPORT_BUFFER; return new google.maps.LatLngBounds( new google.maps.LatLng(sw.lat() - latBuffer, sw.lng() - lngBuffer), new google.maps.LatLng(ne.lat() + latBuffer, ne.lng() + lngBuffer) ); } // Create a marker for a location createMarker(location) { const locationId = this.getLocationId(location); // Check if marker already exists in cache if (this.markerCache.has(locationId)) { return this.markerCache.get(locationId); } const position = { lat: location.lat, lng: location.lng }; const marker = new google.maps.Marker({ position, title: location.name, }); marker.addListener("click", () => { const contentString = this.createInfoWindowContent(location); this.infoWindow.setContent(contentString); this.infoWindow.open(this.map, marker); const correspondingListItem = Array.from( this.locationsList.children ).find( (child) => child.querySelector("h4") && child.querySelector("h4").textContent === location.name ); if (correspondingListItem) this.highlightListItem(correspondingListItem); }); // Cache the marker this.markerCache.set(locationId, marker); return marker; } // Update markers based on viewport bounds updateMarkersForViewport() { const bounds = this.map.getBounds(); if (!bounds) return; const expandedBounds = this.getExpandedBounds(bounds); if (!expandedBounds) return; const locationsInViewport = this.locations.filter(location => { const position = new google.maps.LatLng(location.lat, location.lng); return expandedBounds.contains(position); }); // Get IDs of locations that should be visible const shouldBeVisible = new Set(locationsInViewport.map(loc => this.getLocationId(loc))); // Remove markers that are no longer in viewport const markersToRemove = []; this.visibleMarkers.forEach(locationId => { if (!shouldBeVisible.has(locationId)) { const marker = this.markerCache.get(locationId); if (marker) { markersToRemove.push(marker); this.visibleMarkers.delete(locationId); } } }); if (markersToRemove.length > 0) { this.markerClusterer.removeMarkers(markersToRemove); } // Add new markers for locations that entered viewport const markersToAdd = []; locationsInViewport.forEach(location => { const locationId = this.getLocationId(location); if (!this.visibleMarkers.has(locationId)) { const marker = this.createMarker(location); markersToAdd.push(marker); this.visibleMarkers.add(locationId); } }); if (markersToAdd.length > 0) { this.markerClusterer.addMarkers(markersToAdd); } // Update markers array to reflect currently visible markers this.markers = Array.from(this.visibleMarkers).map(id => this.markerCache.get(id)).filter(Boolean); } // Initial marker rendering - only load what's visible renderMarkers() { this.markerClusterer.clearMarkers(); this.markers = []; this.visibleMarkers.clear(); // Wait a moment for map to be ready, then load initial viewport setTimeout(() => { this.updateMarkersForViewport(); }, 100); } updateSidebar(locations) { this.locationCount.textContent = this.t( "messages.locationCount", locations.length ); this.locationsList.innerHTML = ""; if (locations.length === 0 && this.isSearchActive) { this.locationsList.innerHTML = `<li class="no-stores-message">${this.t( "messages.noStoresNear" )}</li>`; return; } else if (locations.length === 0) { this.locationsList.innerHTML = `<li class="no-stores-message">${this.t( "messages.noStoresArea" )}</li>`; return; } locations.forEach((location) => { const position = { lat: location.lat, lng: location.lng }; const locationId = this.getLocationId(location); const marker = this.markerCache.get(locationId); const listItem = document.createElement("li"); listItem.innerHTML = ` <div class="location-item-sidebar"> <img src="${this.getLocationImage( location )}" class="location-image" alt="${location.name}"> <div class="location-details"> <h4>${location.name}</h4> <p>${location.address || ""}</p> <a href="https://www.google.com/maps/dir/?api=1&destination=${ location.lat },${location.lng}" target="_blank">${this.t( "search.getDirections" )}</a> </div> </div> `; listItem.addEventListener("click", () => { this.map.setCenter(position); this.map.setZoom(17); // Ensure marker is created and visible when clicked from sidebar let markerToShow = marker; if (!markerToShow) { markerToShow = this.createMarker(location); this.markerClusterer.addMarker(markerToShow); this.visibleMarkers.add(locationId); } if (markerToShow) { const contentString = this.createInfoWindowContent(location); this.infoWindow.setContent(contentString); this.infoWindow.open(this.map, markerToShow); } this.highlightListItem(listItem); }); this.locationsList.appendChild(listItem); }); } findUserLocation() { if (navigator.geolocation) { navigator.geolocation.getCurrentPosition( (position) => { const userLocation = { lat: position.coords.latitude, lng: position.coords.longitude, }; this.handleLocationUpdate(userLocation, false); // This is an automatic search }, () => { /* User denied or error, do nothing */ } ); } } handleLocationUpdate(coords, isManualSearch) { this.isSearchActive = isManualSearch; const nearbyLocations = this.locations .map((location) => ({ ...location, distance: this.getDistance(coords, location), })) .filter((location) => location.distance <= this.MAX_DISTANCE_KM) .sort((a, b) => a.distance - b.distance); if (nearbyLocations.length === 0) { this.locationCount.textContent = this.t("messages.zeroLocations"); this.locationsList.innerHTML = `<li class="no-stores-message">${this.t( "messages.noStoresNear" )}</li>`; if (isManualSearch) { this.zoomToAllLocations(); } } else { this.updateSidebar(nearbyLocations); this.zoomToFirstLocation(nearbyLocations); } } zoomToFirstLocation(locations = this.locations) { if (locations.length > 0) { const firstLocation = locations[0]; this.map.setCenter({ lat: firstLocation.lat, lng: firstLocation.lng }); this.map.setZoom(12); } } zoomToAllLocations() { const bounds = new google.maps.LatLngBounds(); if (this.markers.length === 0) return; this.markers.forEach((marker) => bounds.extend(marker.getPosition())); this.map.fitBounds(bounds); } getDistance(coords1, coords2) { const toRad = (x) => (x * Math.PI) / 180; const R = 6371; // Earth radius in km const dLat = toRad(coords2.lat - coords1.lat); const dLon = toRad(coords2.lng - coords1.lng); const lat1 = toRad(coords1.lat); const lat2 = toRad(coords2.lat); const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.sin(dLon / 2) * Math.sin(dLon / 2) * Math.cos(lat1) * Math.cos(lat2); const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); return R * c; } highlightListItem(itemToHighlight) { this.locationsList.childNodes.forEach((child) => { child.style.backgroundColor = child === itemToHighlight ? "#f0f0f0" : ""; }); } toggleMobileView() { this.isMobileListView = !this.isMobileListView; const listIcon = this.mobileToggleBtn.querySelector('.list-icon'); const mapIcon = this.mobileToggleBtn.querySelector('.map-icon'); const toggleText = this.mobileToggleBtn.querySelector('.toggle-text'); if (this.isMobileListView) { // Show list, hide map this.container.classList.add('mobile-list-view'); listIcon.style.display = 'none'; mapIcon.style.display = 'inline'; toggleText.textContent = this.t("mobile.showMap"); } else { // Show map, hide list this.container.classList.remove('mobile-list-view'); listIcon.style.display = 'inline'; mapIcon.style.display = 'none'; toggleText.textContent = this.t("mobile.showList"); } // Trigger map resize after layout change setTimeout(() => { google.maps.event.trigger(this.map, 'resize'); }, 300); } attachEventListeners() { const autocomplete = new google.maps.places.Autocomplete(this.searchInput); autocomplete.setFields(["geometry"]); autocomplete.addListener("place_changed", () => { const place = autocomplete.getPlace(); if (place.geometry && place.geometry.location) { const searchLocation = { lat: place.geometry.location.lat(), lng: place.geometry.location.lng(), }; this.handleLocationUpdate(searchLocation, true); // This is a manual search } }); this.searchInput.addEventListener("input", () => { if (this.searchInput.value === "") { this.isSearchActive = false; this.clearSearchBtn.style.display = "none"; const bounds = this.map.getBounds(); if (!bounds) return; const visibleLocations = this.locations.filter((loc) => { return bounds.contains({ lat: loc.lat, lng: loc.lng }); }); this.updateSidebar(visibleLocations); } 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", () => { // Update markers dynamically as user navigates clearTimeout(this.markerUpdateTimer); this.markerUpdateTimer = setTimeout(() => { this.updateMarkersForViewport(); }, 150); // Faster response for marker updates // Update sidebar (original functionality) if (this.isSearchActive) { return; } clearTimeout(this.debounceTimer); this.debounceTimer = setTimeout(() => { const bounds = this.map.getBounds(); if (!bounds) return; const visibleLocations = this.locations.filter((loc) => { return bounds.contains({ lat: loc.lat, lng: loc.lng }); }); this.updateSidebar(visibleLocations); }, 300); }); this.map.addListener("zoom_changed", () => { this.infoWindow.close(); this.highlightListItem(null); }); } }