UNPKG

@rezw4n/maplibre-google-streetview

Version:

A MapLibre plugin that adds Google Street View functionality with pegman control

596 lines (504 loc) 20.3 kB
/** * MapLibre Google Street View Plugin * A MapLibre GL JS plugin that adds Google Street View functionality with pegman control */ import './styles.css'; /** * MapLibreGoogleStreetView class * @class */ class MapLibreGoogleStreetView { /** * Create a new MapLibreGoogleStreetView instance * @param {Object} options - Configuration options * @param {Object} options.map - MapLibre GL JS map instance * @param {string} options.apiKey - Google Maps API Key * @param {boolean} [options.showPegmanButton=true] - Whether to show the pegman button */ constructor(options) { this.map = options.map; this.apiKey = options.apiKey; this.showPegmanButton = options.showPegmanButton !== false; this.isDragging = false; this.pegmanMarker = null; this.streetViewLayerActive = false; // Touch interaction tracking this.touchStartTime = 0; this.touchMoved = false; this.isTouchInteraction = false; // Create DOM elements this._createDOMElements(); // Initialize the plugin this._init(); } /** * Create the necessary DOM elements * @private */ _createDOMElements() { // Create pegman container this.pegmanContainer = document.createElement('div'); this.pegmanContainer.className = 'maplibre-streetview-pegman-container'; this.pegmanContainer.id = 'pegman-container'; // Create pegman element this.pegman = document.createElement('div'); this.pegman.className = 'maplibre-streetview-pegman'; this.pegman.id = 'pegman'; this.pegman.setAttribute('draggable', 'true'); this.pegmanContainer.appendChild(this.pegman); // Create street view container this.streetView = document.createElement('div'); this.streetView.className = 'maplibre-streetview-street-view'; this.streetView.id = 'street-view'; // Create street view iframe this.streetViewIframe = document.createElement('iframe'); this.streetViewIframe.className = 'maplibre-streetview-iframe'; this.streetViewIframe.id = 'street-view-iframe'; this.streetViewIframe.setAttribute('allowfullscreen', ''); this.streetView.appendChild(this.streetViewIframe); // Create close button with improved accessibility this.closeStreetView = document.createElement('div'); this.closeStreetView.className = 'maplibre-streetview-close'; this.closeStreetView.id = 'close-street-view'; this.closeStreetView.innerHTML = '×'; this.closeStreetView.setAttribute('role', 'button'); this.closeStreetView.setAttribute('aria-label', 'Close Street View'); this.streetView.appendChild(this.closeStreetView); // Append elements to the document if (this.showPegmanButton) { document.body.appendChild(this.pegmanContainer); } document.body.appendChild(this.streetView); } /** * Initialize the plugin * @private */ _init() { // Create pegman marker this._createPegmanMarker(); // Set up event listeners this._setupEventListeners(); // Wait for map to load before adding layers if (this.map.loaded()) { this._onMapLoad(); } else { this.map.on('load', () => this._onMapLoad()); } // Position pegman button this._positionPegmanButton(); // Handle window resize window.addEventListener('resize', () => this._positionPegmanButton()); } /** * Create the pegman marker that appears when dragging * @private */ _createPegmanMarker() { if (this.pegmanMarker) return; this.pegmanMarker = document.createElement('div'); this.pegmanMarker.className = 'maplibre-streetview-pegman-marker'; document.body.appendChild(this.pegmanMarker); } /** * Set up event listeners * @private */ _setupEventListeners() { // Stop map propagation on pegman hover this.pegmanContainer.addEventListener('mouseenter', (e) => { this.map.getCanvas().style.pointerEvents = 'none'; }); this.pegmanContainer.addEventListener('mouseleave', (e) => { this.map.getCanvas().style.pointerEvents = 'auto'; }); // Toggle Street View coverage when clicking on pegman button this.pegmanContainer.addEventListener('click', (e) => { // Only toggle if it's a direct click (not the start of a drag) if (e.target === this.pegman || e.target === this.pegmanContainer) { this._toggleStreetViewLayer(); e.stopPropagation(); // Prevent the click from propagating to the map } }); // Make pegman draggable this.pegman.addEventListener('dragstart', (e) => { this.isDragging = true; this.pegman.classList.add('dragging'); this.pegmanContainer.classList.add('dragging'); // Make the drag image transparent const emptyImg = new Image(); emptyImg.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; e.dataTransfer.setDragImage(emptyImg, 0, 0); e.dataTransfer.effectAllowed = 'move'; // Show and position the pegman marker at the cursor this.pegmanMarker.classList.add('active'); this._updatePegmanMarkerPosition(e); // Make sure Street View coverage is visible when dragging starts if (!this.streetViewLayerActive) { this._toggleStreetViewLayer(); } }); // Handle pegman dragging over map this.map.getContainer().addEventListener('dragover', (e) => { e.preventDefault(); if (this.isDragging) { this._updatePegmanMarkerPosition(e); e.dataTransfer.dropEffect = 'move'; } }); // Handle mousemove for updating pegman marker position document.addEventListener('mousemove', (e) => { if (this.isDragging) { this._updatePegmanMarkerPosition(e); } }); // Handle pegman drop on map this.map.getContainer().addEventListener('drop', (e) => { e.preventDefault(); if (this.isDragging) { const rect = this.map.getContainer().getBoundingClientRect(); const x = e.clientX - rect.left; const y = e.clientY - rect.top; // Convert drop point to geographic coordinates const coords = this.map.unproject([x, y]); // Check if Street View is available at this location before opening this._checkStreetViewAvailability(coords.lat, coords.lng, (isAvailable) => { if (isAvailable) { this._openStreetView(coords.lat, coords.lng); } else { this._showNoStreetViewMessage(); } // Hide Street View coverage layer if (this.streetViewLayerActive) { this._toggleStreetViewLayer(); } }); } // Reset dragging state this._endDragging(); }); // End drag if it leaves the map area this.map.getContainer().addEventListener('dragleave', (e) => { if (this.isDragging) { // Don't end dragging completely, just update marker visibility this.pegmanMarker.classList.remove('active'); } }); // Handle dragenter to show marker again when re-entering map this.map.getContainer().addEventListener('dragenter', (e) => { if (this.isDragging) { this.pegmanMarker.classList.add('active'); } }); // Handle the end of dragging this.pegman.addEventListener('dragend', () => { this._endDragging(); }); // MOBILE TOUCH SUPPORT // Handle touchstart on pegman for mobile drag this.pegman.addEventListener('touchstart', (e) => { // Prevent default to avoid scrolling the page while dragging pegman e.preventDefault(); // Disable map pointer events when touching pegman this.map.getCanvas().style.pointerEvents = 'none'; // Track touch start to distinguish between tap and drag this.touchStartTime = Date.now(); this.touchMoved = false; this.isTouchInteraction = true; // Don't immediately start dragging - will be determined in touchmove // or touchend based on movement }, { passive: false }); // Handle touchmove for updating pegman marker position document.addEventListener('touchmove', (e) => { if (this.isTouchInteraction) { // Mark that touch has moved - this means it's a drag, not a tap this.touchMoved = true; // Only now start the dragging if we haven't already if (!this.isDragging) { this.isDragging = true; this.pegman.classList.add('dragging'); this.pegmanContainer.classList.add('dragging'); // Show and position the pegman marker this.pegmanMarker.classList.add('active'); // Make sure Street View coverage is visible when dragging starts if (!this.streetViewLayerActive) { this._toggleStreetViewLayer(); } } // Prevent page scrolling while dragging e.preventDefault(); const touch = e.touches[0]; if (touch) { this._updatePegmanMarkerPosition(touch); } } }, { passive: false }); // Handle touchend (drop) on map document.addEventListener('touchend', (e) => { // If this was a touch interaction if (this.isTouchInteraction) { // Re-enable map pointer events when touch ends this.map.getCanvas().style.pointerEvents = 'auto'; const touchDuration = Date.now() - this.touchStartTime; const touch = e.changedTouches[0]; // If the touch didn't move much and was short duration, it's a tap if (!this.touchMoved && touchDuration < 300) { // If the tap was on the pegman button, toggle street view layer if (touch && (this.pegman.contains(document.elementFromPoint(touch.clientX, touch.clientY)) || this.pegmanContainer.contains(document.elementFromPoint(touch.clientX, touch.clientY)))) { this._toggleStreetViewLayer(); e.preventDefault(); } } // Otherwise it was a drag, so handle drag ending else if (this.isDragging) { if (touch) { const mapContainer = this.map.getContainer(); const rect = mapContainer.getBoundingClientRect(); // Check if touch ended inside the map container if (touch.clientX >= rect.left && touch.clientX <= rect.right && touch.clientY >= rect.top && touch.clientY <= rect.bottom) { // Convert touch point to map coordinates const x = touch.clientX - rect.left; const y = touch.clientY - rect.top; // Convert to geographic coordinates const coords = this.map.unproject([x, y]); // Check if Street View is available at this location this._checkStreetViewAvailability(coords.lat, coords.lng, (isAvailable) => { if (isAvailable) { this._openStreetView(coords.lat, coords.lng); } else { this._showNoStreetViewMessage(); } // Hide Street View coverage layer if (this.streetViewLayerActive) { this._toggleStreetViewLayer(); } }); } } } // Reset touch and drag states this._endDragging(); this.touchMoved = false; this.isTouchInteraction = false; } }, { passive: false }); // Handle map clicks to open Street View when coverage layer is active this.map.on('click', (e) => { if (this.streetViewLayerActive) { // Get clicked coordinates const coords = e.lngLat; // Check if Street View is available at this location this._checkStreetViewAvailability(coords.lat, coords.lng, (isAvailable) => { if (isAvailable) { this._openStreetView(coords.lat, coords.lng); // Hide Street View coverage layer after opening Street View if (this.streetViewLayerActive) { this._toggleStreetViewLayer(); } } else { this._showNoStreetViewMessage(); } }); } }); // Close street view when clicking the close button - enhanced for mobile this.closeStreetView.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); this.streetView.style.display = 'none'; this.streetViewIframe.src = ''; }); // Add touch-specific event for mobile devices this.closeStreetView.addEventListener('touchend', (e) => { e.preventDefault(); e.stopPropagation(); this.streetView.style.display = 'none'; this.streetViewIframe.src = ''; }, { passive: false }); } /** * Handle map load event * @private */ _onMapLoad() { // Add Street View coverage source as a raster source this.map.addSource('street-view-coverage', { type: 'raster', tiles: ['https://mt1.googleapis.com/vt?lyrs=svv&style=40&x={x}&y={y}&z={z}'], tileSize: 256, attribution: '© Google' }); // Add Street View coverage layer (hidden by default) this.map.addLayer({ id: 'street-view-coverage-layer', type: 'raster', source: 'street-view-coverage', layout: { 'visibility': 'none' // Hidden by default }, paint: { 'raster-opacity': 0.8 } }); // Position the pegman button below the MapLibre controls this._positionPegmanButton(); } /** * Position pegman button below MapLibre controls * @private */ _positionPegmanButton() { if (!this.showPegmanButton) return; // Find the MapLibre navigation control container const mapControls = document.querySelector('.maplibregl-ctrl-top-right'); if (mapControls) { // Get the bottom position of the map controls const controlsRect = mapControls.getBoundingClientRect(); // Position the pegman button below the controls with 10px spacing this.pegmanContainer.style.top = (controlsRect.bottom + 10) + 'px'; this.pegmanContainer.style.right = '10px'; } } /** * Toggle Street View coverage layer * @private */ _toggleStreetViewLayer() { if (!this.map.isStyleLoaded() || !this.map.getLayer('street-view-coverage-layer')) { return; } this.streetViewLayerActive = !this.streetViewLayerActive; try { // Update layer visibility this.map.setLayoutProperty( 'street-view-coverage-layer', 'visibility', this.streetViewLayerActive ? 'visible' : 'none' ); // Update pegman container state if (this.streetViewLayerActive) { this.pegmanContainer.classList.add('streetview-layer-active'); this.pegman.classList.add('active'); } else { this.pegmanContainer.classList.remove('streetview-layer-active'); this.pegman.classList.remove('active'); } } catch (error) { console.error("Error toggling Street View coverage:", error); } } /** * Update pegman marker position during drag * @private * @param {Event|Touch} e - Mouse event or Touch object */ _updatePegmanMarkerPosition(e) { if (!this.isDragging || !this.pegmanMarker) return; // Support both mouse events and touch objects const clientX = e.clientX !== undefined ? e.clientX : e.pageX; const clientY = e.clientY !== undefined ? e.clientY : e.pageY; this.pegmanMarker.style.left = (clientX - 10) + 'px'; this.pegmanMarker.style.top = (clientY - 30) + 'px'; } /** * End dragging state * @private */ _endDragging() { this.isDragging = false; this.pegman.classList.remove('dragging'); this.pegmanContainer.classList.remove('dragging'); if (this.pegmanMarker) { this.pegmanMarker.classList.remove('active'); } // Reset touch states too this.touchMoved = false; this.isTouchInteraction = false; } /** * Check if Street View is available at a given location * @private * @param {number} lat - Latitude * @param {number} lng - Longitude * @param {Function} callback - Callback function */ _checkStreetViewAvailability(lat, lng, callback) { // Create a new request to the Google Street View API const checkUrl = `https://maps.googleapis.com/maps/api/streetview/metadata?location=${lat},${lng}&key=${this.apiKey}`; fetch(checkUrl) .then(response => response.json()) .then(data => { // If status is OK, Street View is available const isAvailable = data.status === 'OK'; callback(isAvailable); }) .catch(error => { console.error("Error checking Street View availability:", error); callback(false); // Assume not available on error }); } /** * Show a message when Street View is not available * @private */ _showNoStreetViewMessage() { // Create notification element if it doesn't exist let notification = document.getElementById('sv-notification'); if (!notification) { notification = document.createElement('div'); notification.id = 'sv-notification'; notification.className = 'maplibre-streetview-notification'; document.body.appendChild(notification); } // Set message and show notification notification.textContent = 'Street View not available at this location'; notification.style.display = 'block'; // Hide notification after 3 seconds setTimeout(() => { notification.style.display = 'none'; }, 3000); } /** * Open Street View at given coordinates * @private * @param {number} lat - Latitude * @param {number} lng - Longitude */ _openStreetView(lat, lng) { // Create Google Street View URL that works with the embedded iframe const streetViewUrl = `https://www.google.com/maps/embed/v1/streetview?key=${this.apiKey}&location=${lat},${lng}&heading=0&pitch=0&fov=90`; // Set the src and add allow attribute for required permissions this.streetViewIframe.src = streetViewUrl; this.streetViewIframe.setAttribute('allow', 'accelerometer; gyroscope; geolocation'); this.streetView.style.display = 'block'; } /** * Remove the Street View plugin * Useful for cleaning up when the map is being destroyed */ remove() { // Remove event listeners window.removeEventListener('resize', this._positionPegmanButton); // Remove DOM elements if (this.pegmanContainer && this.pegmanContainer.parentNode) { this.pegmanContainer.parentNode.removeChild(this.pegmanContainer); } if (this.streetView && this.streetView.parentNode) { this.streetView.parentNode.removeChild(this.streetView); } if (this.pegmanMarker && this.pegmanMarker.parentNode) { this.pegmanMarker.parentNode.removeChild(this.pegmanMarker); } // Remove map layers and sources if (this.map.getLayer('street-view-coverage-layer')) { this.map.removeLayer('street-view-coverage-layer'); } if (this.map.getSource('street-view-coverage')) { this.map.removeSource('street-view-coverage'); } } } // Export the class as default export default MapLibreGoogleStreetView;