diff --git a/MMM-CombinedMap.css b/MMM-CombinedMap.css new file mode 100644 index 0000000..88e9484 --- /dev/null +++ b/MMM-CombinedMap.css @@ -0,0 +1,83 @@ +.mmm-combinedmap-wrapper { + position: relative; + border-radius: 8px; + overflow: hidden; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); +} + +.mmm-combinedmap-wrapper .leaflet-container { + background: #1a1a1a; +} + +/* Hide Leaflet attribution */ +.mmm-combinedmap-wrapper .leaflet-control-attribution { + display: none; +} + +/* Marker styling */ +.mmm-combinedmap-marker { + background: transparent !important; + border: none !important; +} + +/* Radar timestamp display */ +.radar-timestamp { + position: absolute; + bottom: 8px; + left: 8px; + background: rgba(0, 0, 0, 0.7); + color: #fff; + padding: 4px 10px; + border-radius: 4px; + font-size: 12px; + font-family: "Roboto Condensed", sans-serif; + z-index: 1000; + pointer-events: none; +} + +.radar-timestamp.forecast { + background: rgba(0, 100, 180, 0.8); +} + +.radar-timestamp.forecast::after { + content: " ⟩"; + opacity: 0.7; +} + +/* Legend (optional, can be enabled) */ +.mmm-combinedmap-legend { + position: absolute; + bottom: 8px; + right: 8px; + background: rgba(0, 0, 0, 0.7); + padding: 6px 10px; + border-radius: 4px; + font-size: 10px; + color: #ccc; + z-index: 1000; +} + +.mmm-combinedmap-legend .legend-item { + display: flex; + align-items: center; + margin: 2px 0; +} + +.mmm-combinedmap-legend .legend-color { + width: 12px; + height: 12px; + margin-right: 6px; + border-radius: 2px; +} + +/* Traffic colors */ +.legend-traffic-free { background: #00b300; } +.legend-traffic-moderate { background: #ffcc00; } +.legend-traffic-heavy { background: #ff6600; } +.legend-traffic-blocked { background: #cc0000; } + +/* Rain intensity gradient hint */ +.legend-rain-light { background: #a0d0ff; } +.legend-rain-moderate { background: #4080ff; } +.legend-rain-heavy { background: #0040cc; } +.legend-rain-extreme { background: #ff00ff; } diff --git a/MMM-CombinedMap.js b/MMM-CombinedMap.js new file mode 100644 index 0000000..1949c91 --- /dev/null +++ b/MMM-CombinedMap.js @@ -0,0 +1,295 @@ +Module.register("MMM-CombinedMap", { + defaults: { + // Map settings + lat: 49.8833, + lng: 8.8333, + zoom: 7, + width: "300px", + height: "300px", + + // Marker + showMarker: true, + markerColor: "#3388ff", + + // Layers + showTraffic: true, + showRadar: true, + + // TomTom API key (get free at developer.tomtom.com) + tomtomApiKey: "", + + // Animation settings + animationSpeed: 500, // ms per frame + radarOpacity: 0.7, + trafficOpacity: 0.7, + + // 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.radarTimestamps = []; + }, + + 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); + + // Dark base map (CartoDB Dark Matter) + L.tileLayer("https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png", { + maxZoom: 19, + opacity: 1 + }).addTo(this.map); + + // Traffic layer (TomTom) + if (this.config.showTraffic && this.config.tomtomApiKey) { + this.addTrafficLayer(); + } + + // Radar layer (DWD) + if (this.config.showRadar) { + this.initRadarLayers(); + } + + // 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); + }, + + initRadarLayers: function() { + // Fetch available radar timestamps from DWD + this.fetchRadarTimestamps(); + }, + + fetchRadarTimestamps: function() { + // DWD WMS GetCapabilities to find available times + // For simplicity, we'll generate timestamps for the last 2 hours + 2 hours forecast + // DWD updates every 5 minutes + + const now = new Date(); + const timestamps = []; + + // Past 2 hours (every 5 minutes = 24 frames) + for (let i = 24; i >= 0; i--) { + const t = new Date(now.getTime() - i * 5 * 60 * 1000); + // Round to nearest 5 minutes + t.setMinutes(Math.floor(t.getMinutes() / 5) * 5, 0, 0); + timestamps.push(t); + } + + // Future 2 hours (nowcast) - every 5 minutes = 24 frames + for (let i = 1; i <= 24; i++) { + const t = new Date(now.getTime() + i * 5 * 60 * 1000); + t.setMinutes(Math.floor(t.getMinutes() / 5) * 5, 0, 0); + timestamps.push(t); + } + + this.radarTimestamps = timestamps; + this.createRadarLayers(); + }, + + createRadarLayers: function() { + // Clear existing layers + this.radarLayers.forEach(layer => { + if (this.map.hasLayer(layer.leafletLayer)) { + this.map.removeLayer(layer.leafletLayer); + } + }); + this.radarLayers = []; + + // DWD WMS for radar + // Using RV (Radar Vorhersage/Forecast) product for nowcast + // and standard radar for past + + const now = new Date(); + + this.radarTimestamps.forEach((timestamp, index) => { + const isForecast = timestamp > now; + const timeStr = timestamp.toISOString(); + + // DWD WMS layer - using Niederschlagsradar (precipitation radar) + const layer = L.tileLayer.wms("https://maps.dwd.de/geoserver/dwd/wms", { + layers: isForecast ? "dwd:RV-Produkt" : "dwd:Niederschlagsradar", + format: "image/png", + transparent: true, + opacity: 0, // Start hidden + time: timeStr, + styles: "", + version: "1.3.0", + crs: L.CRS.EPSG3857 + }); + + layer.addTo(this.map); + + this.radarLayers.push({ + leafletLayer: layer, + timestamp: timestamp, + isForecast: isForecast + }); + }); + + // Start animation + if (this.radarLayers.length > 0) { + this.startAnimation(); + } + }, + + startAnimation: function() { + if (this.animationTimer) { + clearInterval(this.animationTimer); + } + + // Show first frame + this.showRadarFrame(0); + + this.animationTimer = setInterval(() => { + this.currentRadarIndex = (this.currentRadarIndex + 1) % this.radarLayers.length; + this.showRadarFrame(this.currentRadarIndex); + }, this.config.animationSpeed); + }, + + 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 if needed + 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 (diffMinutes === 0) { + 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.fetchRadarTimestamps(); + }, + + suspend: function() { + if (this.animationTimer) { + clearInterval(this.animationTimer); + this.animationTimer = null; + } + }, + + resume: function() { + if (this.radarLayers.length > 0) { + this.startAnimation(); + } + } +}); diff --git a/README.md b/README.md index d75d287..e3c02a4 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,101 @@ # MMM-CombinedMap -MagicMirror module combining rain radar (DWD) and traffic (TomTom) on a single Leaflet map \ No newline at end of file +A MagicMirror² module that displays a combined map with: +- **Rain radar** from DWD (Deutscher Wetterdienst) with animated history and forecast +- **Traffic layer** from TomTom showing real-time traffic flow and incidents + +All on a sleek dark-themed Leaflet map. + + + +## Installation + +1. Navigate to your MagicMirror modules folder: + ```bash + cd ~/MagicMirror/modules + ``` + +2. Clone this repository: + ```bash + git clone https://gitea.olex.me/clawd/MMM-CombinedMap.git + ``` + +3. No npm install needed - dependencies are loaded from CDN. + +## Configuration + +Add the module to your `config/config.js`: + +```js +{ + module: "MMM-CombinedMap", + position: "bottom_right", + config: { + lat: 49.8833, // Latitude of map center + lng: 8.8333, // Longitude of map center + zoom: 7, // Zoom level (1-18) + width: "300px", // Map width + height: "300px", // Map height + + showMarker: true, // Show a marker at center + markerColor: "#3388ff", // Marker color + + showTraffic: true, // Enable traffic layer + showRadar: true, // Enable rain radar layer + + tomtomApiKey: "", // Your TomTom API key (required for traffic) + + animationSpeed: 500, // Radar animation speed (ms per frame) + radarOpacity: 0.7, // Rain radar opacity (0-1) + trafficOpacity: 0.7, // Traffic layer opacity (0-1) + + updateInterval: 300000 // Radar refresh interval (ms), default 5 min + } +} +``` + +## Getting a TomTom API Key + +1. Go to [developer.tomtom.com](https://developer.tomtom.com/) +2. Create a free account +3. Create a new application +4. Copy the API key +5. Free tier includes 50,000 tile requests per day + +## How It Works + +### Base Map +Uses CartoDB Dark Matter tiles - a dark-themed OpenStreetMap-based tileset perfect for MagicMirror displays. + +### Traffic Layer +TomTom provides two overlay layers: +- **Traffic Flow**: Color-coded roads (green = free, yellow = moderate, red = heavy, dark red = blocked) +- **Traffic Incidents**: Icons for accidents, roadwork, closures + +### Rain Radar +DWD (German Weather Service) provides free WMS radar data: +- **Past 2 hours**: Actual radar observations (every 5 minutes) +- **Next 2 hours**: Nowcast/forecast data + +The radar animates through all frames showing precipitation movement and predicted evolution. + +## Timestamp Display + +The timestamp overlay in the corner shows: +- **Negative values** (e.g., `-30 min`): Historical radar data +- **Now**: Current observation +- **Positive values** (e.g., `+30 min`): Forecast/nowcast - shown with blue background + +## Data Sources + +- **Base Map**: [CartoDB/OpenStreetMap](https://carto.com/basemaps/) +- **Traffic**: [TomTom Traffic API](https://developer.tomtom.com/traffic-api) +- **Rain Radar**: [DWD Open Data](https://www.dwd.de/DE/leistungen/opendata/opendata.html) + +## License + +MIT License + +## Credits + +Built by [James](https://gitea.olex.me/clawd) for Olex's MagicMirror setup. diff --git a/package.json b/package.json new file mode 100644 index 0000000..b4fd51a --- /dev/null +++ b/package.json @@ -0,0 +1,20 @@ +{ + "name": "mmm-combinedmap", + "version": "1.0.0", + "description": "MagicMirror module combining rain radar (DWD) and traffic (TomTom) on a single Leaflet map", + "main": "MMM-CombinedMap.js", + "repository": { + "type": "git", + "url": "https://gitea.olex.me/clawd/MMM-CombinedMap.git" + }, + "keywords": [ + "MagicMirror", + "weather", + "radar", + "traffic", + "map" + ], + "author": "clawd", + "license": "MIT", + "dependencies": {} +}