scribo-maps
Version:
Embeddable map widget for store locators
773 lines (681 loc) • 28.1 kB
JavaScript
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;">×</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);
});
}
}