521 lines
16 KiB
JavaScript
521 lines
16 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"
|
|
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 = [];
|
|
this.trafficFlowLayer = null;
|
|
this.trafficIncidentsLayer = null;
|
|
},
|
|
|
|
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: `<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() {
|
|
this.refreshTrafficLayers();
|
|
|
|
// Set up periodic traffic refresh (every 5 minutes)
|
|
setInterval(() => this.refreshTrafficLayers(), 5 * 60 * 1000);
|
|
},
|
|
|
|
refreshTrafficLayers: function() {
|
|
// Remove existing traffic layers
|
|
if (this.trafficFlowLayer) {
|
|
this.map.removeLayer(this.trafficFlowLayer);
|
|
}
|
|
if (this.trafficIncidentsLayer) {
|
|
this.map.removeLayer(this.trafficIncidentsLayer);
|
|
}
|
|
|
|
// Cache-buster timestamp (rounded to minute for some caching benefit)
|
|
const cacheBust = Math.floor(Date.now() / 60000);
|
|
|
|
// TomTom Traffic Flow tiles
|
|
this.trafficFlowLayer = L.tileLayer(
|
|
`https://api.tomtom.com/traffic/map/4/tile/flow/relative0/{z}/{x}/{y}.png?key=${this.config.tomtomApiKey}&tileSize=256&t=${cacheBust}`,
|
|
{
|
|
maxZoom: 18,
|
|
opacity: this.config.trafficOpacity
|
|
}
|
|
);
|
|
this.trafficFlowLayer.addTo(this.map);
|
|
|
|
// Traffic incidents overlay
|
|
this.trafficIncidentsLayer = L.tileLayer(
|
|
`https://api.tomtom.com/traffic/map/4/tile/incidents/s3/{z}/{x}/{y}.png?key=${this.config.tomtomApiKey}&tileSize=256&t=${cacheBust}`,
|
|
{
|
|
maxZoom: 18,
|
|
opacity: this.config.trafficOpacity
|
|
}
|
|
);
|
|
this.trafficIncidentsLayer.addTo(this.map);
|
|
|
|
Log.info("MMM-CombinedMap: Traffic layers refreshed");
|
|
},
|
|
|
|
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 = "<span>▼</span>";
|
|
|
|
// 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();
|
|
}
|
|
}
|
|
});
|