Initial implementation: Leaflet + DWD radar + TomTom traffic
This commit is contained in:
83
MMM-CombinedMap.css
Normal file
83
MMM-CombinedMap.css
Normal file
@@ -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; }
|
||||||
295
MMM-CombinedMap.js
Normal file
295
MMM-CombinedMap.js
Normal file
@@ -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: `<div style="background-color: ${this.config.markerColor}; width: 12px; height: 12px; border-radius: 50%; border: 2px solid white; box-shadow: 0 0 4px rgba(0,0,0,0.5);"></div>`,
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
100
README.md
100
README.md
@@ -1,3 +1,101 @@
|
|||||||
# MMM-CombinedMap
|
# MMM-CombinedMap
|
||||||
|
|
||||||
MagicMirror module combining rain radar (DWD) and traffic (TomTom) on a single Leaflet map
|
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.
|
||||||
|
|||||||
20
package.json
Normal file
20
package.json
Normal file
@@ -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": {}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user