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" showLegend: true, // Show precipitation color legend // Animation settings animationSpeed: 800, // ms per frame radarOpacity: 0.7, trafficOpacity: 0.7, // Radar frames (reduced to limit API load) historyFrames: 6, // 30 min of history (5 min intervals) forecastFrames: 12, // 1 hour of forecast extraDelayLastFrame: 2000, // Extra delay on last frame // Update interval (ms) - how often to refresh radar data updateInterval: 10 * 60 * 1000, // 10 minutes // Startup delay to avoid hammering API on restarts startupDelay: 5000, // 5 seconds }, 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) - delayed start to avoid API hammering if (this.config.showRadar) { setTimeout(() => this.fetchRadarCapabilities(), this.config.startupDelay); } // Radar legend if (this.config.showRadar && this.config.showLegend) { this.addRadarLegend(); } // 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; // Create or get timeline element let timelineEl = container.querySelector(".radar-timeline"); if (!timelineEl) { timelineEl = this.createTimelineElement(); container.appendChild(timelineEl); } // Calculate position (0-100%) const currentIndex = this.radarLayers.indexOf(layer); const totalFrames = this.radarLayers.length; const position = (currentIndex / (totalFrames - 1)) * 100; // Find "now" position const now = new Date(); let nowIndex = 0; for (let i = 0; i < this.radarLayers.length; i++) { if (this.radarLayers[i].timestamp <= now) { nowIndex = i; } } const nowPosition = (nowIndex / (totalFrames - 1)) * 100; // Update indicator position (30px offset on each side for labels) const indicator = timelineEl.querySelector(".timeline-indicator"); const trackWidth = timelineEl.offsetWidth - 60; // 30px margin each side if (indicator && trackWidth > 0) { const pixelPos = 30 + (position / 100) * trackWidth; indicator.style.left = pixelPos + "px"; } // Update "now" marker position const nowMarker = timelineEl.querySelector(".timeline-now span"); if (nowMarker && trackWidth > 0) { const nowPixelPos = (nowPosition / 100) * 100; nowMarker.style.left = nowPixelPos + "%"; } // Update time label const timeLabel = timelineEl.querySelector(".timeline-time"); if (timeLabel) { const diffMinutes = Math.round((layer.timestamp - now) / 60000); if (Math.abs(diffMinutes) <= 2) { timeLabel.textContent = "Now"; } else if (diffMinutes > 0) { timeLabel.textContent = "+" + diffMinutes + "m"; } else { timeLabel.textContent = diffMinutes + "m"; } timeLabel.classList.toggle("forecast", layer.isForecast); } }, createTimelineElement: function() { const timeline = document.createElement("div"); timeline.className = "radar-timeline"; // Track (the line) const track = document.createElement("div"); track.className = "timeline-track"; // Past section (darker) const past = document.createElement("div"); past.className = "timeline-past"; // Future section (highlighted) const future = document.createElement("div"); future.className = "timeline-future"; // "Now" marker const nowMarker = document.createElement("div"); nowMarker.className = "timeline-now"; nowMarker.innerHTML = ""; // Moving indicator const indicator = document.createElement("div"); indicator.className = "timeline-indicator"; // Time label const timeLabel = document.createElement("div"); timeLabel.className = "timeline-time"; // Labels for start/end const startLabel = document.createElement("div"); startLabel.className = "timeline-label timeline-start"; startLabel.textContent = "-30m"; const endLabel = document.createElement("div"); endLabel.className = "timeline-label timeline-end"; endLabel.textContent = "+1h"; track.appendChild(past); track.appendChild(future); timeline.appendChild(track); timeline.appendChild(nowMarker); timeline.appendChild(indicator); timeline.appendChild(timeLabel); timeline.appendChild(startLabel); timeline.appendChild(endLabel); return timeline; }, addRadarLegend: function() { const container = document.getElementById("mmm-combinedmap-" + this.identifier); if (!container) return; const legend = document.createElement("div"); legend.className = "radar-legend"; // DWD provides a legend graphic via WMS const legendImg = document.createElement("img"); legendImg.src = "https://maps.dwd.de/geoserver/dwd/ows?service=WMS&version=1.3.0&request=GetLegendGraphic&format=image/png&width=15&height=15&layer=Niederschlagsradar"; legendImg.alt = "Precipitation legend"; legend.appendChild(legendImg); container.appendChild(legend); }, 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(); } } });