Module.register("MMM-CombinedMap", { defaults: { // Map settings lat: 49.8666, lng: 8.8505, zoom: 7, width: "450px", height: "580px", // Marker showMarker: true, markerColor: "#00cc00", // Base map style: "dark", "grey", "osm", or custom URL baseMapStyle: "grey", invertBasemap: true, // Invert colors (good for dark themes) // Layers showTraffic: true, showRadar: true, // TomTom API key (get free at developer.tomtom.com) tomtomApiKey: "", // Radar styling radarColorScheme: "vibrant", // "default", "vibrant", or "bright" // Animation settings animationSpeed: 800, // ms per frame radarOpacity: 0.7, trafficOpacity: 0.7, // Radar frames historyFrames: 12, // 1 hour of history (5 min intervals) forecastFrames: 24, // 2 hours of forecast extraDelayLastFrame: 2000, // Extra delay on last frame // Update interval (ms) - how often to refresh radar data updateInterval: 5 * 60 * 1000, // 5 minutes }, getStyles: function() { return [ "MMM-CombinedMap.css", "https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" ]; }, getScripts: function() { return [ "https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" ]; }, start: function() { Log.info("Starting module: " + this.name); this.loaded = false; this.map = null; this.radarLayers = []; this.currentRadarIndex = 0; this.animationTimer = null; this.availableTimestamps = []; }, getDom: function() { const wrapper = document.createElement("div"); wrapper.id = "mmm-combinedmap-" + this.identifier; wrapper.className = "mmm-combinedmap-wrapper"; wrapper.style.width = this.config.width; wrapper.style.height = this.config.height; if (!this.loaded) { wrapper.innerHTML = "Loading map..."; // Initialize map after DOM is ready setTimeout(() => this.initMap(wrapper), 100); } return wrapper; }, initMap: function(container) { if (this.map) return; const mapId = "mmm-combinedmap-" + this.identifier; const mapContainer = document.getElementById(mapId); if (!mapContainer) return; mapContainer.innerHTML = ""; // Create map this.map = L.map(mapId, { zoomControl: false, attributionControl: false, dragging: false, scrollWheelZoom: false, doubleClickZoom: false, boxZoom: false, keyboard: false, touchZoom: false }).setView([this.config.lat, this.config.lng], this.config.zoom); // Base map const baseMapUrls = { "dark": "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png", "grey": "https://{s}.basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}{r}.png", "osm": "https://tile.openstreetmap.org/{z}/{x}/{y}.png", "osm-de": "https://tile.openstreetmap.de/{z}/{x}/{y}.png", "positron": "https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png" }; const baseMapUrl = baseMapUrls[this.config.baseMapStyle] || this.config.baseMapStyle; const baseLayer = L.tileLayer(baseMapUrl, { maxZoom: 19, opacity: 1, className: this.config.invertBasemap ? "inverted-tiles" : "" }); baseLayer.addTo(this.map); // Traffic layer (TomTom) if (this.config.showTraffic && this.config.tomtomApiKey) { this.addTrafficLayer(); } // Radar layer (DWD) if (this.config.showRadar) { this.fetchRadarCapabilities(); } // Marker if (this.config.showMarker) { const markerIcon = L.divIcon({ className: "mmm-combinedmap-marker", html: `
`, iconSize: [16, 16], iconAnchor: [8, 8] }); L.marker([this.config.lat, this.config.lng], { icon: markerIcon }).addTo(this.map); } this.loaded = true; // Set up periodic refresh setInterval(() => this.refreshRadar(), this.config.updateInterval); }, addTrafficLayer: function() { // TomTom Traffic Flow tiles const trafficLayer = L.tileLayer( `https://api.tomtom.com/traffic/map/4/tile/flow/relative0/{z}/{x}/{y}.png?key=${this.config.tomtomApiKey}&tileSize=256`, { maxZoom: 18, opacity: this.config.trafficOpacity } ); trafficLayer.addTo(this.map); // Also add traffic incidents overlay const incidentsLayer = L.tileLayer( `https://api.tomtom.com/traffic/map/4/tile/incidents/s3/{z}/{x}/{y}.png?key=${this.config.tomtomApiKey}&tileSize=256`, { maxZoom: 18, opacity: this.config.trafficOpacity } ); incidentsLayer.addTo(this.map); }, fetchRadarCapabilities: function() { // Parse WMS GetCapabilities to find available time range const capsUrl = "https://maps.dwd.de/geoserver/dwd/wms?service=WMS&version=1.3.0&request=GetCapabilities"; fetch(capsUrl) .then(response => response.text()) .then(xml => { const parser = new DOMParser(); const doc = parser.parseFromString(xml, "text/xml"); // Find Niederschlagsradar layer and its time dimension const layers = doc.querySelectorAll("Layer"); for (const layer of layers) { const nameEl = layer.querySelector("Name"); if (nameEl && nameEl.textContent === "Niederschlagsradar") { const dimEl = layer.querySelector("Dimension[name='time']"); if (dimEl) { this.parseTimeDimension(dimEl.textContent); } break; } } }) .catch(err => { Log.error("MMM-CombinedMap: Failed to fetch WMS capabilities:", err); // Fallback: generate timestamps based on current time this.generateFallbackTimestamps(); }); }, parseTimeDimension: function(dimStr) { // Format: "2026-02-10T09:35:00.000Z/2026-02-11T15:15:00.000Z/PT5M" const parts = dimStr.split("/"); if (parts.length !== 3) { this.generateFallbackTimestamps(); return; } const startTime = new Date(parts[0]); const endTime = new Date(parts[1]); const interval = 5 * 60 * 1000; // PT5M = 5 minutes const now = new Date(); const timestamps = []; // Get frames from history (before now) const historyStart = Math.max( startTime.getTime(), now.getTime() - this.config.historyFrames * interval ); // Get frames into forecast (after now) const forecastEnd = Math.min( endTime.getTime(), now.getTime() + this.config.forecastFrames * interval ); // Round to 5-minute intervals let t = Math.floor(historyStart / interval) * interval; while (t <= forecastEnd) { if (t >= startTime.getTime() && t <= endTime.getTime()) { timestamps.push(new Date(t)); } t += interval; } this.availableTimestamps = timestamps; this.createRadarLayers(); }, generateFallbackTimestamps: function() { // Fallback: generate timestamps without checking capabilities const now = new Date(); const interval = 5 * 60 * 1000; const timestamps = []; // Past hour for (let i = this.config.historyFrames; i >= 0; i--) { const t = new Date(now.getTime() - i * interval); t.setMinutes(Math.floor(t.getMinutes() / 5) * 5, 0, 0); timestamps.push(t); } // Future 2 hours for (let i = 1; i <= this.config.forecastFrames; i++) { const t = new Date(now.getTime() + i * interval); t.setMinutes(Math.floor(t.getMinutes() / 5) * 5, 0, 0); timestamps.push(t); } this.availableTimestamps = timestamps; this.createRadarLayers(); }, createRadarLayers: function() { // Clear existing layers this.radarLayers.forEach(layer => { if (this.map && this.map.hasLayer(layer.leafletLayer)) { this.map.removeLayer(layer.leafletLayer); } }); this.radarLayers = []; if (!this.availableTimestamps.length) return; const now = new Date(); // Determine radar CSS class based on color scheme const radarClass = this.config.radarColorScheme === "vibrant" ? "radar-vibrant" : this.config.radarColorScheme === "bright" ? "radar-bright" : ""; this.availableTimestamps.forEach((timestamp, index) => { const isForecast = timestamp > now; const timeStr = timestamp.toISOString().replace(/\.\d{3}Z$/, ".000Z"); // DWD WMS layer const layer = L.tileLayer.wms("https://maps.dwd.de/geoserver/dwd/wms", { layers: "dwd:Niederschlagsradar", format: "image/png", transparent: true, opacity: 0, // Start hidden time: timeStr, styles: "niederschlagsradar", version: "1.3.0", crs: L.CRS.EPSG3857, className: radarClass }); layer.addTo(this.map); this.radarLayers.push({ leafletLayer: layer, timestamp: timestamp, isForecast: isForecast, isLast: index === this.availableTimestamps.length - 1 }); }); // Start animation if (this.radarLayers.length > 0) { this.startAnimation(); } }, startAnimation: function() { if (this.animationTimer) { clearInterval(this.animationTimer); } this.currentRadarIndex = 0; this.showRadarFrame(0); const animate = () => { const currentLayer = this.radarLayers[this.currentRadarIndex]; const delay = currentLayer && currentLayer.isLast ? this.config.animationSpeed + this.config.extraDelayLastFrame : this.config.animationSpeed; this.animationTimer = setTimeout(() => { this.currentRadarIndex = (this.currentRadarIndex + 1) % this.radarLayers.length; this.showRadarFrame(this.currentRadarIndex); animate(); }, delay); }; animate(); }, showRadarFrame: function(index) { this.radarLayers.forEach((layer, i) => { if (i === index) { layer.leafletLayer.setOpacity(this.config.radarOpacity); } else { layer.leafletLayer.setOpacity(0); } }); // Update timestamp display this.updateTimestampDisplay(this.radarLayers[index]); }, updateTimestampDisplay: function(layer) { const container = document.getElementById("mmm-combinedmap-" + this.identifier); if (!container) return; let timestampEl = container.querySelector(".radar-timestamp"); if (!timestampEl) { timestampEl = document.createElement("div"); timestampEl.className = "radar-timestamp"; container.appendChild(timestampEl); } const time = layer.timestamp; const now = new Date(); const diffMinutes = Math.round((time - now) / 60000); let label; if (Math.abs(diffMinutes) <= 2) { label = "Now"; } else if (diffMinutes > 0) { label = `+${diffMinutes} min`; } else { label = `${diffMinutes} min`; } timestampEl.textContent = label; timestampEl.classList.toggle("forecast", layer.isForecast); }, refreshRadar: function() { Log.info("MMM-CombinedMap: Refreshing radar data"); this.fetchRadarCapabilities(); }, suspend: function() { if (this.animationTimer) { clearTimeout(this.animationTimer); this.animationTimer = null; } }, resume: function() { if (this.radarLayers.length > 0) { this.startAnimation(); } } });