Files
MMM-CombinedMap/MMM-CombinedMap.js

398 lines
12 KiB
JavaScript

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: `<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);
},
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 - request larger tiles for better quality
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,
tileSize: 512, // Request larger tiles for better resolution
zoomOffset: -1 // Compensate for larger tiles
});
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();
}
}
});