Skip to main content

Overview

S-Parking uses the Google Maps JavaScript API with Advanced Markers for high-performance rendering of parking spots. The integration includes custom map styling, dynamic clustering, and interactive overlays.

Map Initialization

The map/core.js module handles Maps API loading and initialization with robust fallback support.

API Loading Strategy

map/core.js
export function loadGoogleMapsAPI() {
    return new Promise((resolve, reject) => {
        // Check if modern API is already loaded
        if (window.google && window.google.maps && 
            typeof google.maps.importLibrary === 'function') {
            logger.debug("✅ API de Maps moderna detectada en caché.");
            resolve();
            return;
        }

        // Dynamic callback for async loading
        const callbackName = `initMap_${Date.now()}`;
        window[callbackName] = () => {
            logger.debug("✅ API de Maps cargada exitosamente vía Callback.");
            delete window[callbackName];
            resolve();
        };

        // Inject script tag
        const script = document.createElement("script");
        script.src = `https://maps.googleapis.com/maps/api/js?key=${CONFIG.GOOGLE_MAPS_API_KEY}&map_ids=${CONFIG.GOOGLE_MAPS_ID}&loading=async&v=weekly&callback=${callbackName}`;
        script.async = true;
        script.defer = true;
        script.onerror = reject;
        
        document.head.appendChild(script);
    });
}
The v=weekly parameter ensures you always get the latest stable Google Maps features.

Map Instance Creation

map/core.js
export async function initMap(containerId) {
    await loadGoogleMapsAPI();
    
    const mapElement = document.getElementById(containerId);
    if (!mapElement) throw new Error(`Element ${containerId} not found`);

    // Modern API with importLibrary
    const { Map } = await google.maps.importLibrary("maps");
    const { AdvancedMarkerElement, PinElement } = 
        await google.maps.importLibrary("marker");
    await google.maps.importLibrary("geometry");

    // Store in module state
    mapState.AdvancedMarkerElement = AdvancedMarkerElement;
    mapState.PinElement = PinElement;
    mapState.geometry = google.maps.geometry;

    mapState.map = new Map(mapElement, {
        center: { lat: -33.43306733282499, lng: -70.61471532552095 },
        zoom: 19,
        mapId: CONFIG.GOOGLE_MAPS_ID,
        tilt: 0,
        disableDefaultUI: true,
        zoomControl: false,
        rotateControl: true,
        gestureHandling: 'greedy'
    });

    mapState.infoWindow = new google.maps.InfoWindow();
    return mapState;
}

Map State Object

map/core.js
export const mapState = {
    map: null,                      // Google Maps instance
    AdvancedMarkerElement: null,    // Advanced Marker class
    PinElement: null,               // Pin Element class
    geometry: null,                 // Geometry library
    infoWindow: null                // Shared InfoWindow
};

Why Advanced Markers?

Advanced Markers provide better performance than legacy markers:
  • Hardware-accelerated rendering
  • Custom HTML content support
  • Built-in collision detection
  • Smoother animations

Custom Marker System

The map/markers.js module implements a sophisticated marker management system with caching and dynamic scaling.

Marker Caching

map/markers.js
const markersCache = {};       // { spotId: AdvancedMarkerElement }
const zoneMarkersCache = {};   // { zoneId: AdvancedMarkerElement }

function renderSpotMarkers(spots, onMarkerClick) {
    // Track active spot IDs
    const activeIds = new Set(spots.map(s => s.id));

    // Remove deleted markers
    Object.keys(markersCache).forEach(id => {
        if (!activeIds.has(id)) {
            markersCache[id].map = null;
            delete markersCache[id];
        }
    });

    // Create or update markers
    spots.forEach(spot => {
        const pinContent = document.createElement('div');
        pinContent.className = getPinClass(spot.status);

        if (markersCache[spot.id]) {
            // Update existing
            const marker = markersCache[spot.id];
            marker.content.className = pinContent.className;
            marker.position = { lat: spot.lat, lng: spot.lng };
        } else {
            // Create new
            const marker = new mapState.AdvancedMarkerElement({
                map: mapState.map,
                position: { lat: spot.lat, lng: spot.lng },
                content: pinContent,
                title: spot.id
            });

            marker.addListener('click', () => {
                showMiniInfoWindow(marker, spot);
                if (onMarkerClick) onMarkerClick(spot);
            });

            markersCache[spot.id] = marker;
        }
    });
}

Pin Styling

map/markers.js
function getPinClass(status) {
    const base = 'parking-pin';
    if (status === 1) return `${base} pin-free`;      // Green
    if (status === 0) return `${base} pin-occupied`;  // Red
    if (status === 2) return `${base} pin-reserved`;  // Amber
    return `${base} pin-unknown`;
}
Corresponding CSS:
styles.css
.parking-pin {
    width: 24px;
    height: 24px;
    border-radius: 50% 50% 50% 0;
    transform: rotate(-45deg);
    border: 2px solid white;
    box-shadow: 0 2px 8px rgba(0,0,0,0.3);
    transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}

.pin-free {
    background: linear-gradient(135deg, #10b981 0%, #059669 100%);
}

.pin-occupied {
    background: linear-gradient(135deg, #f43f5e 0%, #e11d48 100%);
}

.pin-reserved {
    background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
}

.pin-highlight {
    animation: pulse 1.5s ease-in-out infinite;
}

@keyframes pulse {
    0%, 100% { transform: rotate(-45deg) scale(1); }
    50% { transform: rotate(-45deg) scale(1.3); }
}

Dynamic Zoom Clustering

To prevent visual clutter at low zoom levels, the system switches between individual spots and zone clusters:
map/markers.js
const CLUSTER_ZOOM_THRESHOLD = 17;

export function updateClusterView(spots, zones, onMarkerClick) {
    if (!mapState.map) return;
    
    const zoom = mapState.map.getZoom();
    
    if (zoom >= CLUSTER_ZOOM_THRESHOLD) {
        // High zoom: show individual spots
        renderSpotMarkers(spots, onMarkerClick);
        Object.values(zoneMarkersCache).forEach(m => { m.map = null; });
        updateMarkerScale();
    } else {
        // Low zoom: show zone clusters
        Object.values(markersCache).forEach(m => { m.map = null; });
        renderZoneMarkers(spots, zones);
    }
}

Zone Cluster Markers

map/markers.js
function renderZoneMarkers(spots, zones) {
    zones.forEach(zone => {
        const zoneSpots = spots.filter(s => s.zone_id === zone.id);
        if (!zoneSpots.length) return;

        const free = zoneSpots.filter(s => s.status === 1).length;
        const occupied = zoneSpots.filter(s => s.status === 0 || s.status === 2).length;

        // Calculate center point
        const avgLat = zoneSpots.reduce((acc, s) => acc + s.lat, 0) / zoneSpots.length;
        const avgLng = zoneSpots.reduce((acc, s) => acc + s.lng, 0) / zoneSpots.length;

        const content = document.createElement('div');
        content.className = 'zone-marker';
        content.innerHTML = `
            <div class="zone-title">${zone.name || zone.id}</div>
            <div class="zone-counts">
                <span class="zone-free">Libre: ${free}</span> • 
                <span class="zone-occupied">Ocupado: ${occupied}</span>
            </div>
        `;

        if (zoneMarkersCache[zone.id]) {
            const marker = zoneMarkersCache[zone.id];
            marker.position = { lat: avgLat, lng: avgLng };
            marker.content.innerHTML = content.innerHTML;
            if (!marker.map) marker.map = mapState.map;
        } else {
            const marker = new mapState.AdvancedMarkerElement({
                map: mapState.map,
                position: { lat: avgLat, lng: avgLng },
                content,
                title: zone.name
            });
            zoneMarkersCache[zone.id] = marker;
        }
    });
}
Individual parking spots visible with color-coded pins:
  • Green: Available
  • Red: Occupied
  • Amber: Reserved

Zoom-Based Marker Scaling

Markers dynamically scale to remain visible at different zoom levels:
map/markers.js
function updateMarkerScale() {
    if (!mapState.map) return;
    const zoom = mapState.map.getZoom();
    
    let scale = 1;
    if (zoom >= 19) scale = 1.0;
    else if (zoom >= 18) scale = 0.8;
    else if (zoom >= 17) scale = 0.6;
    else if (zoom >= 16) scale = 0.45;
    else if (zoom >= 15) scale = 0.3;
    else scale = 0.2;
    
    Object.values(markersCache).forEach(marker => {
        if (marker.content) {
            marker.content.style.transform = `scale(${scale})`;
        }
    });
}

function attachZoomListener() {
    if (isZoomListenerAttached || !mapState.map) return;
    
    mapState.map.addListener('zoom_changed', () => {
        updateMarkerScale();
    });
    
    isZoomListenerAttached = true;
    updateMarkerScale();
}

Interactive InfoWindows

map/markers.js
function showMiniInfoWindow(marker, spot) {
    if (!mapState.infoWindow) return;

    let statusText = spot.status === 1 ? 'Libre' : 
                     spot.status === 0 ? 'Ocupado' : 'Reservado';
    
    const content = `
        <div class="px-2 py-1 text-center">
            <h3 class="font-bold text-slate-800">${spot.id}</h3>
            <p class="text-xs text-slate-500">${statusText}</p>
        </div>
    `;
    
    mapState.infoWindow.setContent(content);
    mapState.infoWindow.open(mapState.map, marker);
}

Map Event Listeners

The main application sets up map click handlers for admin features:
main.js
const mapObj = MapCore.mapState.map;

mapObj.addListener('click', (e) => {
    const latLng = e.latLng;
    
    if (state.isAdminMode && state.isBuilderMode) {
        // Builder mode: multi-spot line tool
        const result = MapBuilder.handleMapClick(latLng);
        if (result && result.start && result.end) {
            showLineBuilderConfig(result.start, result.end);
        }
    } else if (state.isAdminMode) {
        // Admin mode: single spot creation
        createSingleSpot(latLng);
    }
});

mapObj.addListener('zoom_changed', () => {
    MapMarkers.updateClusterView(state.spots, state.zones, handleSpotClick);
});

Map Styling

Custom map styles are configured via the Maps ID in Google Cloud Console. Key features:
  • Minimal POI labels
  • High contrast roads
  • Satellite hybrid view for parking lot context

Performance Considerations

Marker Limit

Problem: Rendering 1000+ markers causes lagSolution: Cluster view below zoom 17 reduces visible markers by ~90%

Update Efficiency

Problem: Re-rendering all markers on every data updateSolution: Cache-based diffing only updates changed markers

Memory Management

Problem: Marker instances accumulate in memorySolution: Setting marker.map = null properly disposes markers

Builder Mode

Learn about the visual spot builder tool

Dashboard

Understand the overall application architecture

Build docs developers (and LLMs) love