Skip to content

Location Analysis

Advanced SmartMaps GL Geocoding Weather Timezone Elevation Area Vector Tiles

This use case demonstrates a location intelligence dashboard that combines multiple SmartMaps APIs to analyze any point on the map. By clicking on the map or using geolocation, the dashboard displays detailed information including the address, current weather conditions, local timezone, elevation, administrative area boundaries, and nearby infrastructure such as supermarkets, schools, doctors, kindergartens, ATMs, and public transport stops.

How it works

The demo follows a two-phase analysis flow:

Phase 1 -- Overview analysis (triggered by a map click or geolocation):

  1. A marker is placed at the clicked location.
  2. Five API calls fire in parallel via Promise.all: Reverse Geocoding, Weather, Timezone, Elevation, and Area.
  3. The dashboard renders four sections (Infrastructure, Address, Geo Information, Weather & Time) from the results.
  4. The administrative area polygon is drawn on the map.

Phase 2 -- Detailed POI analysis (triggered by the "Umfeld-Analyse starten" button, or automatically on geolocation):

  1. The map flies to zoom level 14.5 centred on the location.
  2. The postal code area (admin level 0) is fetched and the polygon is updated.
  3. Turf.js creates a 1 km buffer around the point.
  4. querySourceFeatures reads POI and transport data directly from the loaded vector tiles.
  5. Matching features are classified into categories and rendered as map markers with popups.

API endpoints used

API Endpoint Purpose
Geocoding (Reverse) geocoder.geocodeReverse() Resolve coordinates to a street address
Weather weather.smartmaps.cloud/api/v2/weather/point Current temperature and weather code
Timezone timezone.smartmaps.cloud/api/v1/timezone/current/point Local time and timezone abbreviation
Elevation elevation.smartmaps.cloud/api/v2/Elevation/point Altitude in metres (POST)
Area areaService.point() Administrative boundary polygon
Vector Tiles map.querySourceFeatures('smartmaps', ...) POI & transport layers from loaded tiles

Code

/*
 * ============================================================
 * SmartMaps Location Analysis Dashboard
 * ============================================================
 *
 * Purpose:
 *   Interactive single-page demo that analyses any point on the
 *   map. A click (or geolocation) triggers parallel API calls
 *   and renders a rich dashboard with address, elevation, area
 *   boundaries, weather, timezone, and nearby infrastructure.
 *
 * APIs used:
 *   - Geocoding API (reverse)  -- resolve coordinates to address
 *   - Weather API              -- current weather at location
 *   - Timezone API             -- local time & timezone info
 *   - Elevation API            -- altitude in metres
 *   - Area API                 -- administrative boundary polygon
 *   - Vector Tiles (POI /      -- query nearby points of interest
 *     Transport layers)           directly from the map source
 *
 * External libraries:
 *   - SmartMaps GL (map rendering)
 *   - SmartMaps Geocoding, Area (service wrappers)
 *   - Turf.js (client-side spatial buffering)
 *
 * Architecture:
 *   1. User clicks the map or uses geolocation.
 *   2. startAnalysis() fires five API calls in parallel via
 *      Promise.all and renders the dashboard.
 *   3. Optionally (on button click or geolocation) the map
 *      zooms in and loadDetailedAnalysis() queries vector tile
 *      POIs within a 1 km buffer.
 * ============================================================
 */

// -----------------------------------------------------------------
// Constants
// -----------------------------------------------------------------

/** Zoom level used when flying to a location for detailed analysis */
const DETAIL_ZOOM = 14.5;

/** Animation speed for map.flyTo transitions */
const FLY_TO_SPEED = 1.5;

/** Radius in kilometres for the POI search buffer */
const POI_SEARCH_RADIUS_KM = 1;

/**
 * Zoom-to-admin-level thresholds.
 * Each entry is [maxZoom, adminLevel]. The first matching
 * threshold (zoom < maxZoom) determines the admin level.
 * The final entry (Infinity) serves as the default fallback.
 */
const ADMIN_LEVEL_THRESHOLDS = [
    [5, 2],   // zoom < 5  -> country (level 2)
    [8, 4],   // zoom < 8  -> state   (level 4)
    [11, 6],  // zoom < 11 -> district (level 6)
    [13, 8],  // zoom < 13 -> municipality (level 8)
    [Infinity, 0] // zoom >= 13 -> postal code (level 0)
];

/** Human-readable labels for Area API admin levels */
const ADMIN_LEVEL_LABELS = {
    0: 'PLZ-Gebiet',
    2: 'Staat',
    4: 'Bundesland',
    6: 'Kreis/Reg.bezirk',
    8: 'Gemeinde',
    9: 'Ortsteil'
};

/** Maps WMO weather codes to icon file names */
const WEATHER_ICON_MAPPING = {
    0: 'ic_day_sunny', 1: 'ic_day_sunny', 2: 'ic_day_partlycloudy',
    3: 'ic_day_cloudy', 45: 'ic_day_fog', 48: 'ic_day_fog',
    51: 'ic_day_sprinkle', 53: 'ic_day_sprinkle', 55: 'ic_day_sprinkle',
    80: 'ic_day_showers', 81: 'ic_day_showers', 82: 'ic_day_showers',
    61: 'ic_day_rain', 63: 'ic_day_rain', 65: 'ic_day_rain',
    56: 'ic_day_rain_mix', 57: 'ic_day_rain_mix',
    66: 'ic_day_rain_mix', 67: 'ic_day_rain_mix',
    71: 'ic_day_snow', 73: 'ic_day_snow', 75: 'ic_day_snow',
    77: 'ic_day_snowflake_cold', 85: 'ic_day_snow', 86: 'ic_day_snow',
    95: 'ic_day_thunderstorm',
    96: 'ic_day_storm_showers', 99: 'ic_day_storm_showers'
};

// -----------------------------------------------------------------
// Utility helpers
// -----------------------------------------------------------------

/**
 * Read a CSS custom property from the document root.
 * @param {string} variable - CSS variable name including '--' prefix
 * @returns {string} The trimmed property value
 */
function getCssVariable(variable) {
    return getComputedStyle(document.documentElement).getPropertyValue(variable).trim();
}

/**
 * Determine the Area API admin level based on the current map zoom.
 * Uses the ADMIN_LEVEL_THRESHOLDS lookup table.
 * @param {number} zoom - Current map zoom level
 * @returns {number} The admin level to request from the Area API
 */
function getAdminLevel(zoom) {
    for (const [maxZoom, level] of ADMIN_LEVEL_THRESHOLDS) {
        if (zoom < maxZoom) return level;
    }
    return 0; // fallback: postal code level
}

// -----------------------------------------------------------------
// Initialisation
// -----------------------------------------------------------------

const apiKey = '[INSERT API-KEY]';

const map = new smartmapsgl.Map({
    container: 'map', apiKey: apiKey, style: smartmapsgl.MapStyle.ESSENTIAL,
    center: [10, 51], zoom: 6
});

const geocoder = new smartmaps.geocodingService.Geocoder(apiKey);

const dashboard = document.getElementById('dashboard');
const dashboardContent = document.getElementById('dashboard-content');
const promptEl = document.getElementById('prompt');
let analysisMarker = null;
let poiMarkers = [];

/** POI category definitions with source layer, OSM filter, icon, and running count */
const poiCategories = [
    { name: 'Supermärkte', source: 'poi', osmFilter: p => p.shop === 'supermarket', icon: 'storefront', count: 0 },
    { name: 'Schulen', source: 'poi', osmFilter: p => p.amenity === 'school', icon: 'school', count: 0 },
    { name: 'Ärzte', source: 'poi', osmFilter: p => p.amenity === 'doctors' || p.amenity === 'clinic', icon: 'medical_services', count: 0 },
    { name: 'Kindergärten', source: 'poi', osmFilter: p => p.amenity === 'kindergarten', icon: 'child_care', count: 0 },
    { name: 'Geldautomaten', source: 'poi', osmFilter: p => p.amenity === 'atm' || p.atm === 'yes' || p.atm === true, icon: 'atm', count: 0 },
    { name: 'Bus-Haltestellen', source: 'transport', osmFilter: p => p.kind === 'bus_stop', icon: 'directions_bus', count: 0 },
    { name: 'Bahn-Haltestellen', source: 'transport', osmFilter: p => ['station', 'tram_stop', 'halt'].includes(p.kind), icon: 'tram', count: 0 },
];

// -----------------------------------------------------------------
// Geolocation control
// -----------------------------------------------------------------

const geolocateControl = new smartmapsgl.GeolocateControl({
    positionOptions: { enableHighAccuracy: true },
    trackUserLocation: false,
    showUserLocation: true
});
map.addControl(geolocateControl, 'top-right');
geolocateControl._geolocateButton.style.display = 'none';

/** Trigger geolocation via the custom button */
document.getElementById('geolocate-button').addEventListener('click', () => {
    geolocateControl.trigger();
});

/** When the browser reports the user position, start analysis immediately */
geolocateControl.on('geolocate', async (position) => {
    const coords = [position.coords.longitude, position.coords.latitude];
    await startAnalysis({ lng: coords[0], lat: coords[1] }, true);
});

// -----------------------------------------------------------------
// Map click handler
// -----------------------------------------------------------------

/** Start analysis on map click, ignoring clicks on markers, popups, or the dashboard */
map.on('click', (event) => {
    const targetElement = event.originalEvent.target;
    if (targetElement.closest('.poi-marker') || targetElement.closest('.smartmapsgl-popup') || targetElement.closest('.dashboard')) {
        return;
    }
    startAnalysis(event.lngLat, false);
});

/** Close the dashboard and reset the map */
document.getElementById('close-dashboard-btn').addEventListener('click', () => {
    dashboard.classList.remove('visible');
    clearMap();
    promptEl.classList.remove('hidden');
});

// -----------------------------------------------------------------
// Map helpers
// -----------------------------------------------------------------

/**
 * Remove the analysis marker, all POI markers, and the area polygon
 * from the map, resetting to a clean state.
 */
function clearMap() {
    if (analysisMarker) {
        analysisMarker.remove();
        analysisMarker = null;
    }
    poiMarkers.forEach(marker => marker.remove());
    poiMarkers = [];
    if (map.getSource('area-source')) {
        map.removeLayer('area-fill');
        map.removeLayer('area-outline');
        map.removeSource('area-source');
    }
}

/**
 * Calculate map padding so that the fly-to animation avoids the
 * dashboard panel. Returns different values for mobile vs desktop.
 * @returns {{top: number, bottom: number, left: number, right: number}}
 */
function getMapPadding() {
    if (window.innerWidth <= 600) {
        const panel = document.getElementById('dashboard');
        const panelHeight = panel ? panel.offsetHeight : 300;
        return { top: 80, bottom: panelHeight + 20, left: 20, right: 20 };
    } else {
        return { top: 40, bottom: 40, left: 460, right: 40 };
    }
}

/**
 * Draw (or replace) the administrative area polygon on the map.
 * Adds a semi-transparent fill and an outline layer.
 * @param {Object} areaData - GeoJSON FeatureCollection from the Area API
 */
function drawAreaPolygon(areaData) {
    if (map.getSource('area-source')) {
        map.removeLayer('area-fill');
        map.removeLayer('area-outline');
        map.removeSource('area-source');
    }
    if (!areaData || !areaData.features || !areaData.features.length === 0) return;

    map.addSource('area-source', { type: 'geojson', data: areaData });
    map.addLayer({ id: 'area-fill', type: 'fill', source: 'area-source', paint: { 'fill-color': getCssVariable('--brand-secondary'), 'fill-opacity': 0.15 } });
    map.addLayer({ id: 'area-outline', type: 'line', source: 'area-source', paint: { 'line-color': getCssVariable('--brand-secondary'), 'line-width': 2 } });
}

// -----------------------------------------------------------------
// Main analysis flow
// -----------------------------------------------------------------

/**
 * Entry point for location analysis. Places a marker, fires five
 * API calls in parallel, and renders the dashboard. Optionally
 * zooms in for a detailed POI analysis.
 * @param {{lng: number, lat: number}} lngLat - Clicked / geolocated position
 * @param {boolean} [startDetailedImmediately=false] - If true, zoom in and load POIs right away
 */
async function startAnalysis(lngLat, startDetailedImmediately = false) {
    clearMap();
    promptEl.classList.add('hidden');
    const coords = [lngLat.lng, lngLat.lat];

    dashboardContent.innerHTML = '<div class="loader"></div><p style="text-align: center;">Analysiere Standort...</p>';
    dashboard.classList.add('visible');

    analysisMarker = new smartmapsgl.Marker({ color: getCssVariable('--brand-accent') }).setLngLat(coords).addTo(map);

    const adminLevel = getAdminLevel(map.getZoom());

    // Fire all API requests in parallel
    const reverseGeocodePromise = geocoder.geocodeReverse({ lat: lngLat.lat, lng: lngLat.lng });
    const weatherPromise = fetch(`https://weather.smartmaps.cloud/api/v2/weather/point?Longitude=${lngLat.lng}&Latitude=${lngLat.lat}&ApiKey=${smartmapsgl.encodeString(apiKey)}`).then(res => res.json());
    const timezonePromise = fetch(`https://timezone.smartmaps.cloud/api/v1/timezone/current/point?Longitude=${lngLat.lng}&Latitude=${lngLat.lat}&ApiKey=${smartmapsgl.encodeString(apiKey)}`).then(res => res.json());
    const elevationPromise = fetch(`https://elevation.smartmaps.cloud/api/v2/Elevation/point?apiKey=${smartmapsgl.encodeString(apiKey)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ points: [{ latitude: lngLat.lat, longitude: lngLat.lng }] }) }).then(res => res.json());

    const areaService = new smartmaps.areaService.Area(apiKey);
    const areaPromise = areaService.point({ point: { latitude: coords[1], longitude: coords[0] }, level: adminLevel });

    try {
        const [geoData, weatherData, timeData, elevData, areaData] = await Promise.all([reverseGeocodePromise, weatherPromise, timezonePromise, elevationPromise, areaPromise]);

        updateDashboardUI({ geoData, weatherData, timeData, elevData, areaData }, coords, startDetailedImmediately);
        drawAreaPolygon(areaData);

        if (startDetailedImmediately) {
            map.flyTo({ center: coords, zoom: DETAIL_ZOOM, speed: FLY_TO_SPEED, padding: getMapPadding() });
            map.once('idle', () => {
                loadDetailedAnalysis(coords);
            });
        }

    } catch (error) {
        console.error("Location analysis failed:", error);
        dashboardContent.innerHTML = `<p style="color: ${getCssVariable('--error-color')};">Analyse fehlgeschlagen. Bitte versuchen Sie es erneut.</p>`;
    }
}

// -----------------------------------------------------------------
// Dashboard section renderers
// -----------------------------------------------------------------

/**
 * Render the Infrastructure section of the dashboard.
 * Shows either a loading spinner or the "Start environment analysis" button.
 * @param {number[]} coords - [lng, lat] of the analysis point
 * @param {boolean} startDetailedImmediately - Whether detailed analysis starts automatically
 * @returns {string} HTML string for the infrastructure section
 */
function renderInfrastructureSection(coords, startDetailedImmediately) {
    let html = `<div class="info-section" id="detail-analysis-section">
        <div class="section-title">
            <h2><span class="material-icons">business</span>Infrastruktur</h2>
             <span class="info-icon"><i class="material-icons">info</i><span class="tooltip">Points of Interest (POIs) aus den <strong>Vector Tiles</strong> im Umkreis von 1km.</span></span>
        </div>`;
    if (startDetailedImmediately) {
        html += `<div class="loader"></div><p style="text-align: center;">Lade Details...</p>`;
    } else {
        html += `
        <button id="analyze-environment-button" data-coords="${coords.join(',')}">
            <span class="material-icons">travel_explore</span>Umfeld-Analyse starten
        </button>
        <p style="font-size: 0.9em; text-align: center; margin-top: 10px; color: var(--text-light);">Zoomt auf den Standort und lädt POIs im Umkreis.</p>`;
    }
    html += `</div>`;
    return html;
}

/**
 * Render the Address section of the dashboard using reverse geocoding results.
 * @param {Object} geoData - GeoJSON FeatureCollection from the Geocoding API
 * @returns {string} HTML string for the address section (empty if no data)
 */
function renderAddressSection(geoData) {
    if (!geoData.features?.[0]) return '';
    const p = geoData.features[0].properties;
    return `<div class="info-section">
        <div class="section-title">
            <h2><span class="material-icons">map</span>Adresse</h2>
            <span class="info-icon"><i class="material-icons">info</i><span class="tooltip">Nutzt die <strong>Geocoding API</strong> zur Ermittlung der Adresse.</span></span>
        </div>
        <div class="info-item"><span>Straße</span> <span>${p.street || '-'} ${p.houseNo || ''}</span></div>
        <div class="info-item"><span>Ort</span> <span>${p.zip || ''} ${p.city || 'N/A'}</span></div>
        <div class="info-item"><span>Land</span> <span>${p.country || 'N/A'}</span></div>
    </div>`;
}

/**
 * Render the Geo Information section (elevation and area/region data).
 * @param {Object} elevData - GeoJSON FeatureCollection from the Elevation API
 * @param {Object} areaData - GeoJSON FeatureCollection from the Area API
 * @returns {string} HTML string for the geo information section
 */
function renderGeoInfoSection(elevData, areaData) {
    let html = `<div id="geo-info-section" class="info-section">
        <div class="section-title">
            <h2><span class="material-icons">public</span>Geo-Informationen</h2>
            <span class="info-icon"><i class="material-icons">info</i><span class="tooltip">Daten aus <strong>Elevation API</strong> (Höhe) und <strong>Area API</strong>. Die Gebietsebene wird automatisch je nach Zoomstufe gewählt.</span></span>
        </div>`;
    if (elevData.features?.[0]) {
        html += `<div class="info-item"><span>Höhe</span> <span>${elevData.features[0].properties.elevation.toFixed(2)} m</span></div>`;
    }

    if (areaData.features?.[0]) {
        const areaProps = areaData.features[0].properties;
        const level = areaProps.level;

        const levelLabel = ADMIN_LEVEL_LABELS[level] || `Gebiet (Level ${level})`;
        const areaName = areaProps.zip || areaProps.name || 'N/A';

        html += `<div class="info-item" id="region-info-item"><span>${levelLabel}</span> <span>${areaName}</span></div>`;
    }
    html += `</div>`;
    return html;
}

/**
 * Render the Weather & Time section of the dashboard.
 * @param {Object} weatherData - GeoJSON FeatureCollection from the Weather API
 * @param {Object} timeData - GeoJSON FeatureCollection from the Timezone API
 * @returns {string} HTML string for the weather & time section
 */
function renderWeatherTimeSection(weatherData, timeData) {
    let html = `<div class="info-section">
        <div class="section-title">
            <h2><span class="material-icons">wb_sunny</span>Wetter & Zeit</h2>
            <span class="info-icon"><i class="material-icons">info</i><span class="tooltip">Live-Daten aus der <strong>Weather API</strong> und <strong>Timezone API</strong>.</span></span>
        </div>`;
    if (weatherData.features?.[0]) {
        const weather = weatherData.features[0].properties.currently;
        const iconName = WEATHER_ICON_MAPPING[weather.weatherCode] || 'ic_day_sunny';
        const iconUrl = `https://docs.smartmaps.cloud/assets/images/weatherImages/${iconName}.svg`;
        html += `<div class="info-item"><span>Temperatur</span> <span class="weather-display"><img src="${iconUrl}" alt="Wetter-Icon" width="40" height="40">${weather.temperature2Meters.toFixed(1)} °C</span></div>`;
    }
    if (timeData.features?.[0]) {
        html += `<div class="info-item"><span>Lokale Zeit</span> <span>${timeData.features[0].properties.isoDateTimeText} (${timeData.features[0].properties.timezoneAbbreviation})</span></div>`;
    }
    html += `</div>`;
    return html;
}

/**
 * Assemble and inject all dashboard sections into the DOM.
 * Delegates to the individual section renderers.
 * @param {Object} data - Aggregated API response data
 * @param {Object} data.geoData - Reverse geocoding result
 * @param {Object} data.weatherData - Weather API result
 * @param {Object} data.timeData - Timezone API result
 * @param {Object} data.elevData - Elevation API result
 * @param {Object} data.areaData - Area API result
 * @param {number[]} coords - [lng, lat] of the analysis point
 * @param {boolean} startDetailedImmediately - Whether to auto-start detailed analysis
 */
function updateDashboardUI(data, coords, startDetailedImmediately) {
    const { geoData, weatherData, timeData, elevData, areaData } = data;

    let html = '';
    html += renderInfrastructureSection(coords, startDetailedImmediately);
    html += renderAddressSection(geoData);
    html += renderGeoInfoSection(elevData, areaData);
    html += renderWeatherTimeSection(weatherData, timeData);

    dashboardContent.innerHTML = html;
}

// -----------------------------------------------------------------
// "Analyse environment" button (delegated click handler)
// -----------------------------------------------------------------

/** Handle click on the dynamically created "Analyse environment" button */
document.body.addEventListener('click', (event) => {
    const button = event.target.closest('#analyze-environment-button');
    if (button) {
        button.innerHTML = '<div class="loader"></div>';
        button.disabled = true;

        const coords = button.dataset.coords.split(',').map(Number);

        map.flyTo({ center: coords, zoom: DETAIL_ZOOM, speed: FLY_TO_SPEED, padding: getMapPadding() });

        map.once('idle', () => {
            loadDetailedAnalysis(coords);
        });
    }
});

// -----------------------------------------------------------------
// Detailed POI analysis
// -----------------------------------------------------------------

/**
 * Fetch the postal code area polygon and draw it on the map.
 * Also updates the region info item in the Geo Information section.
 * @param {number[]} coords - [lng, lat] of the analysis point
 */
async function loadPostalCodeArea(coords) {
    const areaService = new smartmaps.areaService.Area(apiKey);
    try {
        const zipAreaData = await areaService.point({ point: { latitude: coords[1], longitude: coords[0] }, level: 0 });
        drawAreaPolygon(zipAreaData);
        const regionInfoItem = document.getElementById('region-info-item');
        if (regionInfoItem && zipAreaData.features?.[0]) {
            regionInfoItem.innerHTML = `<span>PLZ-Gebiet</span> <span>${zipAreaData.features[0].properties.zip}</span>`;
        }
    } catch (error) {
        console.error("Failed to load postal code area:", error);
    }
}

/**
 * Query the map's vector tile source for POI and transport features
 * within the search radius around the given coordinates.
 * @param {number[]} coords - [lng, lat] of the analysis point
 * @returns {{poiFeatures: Object[], transportFeatures: Object[]}} Matched features by source layer
 */
function queryNearbyPOIs(coords) {
    const centerPoint = turf.point(coords);
    const buffer = turf.buffer(centerPoint, POI_SEARCH_RADIUS_KM, { units: 'kilometers' });

    const poiFeatures = map.querySourceFeatures('smartmaps', {
        sourceLayer: 'poi',
        filter: ['within', buffer.geometry]
    });
    const transportFeatures = map.querySourceFeatures('smartmaps', {
        sourceLayer: 'transport',
        filter: ['within', buffer.geometry]
    });

    return { poiFeatures, transportFeatures };
}

/**
 * Process matched vector tile features: classify them into POI
 * categories, create map markers with popups, and update counts.
 * @param {Object[]} features - Array of GeoJSON features from querySourceFeatures
 * @param {string} sourceName - The source layer name ('poi' or 'transport')
 */
function createPOIMarkers(features, sourceName) {
    features.forEach(feature => {
        for (const category of poiCategories) {
            if (category.source === sourceName && category.osmFilter(feature.properties)) {

                const geometry = feature.geometry;
                let pointCoordinates = [];

                if (geometry.type === 'Point') { pointCoordinates.push(geometry.coordinates); }
                else if (geometry.type === 'MultiPoint') { pointCoordinates = geometry.coordinates; }

                pointCoordinates.forEach(coords => {
                    if (!Array.isArray(coords) || coords.length < 2 || isNaN(coords[0]) || isNaN(coords[1])) {
                        console.warn('Skipping feature with invalid coordinates:', coords, feature);
                        return;
                    }

                    category.count++;

                    const el = document.createElement('div');
                    el.className = 'poi-marker';
                    el.textContent = category.icon;

                    const marker = new smartmapsgl.Marker({ element: el })
                        .setLngLat(coords)
                        .setPopup(new smartmapsgl.Popup({ offset: 25 }).setText(feature.properties.name || category.name.slice(0, -1)))
                        .addTo(map);
                    poiMarkers.push(marker);
                });
                break;
            }
        }
    });
}

/**
 * Render the POI results into the Infrastructure section of the dashboard,
 * replacing the loading indicator with category counts.
 * @param {HTMLElement} detailSection - The DOM element for the infrastructure section
 */
function renderPOIResults(detailSection) {
    let html = `<div class="section-title"><h2><span class="material-icons">business</span>Infrastruktur</h2><span class="info-icon"><i class="material-icons">info</i><span class="tooltip">Points of Interest (POIs) aus den <strong>Vector Tiles</strong> im Umkreis von 1 km.</span></span></div>`;
    poiCategories.forEach(cat => {
        html += `<div class="info-item"><span>${cat.name}</span> <span>${cat.count}</span></div>`;
    });
    detailSection.innerHTML = html;
}

/**
 * Orchestrate the detailed analysis: load the postal code area,
 * query nearby POIs from vector tiles, create markers, and update
 * the dashboard.
 * @param {number[]} coords - [lng, lat] of the analysis point
 */
async function loadDetailedAnalysis(coords) {
    const detailSection = document.getElementById('detail-analysis-section');
    if (!detailSection) return;

    // Show loading state
    detailSection.innerHTML = `<div class="section-title"><h2><span class="material-icons">business</span>Infrastruktur</h2><span class="info-icon"><i class="material-icons">info</i><span class="tooltip">Points of Interest (POIs) aus den <strong>Vector Tiles</strong> im Umkreis von 1km.</span></span></div><div class="loader"></div><p style="text-align: center;">Lade PLZ-Gebiet & POIs...</p>`;

    // Step 1: Fetch and display postal code area polygon
    await loadPostalCodeArea(coords);

    // Step 2: Wait for tiles to render after the area polygon update
    await map.once('render');

    // Step 3: Query vector tiles for nearby POIs
    const { poiFeatures, transportFeatures } = queryNearbyPOIs(coords);

    // Step 4: Reset counts and create markers
    poiCategories.forEach(cat => cat.count = 0);
    createPOIMarkers(poiFeatures, 'poi');
    createPOIMarkers(transportFeatures, 'transport');

    // Step 5: Render results into the dashboard
    renderPOIResults(detailSection);
}
<body>
    <div id="map"></div>

    <button id="geolocate-button">
        <span class="material-icons">my_location</span>
        <span>Mein Standort</span>
    </button>

    <div id="prompt" class="initial-prompt">Klicken Sie auf die Karte, um einen Ort zu analysieren!</div>

    <div id="dashboard" class="dashboard">
        <div class="dashboard-header">
            <h1>Analyse</h1>
            <button id="close-dashboard-btn" class="close-button">&times;</button>
        </div>
        <div id="dashboard-content"></div>
    </div>
</body>
/* =================================================================== */
/* BASE STYLES (integrated from smartmaps-demo-styles.css)             */
/* =================================================================== */
:root {
    --brand-primary: #18345c;
    --brand-secondary: #3498db;
    --brand-accent: #f6b80c;
    --text-primary: #111827;
    --text-secondary: #374151;
    --text-light: #6b7280;
    --surface-bg: #f4f7fa;
    --surface-panel: #ffffff;
    --border-color: #e5e7eb;
    --success-color: #10b981;
    --error-color: #ef4444;
}

body {
    margin: 0;
    padding: 0;
    font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
    background-color: var(--surface-bg);
    color: var(--text-primary);
    -webkit-font-smoothing: antialiased;
    -moz-osx-font-smoothing: grayscale;
    overflow: hidden;
}

#map {
    position: absolute;
    top: 0;
    bottom: 0;
    width: 100%;
    height: 100%;
}

.loader {
    border: 4px solid rgba(0, 0, 0, 0.1);
    border-top: 4px solid var(--brand-secondary);
    border-radius: 50%;
    width: 20px;
    height: 20px;
    animation: spin 1s linear infinite;
    margin: 0 auto;
}

@keyframes spin {
    0% {
        transform: rotate(0deg);
    }

    100% {
        transform: rotate(360deg);
    }
}

.info-icon {
    position: relative;
    display: inline-flex;
    align-items: center;
    cursor: pointer;
    color: #9ca3af;
}

.info-icon .tooltip {
    visibility: hidden;
    width: 260px;
    background-color: #1f2937;
    color: #fff;
    text-align: left;
    border-radius: 6px;
    padding: 12px;
    position: absolute;
    z-index: 100;
    opacity: 0;
    transition: opacity 0.3s;
    font-size: 0.85rem;
    line-height: 1.5;
    font-weight: 400;
    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
    bottom: 130%;
    right: 0;
    left: auto;
    transform: translateX(0);
}

.info-icon .tooltip::after {
    content: "";
    position: absolute;
    top: 100%;
    right: 10px;
    left: auto;
    margin-left: 0;
    border-width: 5px;
    border-style: solid;
    border-color: #1f2937 transparent transparent transparent;
}

.info-icon:hover .tooltip {
    visibility: visible;
    opacity: 1;
}

.info-icon .tooltip strong {
    color: var(--brand-secondary);
}

/* =================================================================== */
/* DEMO-SPECIFIC STYLES                                                */
/* =================================================================== */

.dashboard-header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    padding-bottom: 15px;
    border-bottom: 1px solid var(--border-color);
    margin: 0 0 20px 0;
}

.close-button {
    background: #f1f5f9;
    border: none;
    color: #64748b;
    width: 32px;
    height: 32px;
    border-radius: 50%;
    font-size: 24px;
    line-height: 32px;
    text-align: center;
    cursor: pointer;
    transition: all 0.2s;
}

.close-button:hover {
    background: #e2e8f0;
    transform: rotate(90deg);
}

.info-section {
    margin-bottom: 20px;
}

.info-section+.info-section {
    border-top: 1px solid var(--border-color);
    padding-top: 20px;
}

.section-title {
    display: flex;
    justify-content: space-between;
    align-items: center;
    margin: 0 0 10px 0;
    padding-bottom: 8px;
    position: relative;
}

.section-title h2 {
    font-size: 1.1em;
    font-weight: 500;
    color: var(--text-secondary);
    margin: 0;
    display: flex;
    align-items: center;
    gap: 8px;
    border: none;
    padding: 0;
}

.section-title h2 .material-icons {
    color: var(--brand-primary);
}

.info-item {
    display: flex;
    justify-content: space-between;
    align-items: center;
    font-size: 1em;
    padding: 8px 4px;
    border-bottom: 1px solid #f3f4f6;
}

.info-item:last-child {
    border-bottom: none;
}

.info-item span:first-child {
    font-weight: 500;
    color: var(--text-light);
    margin-right: 10px;
}

.info-item span:last-child {
    color: var(--text-primary);
    font-weight: 500;
    text-align: right;
}

.weather-display {
    display: flex;
    align-items: center;
    justify-content: flex-end;
    gap: 10px;
}

.initial-prompt {
    position: absolute;
    top: 20px;
    left: 50%;
    transform: translateX(-50%);
    background: var(--brand-primary);
    color: white;
    padding: 15px 25px;
    border-radius: 50px;
    font-size: 1.1em;
    font-weight: 500;
    z-index: 1;
    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
    transition: opacity 0.5s, transform 0.5s;
}

.initial-prompt.hidden {
    opacity: 0;
    transform: translateX(-50%) translateY(-20px);
    pointer-events: none;
}

#geolocate-button {
    position: absolute;
    top: 20px;
    right: 20px;
    z-index: 2;
    background-color: var(--surface-panel);
    color: var(--brand-primary);
    border: 1px solid rgba(0, 0, 0, 0.1);
    border-radius: 12px;
    padding: 10px 15px;
    font-size: 1em;
    font-weight: 500;
    cursor: pointer;
    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
    display: flex;
    align-items: center;
    gap: 8px;
    transition: all 0.2s ease-in-out;
}

#geolocate-button:hover {
    transform: translateY(-2px);
    box-shadow: 0 6px 16px rgba(0, 0, 0, 0.15);
}

#analyze-environment-button {
    background-color: var(--brand-secondary);
    color: white;
    border: none;
    padding: 12px;
    border-radius: 8px;
    font-weight: 500;
    cursor: pointer;
    margin-top: 15px;
    width: 100%;
    font-size: 1em;
    transition: all 0.3s;
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 8px;
}

#analyze-environment-button:hover:not(:disabled) {
    background-color: #2980b9;
    transform: translateY(-2px);
}

#analyze-environment-button:disabled {
    background-color: #bdc3c7;
    cursor: not-allowed;
    transform: none;
}

#analyze-environment-button .loader {
    border-top-color: white;
}

.poi-marker {
    font-family: 'Material Icons';
    font-size: 20px;
    color: white;
    background: var(--brand-primary);
    border-radius: 50%;
    width: 30px;
    height: 30px;
    display: flex;
    justify-content: center;
    align-items: center;
    cursor: pointer;
    box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
    border: 2px solid white;
}

.smartmapsgl-popup-content {
    background-color: #1f2937;
    color: white;
    border-radius: 8px;
    padding: 10px 15px !important;
    font-size: 1em;
    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
    max-width: 220px;
    word-wrap: break-word;
}

.smartmapsgl-popup-tip {
    border-top-color: #1f2937 !important;
    border-bottom-color: #1f2937 !important;
}

.smartmapsgl-popup-close-button {
    color: white;
    font-size: 1.5em;
    padding: 0 5px;
}

/* =================================================================== */
/* RESPONSIVE & DESKTOP STYLES                                         */
/* =================================================================== */

/* --- Mobile layout (up to 600px) --- */
@media (max-width: 600px) {
    .initial-prompt {
        width: 70%;
        padding: 12px 15px;
        font-size: 1em;
        text-align: center;
        box-sizing: border-box;
    }

    #geolocate-button {
        top: 10px;
        left: auto;
        right: 10px;
        padding: 10px;
        border-radius: 50%;
        width: 44px;
        height: 44px;
        box-sizing: border-box;
    }

    #geolocate-button span:last-child {
        display: none;
    }

    .dashboard {
        position: fixed;
        top: auto;
        bottom: 0;
        left: 0;
        right: 0;
        width: 100%;
        max-width: none;
        border-radius: 20px 20px 0 0;
        max-height: 70vh;
        transform: translateY(100%);
        transition: transform 0.4s cubic-bezier(0.16, 1, 0.3, 1);

        background: rgba(255, 255, 255, 0.85);
        -webkit-backdrop-filter: blur(10px);
        backdrop-filter: blur(10px);
        border-top: 1px solid rgba(255, 255, 255, 0.2);
        box-shadow: 0 -4px 15px rgba(0, 0, 0, 0.1);
        z-index: 20;
        opacity: 0;
        visibility: hidden;
        padding: 24px;
        overflow-y: auto;
        box-sizing: border-box;
    }

    .dashboard.visible {
        transform: translateY(0);
        opacity: 1;
        visibility: visible;
    }

    .dashboard h1 {
        font-size: 1.5rem;
        color: var(--text-primary);
        margin: 0;
        border: none;
        padding: 0;
    }

    /* Tighter spacing in mobile dashboard */
    .info-section {
        margin-bottom: 12px;
    }

    .info-section+.info-section {
        padding-top: 12px;
    }

    .info-item {
        padding: 6px 4px;
    }
}

/* --- Desktop layout (601px and above) --- */
@media (min-width: 601px) {
    .dashboard {
        position: absolute;
        top: 20px;
        left: 20px;
        width: 420px;
        max-height: calc(100vh - 40px);
        background: var(--surface-panel);
        padding: 24px;
        border-radius: 12px;
        box-shadow: 0 8px 30px rgba(0, 31, 82, 0.12);
        border: 1px solid var(--border-color);
        z-index: 20;

        opacity: 0;
        visibility: hidden;
        transform: scale(0.95) translateY(-10px);
        transition: opacity 0.3s ease, transform 0.3s ease, visibility 0.3s;
        overflow-y: auto;
        box-sizing: border-box;
    }

    .dashboard.visible {
        opacity: 1;
        visibility: visible;
        transform: scale(1) translateY(0);
    }

    .dashboard h1 {
        font-size: 1.75rem;
        color: var(--text-primary);
        margin: 0;
        font-weight: 700;
        border: none;
        padding-bottom: 0;
    }
}
<!DOCTYPE html>
<html lang="de">

<head>
    <meta charset="UTF-8" />
    <title>SmartMaps Showcase: Standort-Analyse-Dashboard</title>
    <meta name="viewport" content="initial-scale=1,maximum-scale=1,user-scalable=no" />

    <link href="css/material-icons.css" rel="stylesheet">

    <!-- SmartMaps Libraries -->
    <script src="https://cdn.smartmaps.cloud/packages/smartmaps/smartmaps-gl/v2/umd/smartmaps-gl.min.js"></script>
    <script src="https://cdn.smartmaps.cloud/packages/smartmaps/area/umd/area.min.js"></script>
    <script src="https://cdn.smartmaps.cloud/packages/smartmaps/geocoding/umd/geocoding.min.js"></script>
    <script src="https://cdn.smartmaps.cloud/packages/turf/7.1.0/turf.min.js"></script>
    <style>
        /* =================================================================== */
        /* BASE STYLES (integrated from smartmaps-demo-styles.css)             */
        /* =================================================================== */
        :root {
            --brand-primary: #18345c;
            --brand-secondary: #3498db;
            --brand-accent: #f6b80c;
            --text-primary: #111827;
            --text-secondary: #374151;
            --text-light: #6b7280;
            --surface-bg: #f4f7fa;
            --surface-panel: #ffffff;
            --border-color: #e5e7eb;
            --success-color: #10b981;
            --error-color: #ef4444;
        }

        body {
            margin: 0;
            padding: 0;
            font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
            background-color: var(--surface-bg);
            color: var(--text-primary);
            -webkit-font-smoothing: antialiased;
            -moz-osx-font-smoothing: grayscale;
            overflow: hidden;
        }

        #map {
            position: absolute;
            top: 0;
            bottom: 0;
            width: 100%;
            height: 100%;
        }

        .loader {
            border: 4px solid rgba(0, 0, 0, 0.1);
            border-top: 4px solid var(--brand-secondary);
            border-radius: 50%;
            width: 20px;
            height: 20px;
            animation: spin 1s linear infinite;
            margin: 0 auto;
        }

        @keyframes spin {
            0% {
                transform: rotate(0deg);
            }

            100% {
                transform: rotate(360deg);
            }
        }

        .info-icon {
            position: relative;
            display: inline-flex;
            align-items: center;
            cursor: pointer;
            color: #9ca3af;
        }

        .info-icon .tooltip {
            visibility: hidden;
            width: 260px;
            background-color: #1f2937;
            color: #fff;
            text-align: left;
            border-radius: 6px;
            padding: 12px;
            position: absolute;
            z-index: 100;
            opacity: 0;
            transition: opacity 0.3s;
            font-size: 0.85rem;
            line-height: 1.5;
            font-weight: 400;
            box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
            bottom: 130%;
            right: 0;
            left: auto;
            transform: translateX(0);
        }

        .info-icon .tooltip::after {
            content: "";
            position: absolute;
            top: 100%;
            right: 10px;
            left: auto;
            margin-left: 0;
            border-width: 5px;
            border-style: solid;
            border-color: #1f2937 transparent transparent transparent;
        }

        .info-icon:hover .tooltip {
            visibility: visible;
            opacity: 1;
        }

        .info-icon .tooltip strong {
            color: var(--brand-secondary);
        }

        /* =================================================================== */
        /* DEMO-SPECIFIC STYLES                                                */
        /* =================================================================== */

        .dashboard-header {
            display: flex;
            justify-content: space-between;
            align-items: center;
            padding-bottom: 15px;
            border-bottom: 1px solid var(--border-color);
            margin: 0 0 20px 0;
        }

        .close-button {
            background: #f1f5f9;
            border: none;
            color: #64748b;
            width: 32px;
            height: 32px;
            border-radius: 50%;
            font-size: 24px;
            line-height: 32px;
            text-align: center;
            cursor: pointer;
            transition: all 0.2s;
        }

        .close-button:hover {
            background: #e2e8f0;
            transform: rotate(90deg);
        }

        .info-section {
            margin-bottom: 20px;
        }

        .info-section+.info-section {
            border-top: 1px solid var(--border-color);
            padding-top: 20px;
        }

        .section-title {
            display: flex;
            justify-content: space-between;
            align-items: center;
            margin: 0 0 10px 0;
            padding-bottom: 8px;
            position: relative;
        }

        .section-title h2 {
            font-size: 1.1em;
            font-weight: 500;
            color: var(--text-secondary);
            margin: 0;
            display: flex;
            align-items: center;
            gap: 8px;
            border: none;
            padding: 0;
        }

        .section-title h2 .material-icons {
            color: var(--brand-primary);
        }

        .info-item {
            display: flex;
            justify-content: space-between;
            align-items: center;
            font-size: 1em;
            padding: 8px 4px;
            border-bottom: 1px solid #f3f4f6;
        }

        .info-item:last-child {
            border-bottom: none;
        }

        .info-item span:first-child {
            font-weight: 500;
            color: var(--text-light);
            margin-right: 10px;
        }

        .info-item span:last-child {
            color: var(--text-primary);
            font-weight: 500;
            text-align: right;
        }

        .weather-display {
            display: flex;
            align-items: center;
            justify-content: flex-end;
            gap: 10px;
        }

        .initial-prompt {
            position: absolute;
            top: 20px;
            left: 50%;
            transform: translateX(-50%);
            background: var(--brand-primary);
            color: white;
            padding: 15px 25px;
            border-radius: 50px;
            font-size: 1.1em;
            font-weight: 500;
            z-index: 1;
            box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
            transition: opacity 0.5s, transform 0.5s;
        }

        .initial-prompt.hidden {
            opacity: 0;
            transform: translateX(-50%) translateY(-20px);
            pointer-events: none;
        }

        #geolocate-button {
            position: absolute;
            top: 20px;
            right: 20px;
            z-index: 2;
            background-color: var(--surface-panel);
            color: var(--brand-primary);
            border: 1px solid rgba(0, 0, 0, 0.1);
            border-radius: 12px;
            padding: 10px 15px;
            font-size: 1em;
            font-weight: 500;
            cursor: pointer;
            box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
            display: flex;
            align-items: center;
            gap: 8px;
            transition: all 0.2s ease-in-out;
        }

        #geolocate-button:hover {
            transform: translateY(-2px);
            box-shadow: 0 6px 16px rgba(0, 0, 0, 0.15);
        }

        #analyze-environment-button {
            background-color: var(--brand-secondary);
            color: white;
            border: none;
            padding: 12px;
            border-radius: 8px;
            font-weight: 500;
            cursor: pointer;
            margin-top: 15px;
            width: 100%;
            font-size: 1em;
            transition: all 0.3s;
            display: flex;
            align-items: center;
            justify-content: center;
            gap: 8px;
        }

        #analyze-environment-button:hover:not(:disabled) {
            background-color: #2980b9;
            transform: translateY(-2px);
        }

        #analyze-environment-button:disabled {
            background-color: #bdc3c7;
            cursor: not-allowed;
            transform: none;
        }

        #analyze-environment-button .loader {
            border-top-color: white;
        }

        .poi-marker {
            font-family: 'Material Icons';
            font-size: 20px;
            color: white;
            background: var(--brand-primary);
            border-radius: 50%;
            width: 30px;
            height: 30px;
            display: flex;
            justify-content: center;
            align-items: center;
            cursor: pointer;
            box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
            border: 2px solid white;
        }

        .smartmapsgl-popup-content {
            background-color: #1f2937;
            color: white;
            border-radius: 8px;
            padding: 10px 15px !important;
            font-size: 1em;
            box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
            max-width: 220px;
            word-wrap: break-word;
        }

        .smartmapsgl-popup-tip {
            border-top-color: #1f2937 !important;
            border-bottom-color: #1f2937 !important;
        }

        .smartmapsgl-popup-close-button {
            color: white;
            font-size: 1.5em;
            padding: 0 5px;
        }

        /* =================================================================== */
        /* RESPONSIVE & DESKTOP STYLES                                         */
        /* =================================================================== */

        /* --- Mobile layout (up to 600px) --- */
        @media (max-width: 600px) {
            .initial-prompt {
                width: 70%;
                padding: 12px 15px;
                font-size: 1em;
                text-align: center;
                box-sizing: border-box;
            }

            #geolocate-button {
                top: 10px;
                left: auto;
                right: 10px;
                padding: 10px;
                border-radius: 50%;
                width: 44px;
                height: 44px;
                box-sizing: border-box;
            }

            #geolocate-button span:last-child {
                display: none;
            }

            .dashboard {
                position: fixed;
                top: auto;
                bottom: 0;
                left: 0;
                right: 0;
                width: 100%;
                max-width: none;
                border-radius: 20px 20px 0 0;
                max-height: 70vh;
                transform: translateY(100%);
                transition: transform 0.4s cubic-bezier(0.16, 1, 0.3, 1);

                background: rgba(255, 255, 255, 0.85);
                -webkit-backdrop-filter: blur(10px);
                backdrop-filter: blur(10px);
                border-top: 1px solid rgba(255, 255, 255, 0.2);
                box-shadow: 0 -4px 15px rgba(0, 0, 0, 0.1);
                z-index: 20;
                opacity: 0;
                visibility: hidden;
                padding: 24px;
                overflow-y: auto;
                box-sizing: border-box;
            }

            .dashboard.visible {
                transform: translateY(0);
                opacity: 1;
                visibility: visible;
            }

            .dashboard h1 {
                font-size: 1.5rem;
                color: var(--text-primary);
                margin: 0;
                border: none;
                padding: 0;
            }

            /* Tighter spacing in mobile dashboard */
            .info-section {
                margin-bottom: 12px;
            }

            .info-section+.info-section {
                padding-top: 12px;
            }

            .info-item {
                padding: 6px 4px;
            }
        }

        /* --- Desktop layout (601px and above) --- */
        @media (min-width: 601px) {
            .dashboard {
                position: absolute;
                top: 20px;
                left: 20px;
                width: 420px;
                max-height: calc(100vh - 40px);
                background: var(--surface-panel);
                padding: 24px;
                border-radius: 12px;
                box-shadow: 0 8px 30px rgba(0, 31, 82, 0.12);
                border: 1px solid var(--border-color);
                z-index: 20;

                opacity: 0;
                visibility: hidden;
                transform: scale(0.95) translateY(-10px);
                transition: opacity 0.3s ease, transform 0.3s ease, visibility 0.3s;
                overflow-y: auto;
                box-sizing: border-box;
            }

            .dashboard.visible {
                opacity: 1;
                visibility: visible;
                transform: scale(1) translateY(0);
            }

            .dashboard h1 {
                font-size: 1.75rem;
                color: var(--text-primary);
                margin: 0;
                font-weight: 700;
                border: none;
                padding-bottom: 0;
            }
        }
    </style>
</head>

<body>
    <div id="map"></div>

    <button id="geolocate-button">
        <span class="material-icons">my_location</span>
        <span>Mein Standort</span>
    </button>

    <div id="prompt" class="initial-prompt">Klicken Sie auf die Karte, um einen Ort zu analysieren!</div>

    <div id="dashboard" class="dashboard">
        <div class="dashboard-header">
            <h1>Analyse</h1>
            <button id="close-dashboard-btn" class="close-button">&times;</button>
        </div>
        <div id="dashboard-content"></div>
    </div>

    <script>
        /*
         * ============================================================
         * SmartMaps Location Analysis Dashboard
         * ============================================================
         *
         * Purpose:
         *   Interactive single-page demo that analyses any point on the
         *   map. A click (or geolocation) triggers parallel API calls
         *   and renders a rich dashboard with address, elevation, area
         *   boundaries, weather, timezone, and nearby infrastructure.
         *
         * APIs used:
         *   - Geocoding API (reverse)  -- resolve coordinates to address
         *   - Weather API              -- current weather at location
         *   - Timezone API             -- local time & timezone info
         *   - Elevation API            -- altitude in metres
         *   - Area API                 -- administrative boundary polygon
         *   - Vector Tiles (POI /      -- query nearby points of interest
         *     Transport layers)           directly from the map source
         *
         * External libraries:
         *   - SmartMaps GL (map rendering)
         *   - SmartMaps Geocoding, Area (service wrappers)
         *   - Turf.js (client-side spatial buffering)
         *
         * Architecture:
         *   1. User clicks the map or uses geolocation.
         *   2. startAnalysis() fires five API calls in parallel via
         *      Promise.all and renders the dashboard.
         *   3. Optionally (on button click or geolocation) the map
         *      zooms in and loadDetailedAnalysis() queries vector tile
         *      POIs within a 1 km buffer.
         * ============================================================
         */

        // -----------------------------------------------------------------
        // Constants
        // -----------------------------------------------------------------

        /** Zoom level used when flying to a location for detailed analysis */
        const DETAIL_ZOOM = 14.5;

        /** Animation speed for map.flyTo transitions */
        const FLY_TO_SPEED = 1.5;

        /** Radius in kilometres for the POI search buffer */
        const POI_SEARCH_RADIUS_KM = 1;

        /**
         * Zoom-to-admin-level thresholds.
         * Each entry is [maxZoom, adminLevel]. The first matching
         * threshold (zoom < maxZoom) determines the admin level.
         * The final entry (Infinity) serves as the default fallback.
         */
        const ADMIN_LEVEL_THRESHOLDS = [
            [5, 2],   // zoom < 5  -> country (level 2)
            [8, 4],   // zoom < 8  -> state   (level 4)
            [11, 6],  // zoom < 11 -> district (level 6)
            [13, 8],  // zoom < 13 -> municipality (level 8)
            [Infinity, 0] // zoom >= 13 -> postal code (level 0)
        ];

        /** Human-readable labels for Area API admin levels */
        const ADMIN_LEVEL_LABELS = {
            0: 'PLZ-Gebiet',
            2: 'Staat',
            4: 'Bundesland',
            6: 'Kreis/Reg.bezirk',
            8: 'Gemeinde',
            9: 'Ortsteil'
        };

        /** Maps WMO weather codes to icon file names */
        const WEATHER_ICON_MAPPING = {
            0: 'ic_day_sunny', 1: 'ic_day_sunny', 2: 'ic_day_partlycloudy',
            3: 'ic_day_cloudy', 45: 'ic_day_fog', 48: 'ic_day_fog',
            51: 'ic_day_sprinkle', 53: 'ic_day_sprinkle', 55: 'ic_day_sprinkle',
            80: 'ic_day_showers', 81: 'ic_day_showers', 82: 'ic_day_showers',
            61: 'ic_day_rain', 63: 'ic_day_rain', 65: 'ic_day_rain',
            56: 'ic_day_rain_mix', 57: 'ic_day_rain_mix',
            66: 'ic_day_rain_mix', 67: 'ic_day_rain_mix',
            71: 'ic_day_snow', 73: 'ic_day_snow', 75: 'ic_day_snow',
            77: 'ic_day_snowflake_cold', 85: 'ic_day_snow', 86: 'ic_day_snow',
            95: 'ic_day_thunderstorm',
            96: 'ic_day_storm_showers', 99: 'ic_day_storm_showers'
        };

        // -----------------------------------------------------------------
        // Utility helpers
        // -----------------------------------------------------------------

        /**
         * Read a CSS custom property from the document root.
         * @param {string} variable - CSS variable name including '--' prefix
         * @returns {string} The trimmed property value
         */
        function getCssVariable(variable) {
            return getComputedStyle(document.documentElement).getPropertyValue(variable).trim();
        }

        /**
         * Determine the Area API admin level based on the current map zoom.
         * Uses the ADMIN_LEVEL_THRESHOLDS lookup table.
         * @param {number} zoom - Current map zoom level
         * @returns {number} The admin level to request from the Area API
         */
        function getAdminLevel(zoom) {
            for (const [maxZoom, level] of ADMIN_LEVEL_THRESHOLDS) {
                if (zoom < maxZoom) return level;
            }
            return 0; // fallback: postal code level
        }

        // -----------------------------------------------------------------
        // Initialisation
        // -----------------------------------------------------------------

        const apiKey = '[INSERT API-KEY]';

        const map = new smartmapsgl.Map({
            container: 'map', apiKey: apiKey, style: smartmapsgl.MapStyle.ESSENTIAL,
            center: [10, 51], zoom: 6
        });

        const geocoder = new smartmaps.geocodingService.Geocoder(apiKey);

        const dashboard = document.getElementById('dashboard');
        const dashboardContent = document.getElementById('dashboard-content');
        const promptEl = document.getElementById('prompt');
        let analysisMarker = null;
        let poiMarkers = [];

        /** POI category definitions with source layer, OSM filter, icon, and running count */
        const poiCategories = [
            { name: 'Supermärkte', source: 'poi', osmFilter: p => p.shop === 'supermarket', icon: 'storefront', count: 0 },
            { name: 'Schulen', source: 'poi', osmFilter: p => p.amenity === 'school', icon: 'school', count: 0 },
            { name: 'Ärzte', source: 'poi', osmFilter: p => p.amenity === 'doctors' || p.amenity === 'clinic', icon: 'medical_services', count: 0 },
            { name: 'Kindergärten', source: 'poi', osmFilter: p => p.amenity === 'kindergarten', icon: 'child_care', count: 0 },
            { name: 'Geldautomaten', source: 'poi', osmFilter: p => p.amenity === 'atm' || p.atm === 'yes' || p.atm === true, icon: 'atm', count: 0 },
            { name: 'Bus-Haltestellen', source: 'transport', osmFilter: p => p.kind === 'bus_stop', icon: 'directions_bus', count: 0 },
            { name: 'Bahn-Haltestellen', source: 'transport', osmFilter: p => ['station', 'tram_stop', 'halt'].includes(p.kind), icon: 'tram', count: 0 },
        ];

        // -----------------------------------------------------------------
        // Geolocation control
        // -----------------------------------------------------------------

        const geolocateControl = new smartmapsgl.GeolocateControl({
            positionOptions: { enableHighAccuracy: true },
            trackUserLocation: false,
            showUserLocation: true
        });
        map.addControl(geolocateControl, 'top-right');
        geolocateControl._geolocateButton.style.display = 'none';

        /** Trigger geolocation via the custom button */
        document.getElementById('geolocate-button').addEventListener('click', () => {
            geolocateControl.trigger();
        });

        /** When the browser reports the user position, start analysis immediately */
        geolocateControl.on('geolocate', async (position) => {
            const coords = [position.coords.longitude, position.coords.latitude];
            await startAnalysis({ lng: coords[0], lat: coords[1] }, true);
        });

        // -----------------------------------------------------------------
        // Map click handler
        // -----------------------------------------------------------------

        /** Start analysis on map click, ignoring clicks on markers, popups, or the dashboard */
        map.on('click', (event) => {
            const targetElement = event.originalEvent.target;
            if (targetElement.closest('.poi-marker') || targetElement.closest('.smartmapsgl-popup') || targetElement.closest('.dashboard')) {
                return;
            }
            startAnalysis(event.lngLat, false);
        });

        /** Close the dashboard and reset the map */
        document.getElementById('close-dashboard-btn').addEventListener('click', () => {
            dashboard.classList.remove('visible');
            clearMap();
            promptEl.classList.remove('hidden');
        });

        // -----------------------------------------------------------------
        // Map helpers
        // -----------------------------------------------------------------

        /**
         * Remove the analysis marker, all POI markers, and the area polygon
         * from the map, resetting to a clean state.
         */
        function clearMap() {
            if (analysisMarker) {
                analysisMarker.remove();
                analysisMarker = null;
            }
            poiMarkers.forEach(marker => marker.remove());
            poiMarkers = [];
            if (map.getSource('area-source')) {
                map.removeLayer('area-fill');
                map.removeLayer('area-outline');
                map.removeSource('area-source');
            }
        }

        /**
         * Calculate map padding so that the fly-to animation avoids the
         * dashboard panel. Returns different values for mobile vs desktop.
         * @returns {{top: number, bottom: number, left: number, right: number}}
         */
        function getMapPadding() {
            if (window.innerWidth <= 600) {
                const panel = document.getElementById('dashboard');
                const panelHeight = panel ? panel.offsetHeight : 300;
                return { top: 80, bottom: panelHeight + 20, left: 20, right: 20 };
            } else {
                return { top: 40, bottom: 40, left: 460, right: 40 };
            }
        }

        /**
         * Draw (or replace) the administrative area polygon on the map.
         * Adds a semi-transparent fill and an outline layer.
         * @param {Object} areaData - GeoJSON FeatureCollection from the Area API
         */
        function drawAreaPolygon(areaData) {
            if (map.getSource('area-source')) {
                map.removeLayer('area-fill');
                map.removeLayer('area-outline');
                map.removeSource('area-source');
            }
            if (!areaData || !areaData.features || !areaData.features.length === 0) return;

            map.addSource('area-source', { type: 'geojson', data: areaData });
            map.addLayer({ id: 'area-fill', type: 'fill', source: 'area-source', paint: { 'fill-color': getCssVariable('--brand-secondary'), 'fill-opacity': 0.15 } });
            map.addLayer({ id: 'area-outline', type: 'line', source: 'area-source', paint: { 'line-color': getCssVariable('--brand-secondary'), 'line-width': 2 } });
        }

        // -----------------------------------------------------------------
        // Main analysis flow
        // -----------------------------------------------------------------

        /**
         * Entry point for location analysis. Places a marker, fires five
         * API calls in parallel, and renders the dashboard. Optionally
         * zooms in for a detailed POI analysis.
         * @param {{lng: number, lat: number}} lngLat - Clicked / geolocated position
         * @param {boolean} [startDetailedImmediately=false] - If true, zoom in and load POIs right away
         */
        async function startAnalysis(lngLat, startDetailedImmediately = false) {
            clearMap();
            promptEl.classList.add('hidden');
            const coords = [lngLat.lng, lngLat.lat];

            dashboardContent.innerHTML = '<div class="loader"></div><p style="text-align: center;">Analysiere Standort...</p>';
            dashboard.classList.add('visible');

            analysisMarker = new smartmapsgl.Marker({ color: getCssVariable('--brand-accent') }).setLngLat(coords).addTo(map);

            const adminLevel = getAdminLevel(map.getZoom());

            // Fire all API requests in parallel
            const reverseGeocodePromise = geocoder.geocodeReverse({ lat: lngLat.lat, lng: lngLat.lng });
            const weatherPromise = fetch(`https://weather.smartmaps.cloud/api/v2/weather/point?Longitude=${lngLat.lng}&Latitude=${lngLat.lat}&ApiKey=${smartmapsgl.encodeString(apiKey)}`).then(res => res.json());
            const timezonePromise = fetch(`https://timezone.smartmaps.cloud/api/v1/timezone/current/point?Longitude=${lngLat.lng}&Latitude=${lngLat.lat}&ApiKey=${smartmapsgl.encodeString(apiKey)}`).then(res => res.json());
            const elevationPromise = fetch(`https://elevation.smartmaps.cloud/api/v2/Elevation/point?apiKey=${smartmapsgl.encodeString(apiKey)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ points: [{ latitude: lngLat.lat, longitude: lngLat.lng }] }) }).then(res => res.json());

            const areaService = new smartmaps.areaService.Area(apiKey);
            const areaPromise = areaService.point({ point: { latitude: coords[1], longitude: coords[0] }, level: adminLevel });

            try {
                const [geoData, weatherData, timeData, elevData, areaData] = await Promise.all([reverseGeocodePromise, weatherPromise, timezonePromise, elevationPromise, areaPromise]);

                updateDashboardUI({ geoData, weatherData, timeData, elevData, areaData }, coords, startDetailedImmediately);
                drawAreaPolygon(areaData);

                if (startDetailedImmediately) {
                    map.flyTo({ center: coords, zoom: DETAIL_ZOOM, speed: FLY_TO_SPEED, padding: getMapPadding() });
                    map.once('idle', () => {
                        loadDetailedAnalysis(coords);
                    });
                }

            } catch (error) {
                console.error("Location analysis failed:", error);
                dashboardContent.innerHTML = `<p style="color: ${getCssVariable('--error-color')};">Analyse fehlgeschlagen. Bitte versuchen Sie es erneut.</p>`;
            }
        }

        // -----------------------------------------------------------------
        // Dashboard section renderers
        // -----------------------------------------------------------------

        /**
         * Render the Infrastructure section of the dashboard.
         * Shows either a loading spinner or the "Start environment analysis" button.
         * @param {number[]} coords - [lng, lat] of the analysis point
         * @param {boolean} startDetailedImmediately - Whether detailed analysis starts automatically
         * @returns {string} HTML string for the infrastructure section
         */
        function renderInfrastructureSection(coords, startDetailedImmediately) {
            let html = `<div class="info-section" id="detail-analysis-section">
                <div class="section-title">
                    <h2><span class="material-icons">business</span>Infrastruktur</h2>
                     <span class="info-icon"><i class="material-icons">info</i><span class="tooltip">Points of Interest (POIs) aus den <strong>Vector Tiles</strong> im Umkreis von 1km.</span></span>
                </div>`;
            if (startDetailedImmediately) {
                html += `<div class="loader"></div><p style="text-align: center;">Lade Details...</p>`;
            } else {
                html += `
                <button id="analyze-environment-button" data-coords="${coords.join(',')}">
                    <span class="material-icons">travel_explore</span>Umfeld-Analyse starten
                </button>
                <p style="font-size: 0.9em; text-align: center; margin-top: 10px; color: var(--text-light);">Zoomt auf den Standort und lädt POIs im Umkreis.</p>`;
            }
            html += `</div>`;
            return html;
        }

        /**
         * Render the Address section of the dashboard using reverse geocoding results.
         * @param {Object} geoData - GeoJSON FeatureCollection from the Geocoding API
         * @returns {string} HTML string for the address section (empty if no data)
         */
        function renderAddressSection(geoData) {
            if (!geoData.features?.[0]) return '';
            const p = geoData.features[0].properties;
            return `<div class="info-section">
                <div class="section-title">
                    <h2><span class="material-icons">map</span>Adresse</h2>
                    <span class="info-icon"><i class="material-icons">info</i><span class="tooltip">Nutzt die <strong>Geocoding API</strong> zur Ermittlung der Adresse.</span></span>
                </div>
                <div class="info-item"><span>Straße</span> <span>${p.street || '-'} ${p.houseNo || ''}</span></div>
                <div class="info-item"><span>Ort</span> <span>${p.zip || ''} ${p.city || 'N/A'}</span></div>
                <div class="info-item"><span>Land</span> <span>${p.country || 'N/A'}</span></div>
            </div>`;
        }

        /**
         * Render the Geo Information section (elevation and area/region data).
         * @param {Object} elevData - GeoJSON FeatureCollection from the Elevation API
         * @param {Object} areaData - GeoJSON FeatureCollection from the Area API
         * @returns {string} HTML string for the geo information section
         */
        function renderGeoInfoSection(elevData, areaData) {
            let html = `<div id="geo-info-section" class="info-section">
                <div class="section-title">
                    <h2><span class="material-icons">public</span>Geo-Informationen</h2>
                    <span class="info-icon"><i class="material-icons">info</i><span class="tooltip">Daten aus <strong>Elevation API</strong> (Höhe) und <strong>Area API</strong>. Die Gebietsebene wird automatisch je nach Zoomstufe gewählt.</span></span>
                </div>`;
            if (elevData.features?.[0]) {
                html += `<div class="info-item"><span>Höhe</span> <span>${elevData.features[0].properties.elevation.toFixed(2)} m</span></div>`;
            }

            if (areaData.features?.[0]) {
                const areaProps = areaData.features[0].properties;
                const level = areaProps.level;

                const levelLabel = ADMIN_LEVEL_LABELS[level] || `Gebiet (Level ${level})`;
                const areaName = areaProps.zip || areaProps.name || 'N/A';

                html += `<div class="info-item" id="region-info-item"><span>${levelLabel}</span> <span>${areaName}</span></div>`;
            }
            html += `</div>`;
            return html;
        }

        /**
         * Render the Weather & Time section of the dashboard.
         * @param {Object} weatherData - GeoJSON FeatureCollection from the Weather API
         * @param {Object} timeData - GeoJSON FeatureCollection from the Timezone API
         * @returns {string} HTML string for the weather & time section
         */
        function renderWeatherTimeSection(weatherData, timeData) {
            let html = `<div class="info-section">
                <div class="section-title">
                    <h2><span class="material-icons">wb_sunny</span>Wetter & Zeit</h2>
                    <span class="info-icon"><i class="material-icons">info</i><span class="tooltip">Live-Daten aus der <strong>Weather API</strong> und <strong>Timezone API</strong>.</span></span>
                </div>`;
            if (weatherData.features?.[0]) {
                const weather = weatherData.features[0].properties.currently;
                const iconName = WEATHER_ICON_MAPPING[weather.weatherCode] || 'ic_day_sunny';
                const iconUrl = `https://docs.smartmaps.cloud/assets/images/weatherImages/${iconName}.svg`;
                html += `<div class="info-item"><span>Temperatur</span> <span class="weather-display"><img src="${iconUrl}" alt="Wetter-Icon" width="40" height="40">${weather.temperature2Meters.toFixed(1)} °C</span></div>`;
            }
            if (timeData.features?.[0]) {
                html += `<div class="info-item"><span>Lokale Zeit</span> <span>${timeData.features[0].properties.isoDateTimeText} (${timeData.features[0].properties.timezoneAbbreviation})</span></div>`;
            }
            html += `</div>`;
            return html;
        }

        /**
         * Assemble and inject all dashboard sections into the DOM.
         * Delegates to the individual section renderers.
         * @param {Object} data - Aggregated API response data
         * @param {Object} data.geoData - Reverse geocoding result
         * @param {Object} data.weatherData - Weather API result
         * @param {Object} data.timeData - Timezone API result
         * @param {Object} data.elevData - Elevation API result
         * @param {Object} data.areaData - Area API result
         * @param {number[]} coords - [lng, lat] of the analysis point
         * @param {boolean} startDetailedImmediately - Whether to auto-start detailed analysis
         */
        function updateDashboardUI(data, coords, startDetailedImmediately) {
            const { geoData, weatherData, timeData, elevData, areaData } = data;

            let html = '';
            html += renderInfrastructureSection(coords, startDetailedImmediately);
            html += renderAddressSection(geoData);
            html += renderGeoInfoSection(elevData, areaData);
            html += renderWeatherTimeSection(weatherData, timeData);

            dashboardContent.innerHTML = html;
        }

        // -----------------------------------------------------------------
        // "Analyse environment" button (delegated click handler)
        // -----------------------------------------------------------------

        /** Handle click on the dynamically created "Analyse environment" button */
        document.body.addEventListener('click', (event) => {
            const button = event.target.closest('#analyze-environment-button');
            if (button) {
                button.innerHTML = '<div class="loader"></div>';
                button.disabled = true;

                const coords = button.dataset.coords.split(',').map(Number);

                map.flyTo({ center: coords, zoom: DETAIL_ZOOM, speed: FLY_TO_SPEED, padding: getMapPadding() });

                map.once('idle', () => {
                    loadDetailedAnalysis(coords);
                });
            }
        });

        // -----------------------------------------------------------------
        // Detailed POI analysis
        // -----------------------------------------------------------------

        /**
         * Fetch the postal code area polygon and draw it on the map.
         * Also updates the region info item in the Geo Information section.
         * @param {number[]} coords - [lng, lat] of the analysis point
         */
        async function loadPostalCodeArea(coords) {
            const areaService = new smartmaps.areaService.Area(apiKey);
            try {
                const zipAreaData = await areaService.point({ point: { latitude: coords[1], longitude: coords[0] }, level: 0 });
                drawAreaPolygon(zipAreaData);
                const regionInfoItem = document.getElementById('region-info-item');
                if (regionInfoItem && zipAreaData.features?.[0]) {
                    regionInfoItem.innerHTML = `<span>PLZ-Gebiet</span> <span>${zipAreaData.features[0].properties.zip}</span>`;
                }
            } catch (error) {
                console.error("Failed to load postal code area:", error);
            }
        }

        /**
         * Query the map's vector tile source for POI and transport features
         * within the search radius around the given coordinates.
         * @param {number[]} coords - [lng, lat] of the analysis point
         * @returns {{poiFeatures: Object[], transportFeatures: Object[]}} Matched features by source layer
         */
        function queryNearbyPOIs(coords) {
            const centerPoint = turf.point(coords);
            const buffer = turf.buffer(centerPoint, POI_SEARCH_RADIUS_KM, { units: 'kilometers' });

            const poiFeatures = map.querySourceFeatures('smartmaps', {
                sourceLayer: 'poi',
                filter: ['within', buffer.geometry]
            });
            const transportFeatures = map.querySourceFeatures('smartmaps', {
                sourceLayer: 'transport',
                filter: ['within', buffer.geometry]
            });

            return { poiFeatures, transportFeatures };
        }

        /**
         * Process matched vector tile features: classify them into POI
         * categories, create map markers with popups, and update counts.
         * @param {Object[]} features - Array of GeoJSON features from querySourceFeatures
         * @param {string} sourceName - The source layer name ('poi' or 'transport')
         */
        function createPOIMarkers(features, sourceName) {
            features.forEach(feature => {
                for (const category of poiCategories) {
                    if (category.source === sourceName && category.osmFilter(feature.properties)) {

                        const geometry = feature.geometry;
                        let pointCoordinates = [];

                        if (geometry.type === 'Point') { pointCoordinates.push(geometry.coordinates); }
                        else if (geometry.type === 'MultiPoint') { pointCoordinates = geometry.coordinates; }

                        pointCoordinates.forEach(coords => {
                            if (!Array.isArray(coords) || coords.length < 2 || isNaN(coords[0]) || isNaN(coords[1])) {
                                console.warn('Skipping feature with invalid coordinates:', coords, feature);
                                return;
                            }

                            category.count++;

                            const el = document.createElement('div');
                            el.className = 'poi-marker';
                            el.textContent = category.icon;

                            const marker = new smartmapsgl.Marker({ element: el })
                                .setLngLat(coords)
                                .setPopup(new smartmapsgl.Popup({ offset: 25 }).setText(feature.properties.name || category.name.slice(0, -1)))
                                .addTo(map);
                            poiMarkers.push(marker);
                        });
                        break;
                    }
                }
            });
        }

        /**
         * Render the POI results into the Infrastructure section of the dashboard,
         * replacing the loading indicator with category counts.
         * @param {HTMLElement} detailSection - The DOM element for the infrastructure section
         */
        function renderPOIResults(detailSection) {
            let html = `<div class="section-title"><h2><span class="material-icons">business</span>Infrastruktur</h2><span class="info-icon"><i class="material-icons">info</i><span class="tooltip">Points of Interest (POIs) aus den <strong>Vector Tiles</strong> im Umkreis von 1 km.</span></span></div>`;
            poiCategories.forEach(cat => {
                html += `<div class="info-item"><span>${cat.name}</span> <span>${cat.count}</span></div>`;
            });
            detailSection.innerHTML = html;
        }

        /**
         * Orchestrate the detailed analysis: load the postal code area,
         * query nearby POIs from vector tiles, create markers, and update
         * the dashboard.
         * @param {number[]} coords - [lng, lat] of the analysis point
         */
        async function loadDetailedAnalysis(coords) {
            const detailSection = document.getElementById('detail-analysis-section');
            if (!detailSection) return;

            // Show loading state
            detailSection.innerHTML = `<div class="section-title"><h2><span class="material-icons">business</span>Infrastruktur</h2><span class="info-icon"><i class="material-icons">info</i><span class="tooltip">Points of Interest (POIs) aus den <strong>Vector Tiles</strong> im Umkreis von 1km.</span></span></div><div class="loader"></div><p style="text-align: center;">Lade PLZ-Gebiet & POIs...</p>`;

            // Step 1: Fetch and display postal code area polygon
            await loadPostalCodeArea(coords);

            // Step 2: Wait for tiles to render after the area polygon update
            await map.once('render');

            // Step 3: Query vector tiles for nearby POIs
            const { poiFeatures, transportFeatures } = queryNearbyPOIs(coords);

            // Step 4: Reset counts and create markers
            poiCategories.forEach(cat => cat.count = 0);
            createPOIMarkers(poiFeatures, 'poi');
            createPOIMarkers(transportFeatures, 'transport');

            // Step 5: Render results into the dashboard
            renderPOIResults(detailSection);
        }
    </script>
</body>

</html>

Features & APIs

  • Geocoding API (Reverse) -- Resolves geographic coordinates into a human-readable address (street, city, country)
  • Weather API -- Retrieves live weather data (temperature, weather code and icon) for the selected location
  • Timezone API -- Provides the current local time and timezone abbreviation at the selected point
  • Elevation API -- Returns the elevation in meters for the given coordinates
  • Area API -- Fetches administrative area boundaries (country, state, district, municipality or postal code area) depending on the current zoom level, and renders the polygon on the map
  • Vector Tiles (POI & Transport layers) -- Queries nearby points of interest within a 1 km radius (supermarkets, schools, doctors, kindergartens, ATMs, bus stops, and train/tram stations) directly from the map's vector tile source
  • Geolocation -- Uses the browser's Geolocation API to center the map on the user's current position and immediately start the analysis
  • Turf.js -- Performs client-side spatial buffering to define the 1 km search radius around the selected point