Standortanalyse
Dieser Anwendungsfall zeigt ein Standort-Informationsdashboard, das mehrere SmartMaps-APIs kombiniert, um einen beliebigen Punkt auf der Karte zu analysieren. Durch Klicken auf die Karte oder Nutzung der Geolokalisierung zeigt das Dashboard detaillierte Informationen an, darunter die Adresse, aktuelle Wetterbedingungen, die lokale Zeitzone, die Höhe, Verwaltungsgebietsgrenzen sowie nahegelegene Infrastruktur wie Supermärkte, Schulen, Ärzte, Kindergärten, Geldautomaten und öffentliche Verkehrsmittelhaltestellen.
Wie es funktioniert
Die Demo folgt einem zweiphasigen Analyseablauf:
Phase 1 -- Übersichtsanalyse (ausgelöst durch einen Kartenklick oder Geolokalisierung):
- An der angeklickten Position wird ein Marker platziert.
- Fünf API-Aufrufe werden parallel über
Promise.allausgelöst: Reverse-Geocoding, Weather, Timezone, Elevation und Area. - Das Dashboard rendert aus den Ergebnissen vier Abschnitte (Infrastruktur, Adresse, Geo-Informationen, Wetter & Zeit).
- Das Verwaltungsgebiets-Polygon wird auf der Karte gezeichnet.
Phase 2 -- Detaillierte POI-Analyse (ausgelöst durch die Schaltfläche „Umfeld-Analyse starten" oder automatisch bei Geolokalisierung):
- Die Karte fliegt auf Zoomstufe 14.5, zentriert auf den Standort.
- Das PLZ-Gebiet (Verwaltungsebene 0) wird abgerufen und das Polygon aktualisiert.
- Turf.js erstellt einen 1-km-Puffer um den Punkt.
querySourceFeaturesliest POI- und Transportdaten direkt aus den geladenen Vector-Tiles.- Übereinstimmende Features werden in Kategorien eingeteilt und als Kartenmarker mit Pop-ups dargestellt.
Verwendete API-Endpunkte
| API | Endpunkt | Zweck |
|---|---|---|
| Geocoding (Reverse) | geocoder.geocodeReverse() |
Koordinaten in eine Straßenadresse auflösen |
| Weather | weather.smartmaps.cloud/api/v2/weather/point |
Aktuelle Temperatur und Wettercode |
| Timezone | timezone.smartmaps.cloud/api/v1/timezone/current/point |
Lokale Zeit und Zeitzonenkürzel |
| Elevation | elevation.smartmaps.cloud/api/v2/Elevation/point |
Höhe in Metern (POST) |
| Area | areaService.point() |
Verwaltungsgebiets-Polygon |
| Vector Tiles | map.querySourceFeatures('smartmaps', ...) |
POI- und Transport-Layer aus geladenen 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">×</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">×</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>
Funktionen & APIs
- Geocoding API (Reverse) -- Löst geografische Koordinaten in eine menschenlesbare Adresse auf (Straße, Stadt, Land)
- Weather API -- Ruft Live-Wetterdaten (Temperatur, Wettercode und Icon) für den ausgewählten Standort ab
- Timezone API -- Liefert die aktuelle Ortszeit und das Zeitzonenkürzel am ausgewählten Punkt
- Elevation API -- Gibt die Höhe in Metern für die angegebenen Koordinaten zurück
- Area API -- Ruft je nach aktueller Zoomstufe Verwaltungsgebietsgrenzen ab (Land, Bundesland, Kreis, Gemeinde oder PLZ-Gebiet) und rendert das Polygon auf der Karte
- Vector Tiles (POI & Transport layers) -- Fragt Points of Interest im Umkreis von 1 km (Supermärkte, Schulen, Ärzte, Kindergärten, Geldautomaten, Bushaltestellen und Bahn-/Tram-Haltestellen) direkt aus der Vector-Tile-Quelle der Karte ab
- Geolocation -- Nutzt die Geolocation-API des Browsers, um die Karte auf die aktuelle Position des Nutzers zu zentrieren und die Analyse sofort zu starten
- Turf.js -- Führt clientseitiges räumliches Buffering durch, um den 1-km-Suchradius um den ausgewählten Punkt zu definieren