(function () { const DEFAULT_MODEL = "gfs-025"; const PUBLISHED_REGION = String(window.IWIND_FORECAST_DEFAULT_REGION || "europe"); const WORLD_REGION = "world"; const WORLD_TILE_MIN_ZOOM = 0; const WORLD_TILE_MAX_ZOOM = 8; const WORLD_TILE_DISPLAY_MAX_ZOOM = 12; const FORECAST_REGIONS = Array.isArray(window.IWIND_FORECAST_REGIONS) ? window.IWIND_FORECAST_REGIONS : []; const FORECAST_MODEL_LABELS = { "gfs-025": "GFS0.25°", "icon-d2": "ICON-D2", "icon-eu": "ICON-EU", "ecmwf": "ECMWF", }; const qs = new URLSearchParams(window.location.search); const REQUESTED_REGION = String(qs.get("region") || window.IWIND_FORECAST_DEMO_DEFAULT_REGION || "").trim(); const apiOrigin = window.location.hostname === "www.iwind.at" || window.location.hostname === "iwind.at" ? "https://weather.iwind.at" : ""; const dom = { modelSelect: document.getElementById("modelSelect"), basemapSelect: document.getElementById("basemapSelect"), opacityRange: document.getElementById("opacityRange"), opacityValue: document.getElementById("opacityValue"), threadsToggle: document.getElementById("threadsToggle"), threadsDensityRange: document.getElementById("threadsDensityRange"), threadsDensityValue: document.getElementById("threadsDensityValue"), threadsLengthRange: document.getElementById("threadsLengthRange"), threadsLengthValue: document.getElementById("threadsLengthValue"), threadsSpeedRange: document.getElementById("threadsSpeedRange"), threadsSpeedValue: document.getElementById("threadsSpeedValue"), threadsWidthRange: document.getElementById("threadsWidthRange"), threadsWidthValue: document.getElementById("threadsWidthValue"), threadsFadeRange: document.getElementById("threadsFadeRange"), threadsFadeValue: document.getElementById("threadsFadeValue"), reloadButton: document.getElementById("reloadButton"), followPointButton: document.getElementById("followPointButton"), runValue: document.getElementById("runValue"), validValue: document.getElementById("validValue"), timelineLabel: document.getElementById("timelineLabel"), timelineMeta: document.getElementById("timelineMeta"), prevButton: document.getElementById("prevButton"), playButton: document.getElementById("playButton"), nextButton: document.getElementById("nextButton"), timeRange: document.getElementById("timeRange"), timelineTicks: document.getElementById("timelineTicks"), pointPanel: document.getElementById("pointPanel"), pointTitle: document.getElementById("pointTitle"), pointSubtitle: document.getElementById("pointSubtitle"), pointCurrent: document.getElementById("pointCurrent"), pointSeries: document.getElementById("pointSeries"), resetViewButton: document.getElementById("resetViewButton"), openMainMapButton: document.getElementById("openMainMapButton"), viewStateValue: document.getElementById("viewStateValue"), }; const DEMO_VIEW_STORAGE_KEY = "iwind:forecast-demo:view:v1"; const jawgToken = "jgKifz7zUmdACbcf8eWvpk4jwVNtCykl7prTVhb7NKPMOmqitsVSEVp1Oc1mhGk6"; const jawgAttribution = '© JawgMaps | © OpenStreetMap'; const jawgBaseOptions = { attribution: jawgAttribution, maxZoom: 22, updateWhenZooming: false, updateWhenIdle: true, keepBuffer: 1, crossOrigin: true, }; const state = { model: qs.get("model") || DEFAULT_MODEL, manifest: null, entries: [], activeIndex: 0, region: REQUESTED_REGION || PUBLISHED_REGION, basemapKey: "carto-light", hasFocusedDataBounds: false, hasPinnedInitialView: false, latestGridBounds: null, latestParticleBounds: null, activeSurfaceKey: "", surfaceAbortController: null, surfaceRequestId: 0, }; let timelineController = null; let panelShell = null; let pointInteraction = null; let forecastData = null; function parseFinite(value, fallback) { const num = Number(value); return Number.isFinite(num) ? num : fallback; } function setPointPanelOpen(open) { if (panelShell) { panelShell.setPointPanelOpen(open); } } function readStoredViewState() { try { const raw = window.localStorage.getItem(DEMO_VIEW_STORAGE_KEY); if (!raw) return null; const parsed = JSON.parse(raw); return parsed && typeof parsed === "object" ? parsed : null; } catch (_) { return null; } } const storedView = readStoredViewState(); const initialLat = parseFinite(qs.get("lat"), parseFinite(storedView && storedView.lat, 47.5)); const initialLng = parseFinite(qs.get("lng"), parseFinite(storedView && storedView.lng, 14.0)); const initialZoom = parseFinite(qs.get("z"), parseFinite(storedView && storedView.z, 6)); const requestedBaseKey = String(qs.get("base") || (storedView && storedView.base) || "carto-light").trim(); state.basemapKey = requestedBaseKey || "carto-light"; state.hasPinnedInitialView = qs.has("lat") || qs.has("lng") || qs.has("z") || !!storedView; dom.modelSelect.value = state.model; const standard = L.tileLayer("https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png", { attribution: '© CartoDB', subdomains: "abcd", maxZoom: 19, updateWhenIdle: true, updateWhenZooming: false, keepBuffer: 2, crossOrigin: true, }); const satellite = L.tileLayer("https://{s}.google.com/vt/lyrs=s&x={x}&y={y}&z={z}", { subdomains: ["mt0", "mt1", "mt2", "mt3"], attribution: "© Google Maps", updateWhenZooming: false, updateWhenIdle: true, keepBuffer: 1, }); const openTopoMap = L.tileLayer("https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png", { maxZoom: 17, attribution: "© OpenTopoMap (CC-BY-SA), © OpenStreetMap contributors", updateWhenZooming: false, updateWhenIdle: true, keepBuffer: 1, }); const esriTopo = L.tileLayer("https://server.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/{z}/{y}/{x}", { attribution: "Tiles © Esri", maxZoom: 17, updateWhenZooming: false, updateWhenIdle: true, keepBuffer: 1, }); const openMapTiles = L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", { attribution: "© OpenStreetMap contributors", updateWhenZooming: false, updateWhenIdle: true, keepBuffer: 1, }); const jawgTerrain = L.tileLayer(`https://tile.jawg.io/jawg-terrain/{z}/{x}/{y}{r}.png?access-token=${jawgToken}`, jawgBaseOptions); const esriWorldImagery = L.layerGroup([ L.tileLayer("https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}", { attribution: "© Esri World Imagery", maxZoom: 18, }), L.tileLayer("https://server.arcgisonline.com/ArcGIS/rest/services/Reference/World_Boundaries_and_Places/MapServer/tile/{z}/{y}/{x}", { attribution: "© Esri Labels", maxZoom: 18, }), ]); const baseLayersByKey = { "carto-light": standard, satellite: satellite, "open-topo": openTopoMap, "esri-topo": esriTopo, openmaptiles: openMapTiles, "jawg-terrain": jawgTerrain, "esri-3d": esriWorldImagery, }; const initialBaseLayer = baseLayersByKey[state.basemapKey] || standard; const map = L.map("map", { zoomControl: true, preferCanvas: true, zoomSnap: 1, zoomDelta: 1, worldCopyJump: false, layers: [initialBaseLayer], }).setView([initialLat, initialLng], initialZoom); if (dom.basemapSelect) { dom.basemapSelect.value = baseLayersByKey[state.basemapKey] ? state.basemapKey : "carto-light"; } L.control.scale({ imperial: false }).addTo(map); const windColorPane = map.createPane("windColor"); windColorPane.style.zIndex = "450"; windColorPane.style.pointerEvents = "none"; windColorPane.style.mixBlendMode = "normal"; windColorPane.style.opacity = "1"; let forecastLayer = L.tileLayer("", { opacity: parseInt(dom.opacityRange.value, 10) / 100, className: "forecast-tiles", pane: "windColor", crossOrigin: true, updateWhenIdle: true, keepBuffer: 2, maxZoom: 19, }); let gridFallbackLayer = null; const particles = L.windParticlesLayer ? L.windParticlesLayer(null, { active: true, particleCount: Number(dom.threadsDensityRange.value), speedScale: Number(dom.threadsLengthRange.value) / 100, velocityScale: Number(dom.threadsSpeedRange.value) / 100, fadeAlpha: Number(dom.threadsFadeRange.value) / 100, lineWidth: Number(dom.threadsWidthRange.value), minSpeed: 0.2, useColors: true, color: "rgba(22, 87, 128, 0.3)", }) : null; if (particles) { particles.addTo(map); } function parseForecastDate(value) { if (!value) return null; if (/^\d{8}T\d{6}Z$/.test(value)) { const iso = value.slice(0, 4) + "-" + value.slice(4, 6) + "-" + value.slice(6, 8) + "T" + value.slice(9, 11) + ":" + value.slice(11, 13) + ":" + value.slice(13, 15) + "Z"; return new Date(iso); } const date = new Date(value); return Number.isNaN(date.getTime()) ? null : date; } function formatDateLabel(value) { const date = parseForecastDate(value); if (!date) return "-"; return date.toLocaleString(undefined, { weekday: "short", day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit", }); } function formatTimelineHour(value) { const date = parseForecastDate(value); if (!date) return "-"; return date.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit", }); } function timelineDayKey(value) { const date = parseForecastDate(value); if (!date) return "unknown"; return date.getFullYear() + "-" + String(date.getMonth() + 1).padStart(2, "0") + "-" + String(date.getDate()).padStart(2, "0"); } function formatTimelineDayCompact(value) { const date = parseForecastDate(value); if (!date) return "-"; const weekday = date.toLocaleDateString(undefined, { weekday: "long" }); const day = String(date.getDate()).padStart(2, "0"); const month = String(date.getMonth() + 1).padStart(2, "0"); const year = String(date.getFullYear()).slice(-2); return weekday + ", " + day + "." + month + "." + year; } function formatTimelineLabelCompact(value) { const date = parseForecastDate(value); if (!date) return "-"; const weekday = date.toLocaleDateString(undefined, { weekday: "short" }).replace(/\.$/, "").toUpperCase(); return weekday + " " + date.getDate() + "." + (date.getMonth() + 1) + ". " + formatTimelineHour(value); } function formatForecastModelLabel(modelKey) { const key = String(modelKey || "").trim().toLowerCase(); if (FORECAST_MODEL_LABELS[key]) return FORECAST_MODEL_LABELS[key]; return key ? key.replace(/-/g, " ").replace(/\b\w/g, function (char) { return char.toUpperCase(); }) : "Forecast"; } function activeBasemapKey() { return baseLayersByKey[state.basemapKey] ? state.basemapKey : "carto-light"; } function buildDemoUrl() { const url = new URL(window.location.pathname, window.location.origin); const center = map.getCenter(); url.searchParams.set("model", state.model); url.searchParams.set("lat", center.lat.toFixed(6)); url.searchParams.set("lng", center.lng.toFixed(6)); url.searchParams.set("z", String(Math.round(map.getZoom()))); url.searchParams.set("base", activeBasemapKey()); if (REQUESTED_REGION || (state.region && state.region !== PUBLISHED_REGION)) { url.searchParams.set("region", REQUESTED_REGION || state.region); } return url; } function buildMainMapUrl() { const url = new URL("/map", window.location.origin); const center = map.getCenter(); url.searchParams.set("lat", center.lat.toFixed(6)); url.searchParams.set("lng", center.lng.toFixed(6)); url.searchParams.set("z", String(Math.round(map.getZoom()))); url.searchParams.set("base", activeBasemapKey()); return url.toString(); } function updateViewStateUi() { if (!dom.viewStateValue) return; const center = map.getCenter(); dom.viewStateValue.textContent = activeBasemapKey() + " | z" + Math.round(map.getZoom()) + " | " + center.lat.toFixed(2) + ", " + center.lng.toFixed(2); } function persistDemoView() { const center = map.getCenter(); const payload = { lat: Number(center.lat.toFixed(6)), lng: Number(center.lng.toFixed(6)), z: Math.round(map.getZoom()), base: activeBasemapKey(), }; try { window.localStorage.setItem(DEMO_VIEW_STORAGE_KEY, JSON.stringify(payload)); } catch (_) {} try { window.history.replaceState({}, "", buildDemoUrl().toString()); } catch (_) {} if (dom.openMainMapButton) { dom.openMainMapButton.dataset.targetUrl = buildMainMapUrl(); } updateViewStateUi(); } function setBasemap(key, persist) { const nextKey = baseLayersByKey[key] ? key : "carto-light"; state.basemapKey = nextKey; Object.keys(baseLayersByKey).forEach(function (layerKey) { const layer = baseLayersByKey[layerKey]; if (layerKey === nextKey) { if (!map.hasLayer(layer)) map.addLayer(layer); } else if (map.hasLayer(layer)) { map.removeLayer(layer); } }); if (dom.basemapSelect) { dom.basemapSelect.value = nextKey; } if (persist !== false) { persistDemoView(); } else { updateViewStateUi(); } } function createAbortError() { try { return new DOMException("Surface request superseded", "AbortError"); } catch (_) { const error = new Error("Surface request superseded"); error.name = "AbortError"; return error; } } function entrySurfaceKey(entry) { if (!entry) return ""; if (entry.source === "published-bin") { return String(entry.binary || entry.valid || ""); } return String(entry.tiles || entry.vector || entry.valid || ""); } function ensureLatestSurfaceRequest(requestId, signal) { if ((signal && signal.aborted) || requestId !== state.surfaceRequestId) { throw createAbortError(); } } function setActiveIndex(nextIndex, options) { if (!state.entries.length) return; const opts = options || {}; const size = state.entries.length; const normalized = ((nextIndex % size) + size) % size; const changed = normalized !== state.activeIndex; state.activeIndex = normalized; const entry = state.entries[normalized]; if (timelineController) { timelineController.refreshActive(entry, opts); } if (!changed && opts.fromScroll) return; if (!changed && state.activeSurfaceKey === entrySurfaceKey(entry)) return; preloadNextTile(); requestSurfaceSync(entry).catch(function (error) { if (error && error.name === "AbortError") { return; } if (entry && entry.source === "published-bin") { findFirstUsableEntryIndex(normalized + 1).then(function (fallbackIndex) { if (fallbackIndex >= 0 && fallbackIndex !== normalized) { setActiveIndex(fallbackIndex, { center: true, recovered: true }); return; } dom.timelineLabel.textContent = "Forecast konnte nicht geladen werden"; dom.timelineMeta.textContent = "Keine gueltige Forecast-Datei fuer diesen Zeitpunkt gefunden."; }); return; } if (entry && entry.tiles && surfaceZoomAllowed(entry)) { forecastLayer.setUrl(forecastData.toAbsolute(entry.tiles)); } else { clearSurfaceLayer(); } }); if (pointInteraction) { pointInteraction.handleActiveEntryChange(); } } async function requestSurfaceSync(entry) { if (state.surfaceAbortController) { state.surfaceAbortController.abort(); } const controller = new AbortController(); const requestId = state.surfaceRequestId + 1; state.surfaceAbortController = controller; state.surfaceRequestId = requestId; try { await syncSurface(entry, { signal: controller.signal, requestId: requestId, }); ensureLatestSurfaceRequest(requestId, controller.signal); state.activeSurfaceKey = entrySurfaceKey(entry); } finally { if (state.surfaceAbortController === controller) { state.surfaceAbortController = null; } } } function preloadNextTile() { if (state.entries.length < 2) return; const nextEntry = state.entries[(state.activeIndex + 1) % state.entries.length]; if (!nextEntry || nextEntry.source === "published-bin" || !nextEntry.tiles) return; if (!surfaceZoomAllowed(nextEntry)) return; const center = map.getCenter(); const zoom = surfaceRequestZoom(nextEntry); const tilePoint = map.project(center, zoom).divideBy(256).floor(); const url = forecastData.toAbsolute(nextEntry.tiles) .replace("{z}", String(zoom)) .replace("{x}", String(tilePoint.x)) .replace("{y}", String(tilePoint.y)); const image = new Image(); image.crossOrigin = "anonymous"; image.src = url; } function updateOpacity() { const value = parseInt(dom.opacityRange.value, 10); dom.opacityValue.textContent = value + "%"; forecastLayer.setOpacity(value / 100); if (gridFallbackLayer) { if (typeof gridFallbackLayer.setOpacity === "function") { gridFallbackLayer.setOpacity(value / 100); } else { gridFallbackLayer.options.fillOpacity = value / 100; if (typeof gridFallbackLayer._draw === "function") { gridFallbackLayer._draw(); } } } } function updateParticlesUi() { dom.threadsDensityValue.textContent = dom.threadsDensityRange.value; dom.threadsLengthValue.textContent = dom.threadsLengthRange.value; dom.threadsSpeedValue.textContent = dom.threadsSpeedRange.value + "%"; dom.threadsWidthValue.textContent = Number(dom.threadsWidthRange.value).toFixed(1) + " px"; dom.threadsFadeValue.textContent = dom.threadsFadeRange.value + "%"; } function updateParticleOptions() { updateParticlesUi(); if (!particles) return; particles.setOptions({ active: !!dom.threadsToggle.checked, particleCount: Number(dom.threadsDensityRange.value), speedScale: Number(dom.threadsLengthRange.value) / 100, velocityScale: Number(dom.threadsSpeedRange.value) / 100, fadeAlpha: Number(dom.threadsFadeRange.value) / 100, lineWidth: Number(dom.threadsWidthRange.value), }); } function escapeHtml(value) { return String(value) .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } function regionArea(region) { if (!region) return Number.POSITIVE_INFINITY; return Math.max(0, Number(region.east) - Number(region.west)) * Math.max(0, Number(region.north) - Number(region.south)); } function normalizeLongitude(value) { let lng = Number(value); if (!Number.isFinite(lng)) return lng; while (lng > 180) lng -= 360; while (lng < -180) lng += 360; return lng; } function regionContainsLatLng(region, latlng) { if (!region || !latlng) return false; const lat = Number(latlng.lat); const lng = normalizeLongitude(latlng.lng); if (region.key === "mediterranean" && lat > 45.5) { return false; } return lat >= Number(region.south) && lat <= Number(region.north) && lng >= Number(region.west) && lng <= Number(region.east); } function resolvePreferredRegion(latlng) { if (REQUESTED_REGION) { return REQUESTED_REGION; } const matches = FORECAST_REGIONS .filter(function (region) { return regionContainsLatLng(region, latlng); }) .sort(function (a, b) { return regionArea(a) - regionArea(b); }); if (matches.length) { return matches[0].key || PUBLISHED_REGION; } return PUBLISHED_REGION; } function regionQuery(regionKey) { const key = String(regionKey || "").trim(); return key ? "?region=" + encodeURIComponent(key) : ""; } function buildRegionFallbackChain(preferredRegion, defaultRegion) { if (String(preferredRegion || "").trim() === WORLD_REGION) { return [WORLD_REGION]; } return [preferredRegion, WORLD_REGION, defaultRegion].filter(function (regionKey, index, list) { const key = String(regionKey || "").trim(); return key && list.findIndex(function (candidate) { return String(candidate || "").trim() === key; }) === index; }); } function isWorldMode() { return REQUESTED_REGION === WORLD_REGION || state.region === WORLD_REGION; } function isWorldTileEntry(entry) { if (!entry || !entry.tiles) return false; const region = String(entry.region || "").trim(); const tiles = String(entry.tiles || ""); return region === WORLD_REGION || tiles.indexOf("/world/") !== -1; } function configureForecastLayerZoom(entry) { if (isWorldTileEntry(entry)) { forecastLayer.options.maxNativeZoom = WORLD_TILE_MAX_ZOOM; forecastLayer.options.maxZoom = WORLD_TILE_DISPLAY_MAX_ZOOM; return; } delete forecastLayer.options.maxNativeZoom; forecastLayer.options.maxZoom = 19; } function surfaceRequestZoom(entry) { const currentZoom = Math.round(map.getZoom()); if (isWorldTileEntry(entry)) { return Math.min(currentZoom, WORLD_TILE_MAX_ZOOM); } const entryMaxZoom = Number(entry && entry.maxZoom); return Number.isFinite(entryMaxZoom) ? Math.min(currentZoom, entryMaxZoom) : currentZoom; } function entriesForRegion(entries, regionKey) { const list = Array.isArray(entries) ? entries : []; if (String(regionKey || "").trim() !== WORLD_REGION) { return list; } return list.filter(isWorldTileEntry); } async function findFirstUsableEntryIndex(startIndex) { if (!state.entries.length) return -1; for (let offset = 0; offset < state.entries.length; offset += 1) { const index = (startIndex + offset) % state.entries.length; const entry = state.entries[index]; if (!entry) continue; if (isWorldMode()) { if (isWorldTileEntry(entry)) { return index; } continue; } if (entry.source !== "published-bin" || !entry.binary) { return index; } if (await forecastData.publishedBinaryExists(entry.binary)) { return index; } } return -1; } async function loadForecast() { if (timelineController) { timelineController.stop(); } let manifest; let entries; const preferredRegion = resolvePreferredRegion(map.getCenter()); const preferApiTimesOnly = preferredRegion === WORLD_REGION; state.region = preferredRegion; const defaultRegion = PUBLISHED_REGION; const fallbackRegions = buildRegionFallbackChain(preferredRegion, defaultRegion); try { if (preferApiTimesOnly) { throw new Error("world region uses api times"); } let publishedError = null; for (const regionKey of fallbackRegions) { try { const latest = await forecastData.fetchJson( forecastData.publishedLatestUrlForRegion(state.model, regionKey), { cache: "no-store" } ); const publishedManifest = await forecastData.fetchJson(latest.manifest, { cache: "no-store" }); const publishedEntries = entriesForRegion( forecastData.buildEntriesFromPublishedManifest(publishedManifest), regionKey ); if (!publishedEntries.length) { throw new Error("published manifest has no files"); } manifest = publishedManifest; entries = publishedEntries; manifest.run = manifest.run_at || manifest.run || latest.run; manifest.region = manifest.region || latest.region || regionKey || PUBLISHED_REGION; state.region = manifest.region; publishedError = null; break; } catch (error) { publishedError = error; } } if (publishedError) { throw publishedError; } } catch (publishedError) { let fallbackError = publishedError; entries = []; for (const regionKey of fallbackRegions) { try { manifest = await forecastData.fetchJson( "/api/forecast/" + encodeURIComponent(state.model) + "/manifest" + regionQuery(regionKey) ); try { const resolvedRegion = manifest.region || regionKey || defaultRegion; if (resolvedRegion !== WORLD_REGION) { const publishedManifestUrl = forecastData.publishedRunManifestUrl( state.model, manifest.run_key || manifest.run || manifest.run_at || "", resolvedRegion ); if (publishedManifestUrl) { const publishedManifest = await forecastData.fetchJson(publishedManifestUrl, { cache: "no-store" }); const publishedEntries = entriesForRegion( forecastData.buildEntriesFromPublishedManifest(publishedManifest), resolvedRegion ); if (publishedEntries.length) { manifest = publishedManifest; manifest.run = manifest.run_key || manifest.run || manifest.run_at; manifest.region = manifest.region || resolvedRegion || PUBLISHED_REGION; entries = publishedEntries; break; } } } } catch (_) { } const times = await forecastData.fetchJson( "/api/forecast/" + encodeURIComponent(state.model) + "/times" + regionQuery(regionKey) ); entries = entriesForRegion(Array.isArray(times.valid) ? times.valid : [], regionKey); if (entries.length) { manifest.region = manifest.region || regionKey || defaultRegion; break; } } catch (error) { fallbackError = error; entries = []; } } if (!entries.length) { throw fallbackError; } } state.manifest = manifest; state.region = manifest.region || preferredRegion || PUBLISHED_REGION; state.entries = entries; state.activeIndex = 0; dom.runValue.textContent = formatDateLabel(manifest.run_at || manifest.run || entries[0].valid); dom.timeRange.max = String(entries.length - 1); dom.timeRange.value = "0"; if (timelineController) { timelineController.renderTicks(); } const firstUsableIndex = await findFirstUsableEntryIndex(0); setActiveIndex(firstUsableIndex >= 0 ? firstUsableIndex : 0, { center: true }); } function gridBounds(grid) { const south = Math.min(grid.lat0, grid.lat0 + grid.dlat * (grid.nlat - 1)); const north = Math.max(grid.lat0, grid.lat0 + grid.dlat * (grid.nlat - 1)); const west = Math.min(grid.lon0, grid.lon0 + grid.dlon * (grid.nlon - 1)); const east = Math.max(grid.lon0, grid.lon0 + grid.dlon * (grid.nlon - 1)); return L.latLngBounds([south, west], [north, east]); } function applyGridFocus(grid) { const bounds = gridBounds(grid); state.latestGridBounds = bounds; state.latestParticleBounds = bounds; if (particles && typeof particles.setBounds === "function") { particles.setBounds(bounds); } if (state.hasFocusedDataBounds || state.hasPinnedInitialView) return; state.hasFocusedDataBounds = true; map.fitBounds(bounds.pad(0.08), { animate: false, padding: [24, 24], }); persistDemoView(); } function centerTileUrl(tileTemplate, entry) { const center = map.getCenter(); const zoom = surfaceRequestZoom(entry); const tilePoint = map.project(center, zoom).divideBy(256).floor(); return forecastData.toAbsolute(tileTemplate) .replace("{z}", String(zoom)) .replace("{x}", String(tilePoint.x)) .replace("{y}", String(tilePoint.y)); } function entryRegion(entry) { return String((entry && entry.region) || state.region || "").trim(); } function surfaceZoomAllowed(entry) { if (!entry || !entry.tiles) return true; const minZoom = Number.isFinite(Number(entry.minZoom)) ? Number(entry.minZoom) : null; const configuredMaxZoom = Number.isFinite(Number(entry.maxZoom)) ? Number(entry.maxZoom) : null; const maxZoom = isWorldTileEntry(entry) ? WORLD_TILE_DISPLAY_MAX_ZOOM : configuredMaxZoom; const zoom = map.getZoom(); if (minZoom !== null && zoom < minZoom) return false; if (maxZoom !== null && zoom > maxZoom) return false; return true; } function clearSurfaceLayer() { if (map.hasLayer(forecastLayer)) { map.removeLayer(forecastLayer); } if (gridFallbackLayer && map.hasLayer(gridFallbackLayer)) { map.removeLayer(gridFallbackLayer); } } async function tileAvailable(entry, signal) { if (!entry || !entry.tiles) return false; if (isWorldMode() && !isWorldTileEntry(entry)) return false; if (!surfaceZoomAllowed(entry)) return false; try { const response = await fetch(centerTileUrl(entry.tiles, entry), { method: "HEAD", cache: "no-store", credentials: "same-origin", signal: signal, }); return response.ok; } catch (_) { return false; } } async function syncSurface(entry, runtimeOptions) { const opts = runtimeOptions || {}; if (isWorldMode() && entry && !isWorldTileEntry(entry)) { clearSurfaceLayer(); throw new Error("World forecast entry is not tile-backed"); } if (entry && entry.tiles && !surfaceZoomAllowed(entry)) { clearSurfaceLayer(); if (entryRegion(entry) === WORLD_REGION) { dom.timelineMeta.textContent = "World-Windlayer nativ ab Zoom " + WORLD_TILE_MIN_ZOOM + " bis " + WORLD_TILE_MAX_ZOOM + ", sichtbar bis Zoom " + WORLD_TILE_DISPLAY_MAX_ZOOM + "."; } return; } if (entry && entry.source === "published-bin" && entry.binary) { const binaryExists = await forecastData.publishedBinaryExists(entry.binary, { signal: opts.signal }); ensureLatestSurfaceRequest(opts.requestId, opts.signal); if (!binaryExists) { throw new Error("Published binary missing for " + entry.valid); } const grid = await forecastData.loadPublishedGrid(entry, { signal: opts.signal }); ensureLatestSurfaceRequest(opts.requestId, opts.signal); applyGridFocus(grid); if (particles && typeof particles.setField === "function") { particles.setField(grid); if (state.latestParticleBounds && typeof particles.setBounds === "function") { particles.setBounds(state.latestParticleBounds); } updateParticleOptions(); } if (!gridFallbackLayer) { gridFallbackLayer = L.windGridLayer(grid, { pane: "windColor", fillOpacity: parseInt(dom.opacityRange.value, 10) / 100, blurPx: 0.8, featherPx: 10, }).addTo(map); } else { gridFallbackLayer.setData(grid); if (!map.hasLayer(gridFallbackLayer)) { gridFallbackLayer.addTo(map); } } if (map.hasLayer(forecastLayer)) { map.removeLayer(forecastLayer); } return; } const hasTile = await tileAvailable(entry, opts.signal); ensureLatestSurfaceRequest(opts.requestId, opts.signal); if (hasTile) { configureForecastLayerZoom(entry); if (!map.hasLayer(forecastLayer)) { forecastLayer.addTo(map); } forecastLayer.setUrl(forecastData.toAbsolute(entry.tiles)); if (gridFallbackLayer && map.hasLayer(gridFallbackLayer)) { map.removeLayer(gridFallbackLayer); } return; } if (!entry.vector || typeof L.windGridLayer !== "function") { if (isWorldMode()) { clearSurfaceLayer(); dom.timelineMeta.textContent = "World-Windlayer fuer diesen Zeitpunkt nicht verfuegbar."; return; } configureForecastLayerZoom(entry); if (!map.hasLayer(forecastLayer)) { forecastLayer.addTo(map); } forecastLayer.setUrl(forecastData.toAbsolute(entry.tiles)); return; } const grid = forecastData.normalizeGrid(await forecastData.fetchJson(entry.vector, { signal: opts.signal })); ensureLatestSurfaceRequest(opts.requestId, opts.signal); applyGridFocus(grid); if (particles && typeof particles.setField === "function") { particles.setField(grid); if (state.latestParticleBounds && typeof particles.setBounds === "function") { particles.setBounds(state.latestParticleBounds); } updateParticleOptions(); } if (!gridFallbackLayer) { gridFallbackLayer = L.windGridLayer(grid, { pane: "windColor", fillOpacity: parseInt(dom.opacityRange.value, 10) / 100, blurPx: 0.8, featherPx: 10, }).addTo(map); } else { gridFallbackLayer.setData(grid); if (!map.hasLayer(gridFallbackLayer)) { gridFallbackLayer.addTo(map); } } if (map.hasLayer(forecastLayer)) { map.removeLayer(forecastLayer); } } dom.modelSelect.addEventListener("change", function () { const url = buildDemoUrl(); url.searchParams.set("model", dom.modelSelect.value); window.location.href = url.toString(); }); dom.opacityRange.addEventListener("input", updateOpacity); dom.threadsToggle.addEventListener("change", updateParticleOptions); dom.threadsDensityRange.addEventListener("input", updateParticleOptions); dom.threadsLengthRange.addEventListener("input", updateParticleOptions); dom.threadsSpeedRange.addEventListener("input", updateParticleOptions); dom.threadsWidthRange.addEventListener("input", updateParticleOptions); dom.threadsFadeRange.addEventListener("input", updateParticleOptions); dom.reloadButton.addEventListener("click", function () { loadForecast().catch(function () { dom.timelineLabel.textContent = "Forecast konnte nicht geladen werden"; dom.timelineMeta.textContent = "Bitte API und Run-Daten pruefen."; }); }); if (dom.basemapSelect) { dom.basemapSelect.addEventListener("change", function () { setBasemap(dom.basemapSelect.value); }); } if (dom.resetViewButton) { dom.resetViewButton.addEventListener("click", function () { if (!state.latestGridBounds) return; state.hasPinnedInitialView = false; state.hasFocusedDataBounds = false; map.fitBounds(state.latestGridBounds.pad(0.08), { animate: true, padding: [24, 24], }); }); } if (dom.openMainMapButton) { dom.openMainMapButton.addEventListener("click", function () { window.open(buildMainMapUrl(), "_blank", "noopener"); }); } map.on("click", function (event) { if (pointInteraction) { pointInteraction.showPreview(event.latlng); } }); map.on("moveend zoomend", function (event) { persistDemoView(); const activeEntry = state.entries[state.activeIndex]; if (event && event.type === "zoomend" && activeEntry && activeEntry.tiles && entryRegion(activeEntry) === WORLD_REGION) { state.activeSurfaceKey = ""; requestSurfaceSync(activeEntry).catch(function (error) { if (!error || error.name !== "AbortError") { console.warn("[forecast-demo] world surface refresh failed", error); } }); } }); forecastData = window.iwindForecastDataSource.create({ apiOrigin: apiOrigin, publishedRegion: PUBLISHED_REGION, defaultRegion: PUBLISHED_REGION, lookbackHours: 3, getModel: function () { return state.model; }, getManifest: function () { return state.manifest; }, parseForecastDate: parseForecastDate, }); panelShell = window.iwindForecastPanelShell.create({ dom: dom, closePointDetailsPanel: function () { if (pointInteraction) { pointInteraction.closeDetailsPanel(); } }, clearPointMarker: function () { if (pointInteraction) { pointInteraction.clearMarker(); } }, }); panelShell.setup(); pointInteraction = window.iwindForecastPointInteraction.create({ leaflet: L, map: map, dom: dom, getEntries: function () { return state.entries; }, getActiveIndex: function () { return state.activeIndex; }, getModel: function () { return state.model; }, getRegion: function () { return state.region || PUBLISHED_REGION; }, getRun: function () { return state.manifest && state.manifest.run ? state.manifest.run : ""; }, setPointPanelOpen: setPointPanelOpen, fetchJson: forecastData.fetchJson, loadPublishedGrid: forecastData.loadPublishedGrid, parseForecastDate: parseForecastDate, timelineDayKey: timelineDayKey, formatTimelineLabelCompact: formatTimelineLabelCompact, formatForecastModelLabel: formatForecastModelLabel, }); timelineController = window.iwindForecastTimeline.create({ dom: dom, getEntries: function () { return state.entries; }, getActiveIndex: function () { return state.activeIndex; }, getModel: function () { return state.model; }, getRegion: function () { return (state.manifest && state.manifest.region) || PUBLISHED_REGION; }, setActiveIndex: setActiveIndex, parseForecastDate: parseForecastDate, formatDateLabel: formatDateLabel, formatTimelineLabelCompact: formatTimelineLabelCompact, timelineDayKey: timelineDayKey, formatTimelineDayCompact: formatTimelineDayCompact, escapeHtml: escapeHtml, }); timelineController.setupPanel(panelShell.getTimelinePanel()); updateOpacity(); updateParticlesUi(); updateParticleOptions(); setBasemap(state.basemapKey, false); persistDemoView(); loadForecast().catch(function () { dom.timelineLabel.textContent = "Forecast konnte nicht geladen werden"; dom.timelineMeta.textContent = "Bitte Manifest, Times und Tile-Auslieferung pruefen."; }); })(); (function () { const DEFAULT_MODEL = "gfs-025"; const PUBLISHED_REGION = String(window.IWIND_FORECAST_DEFAULT_REGION || "europe"); const WORLD_REGION = "world"; const WORLD_TILE_MIN_ZOOM = 0; const WORLD_TILE_MAX_ZOOM = 8; const WORLD_TILE_DISPLAY_MAX_ZOOM = 12; const FORECAST_REGIONS = Array.isArray(window.IWIND_FORECAST_REGIONS) ? window.IWIND_FORECAST_REGIONS : []; const FORECAST_MODEL_LABELS = { "gfs-025": "GFS0.25°", "icon-d2": "ICON-D2", "icon-eu": "ICON-EU", "ecmwf": "ECMWF", }; const qs = new URLSearchParams(window.location.search); const REQUESTED_REGION = String(qs.get("region") || window.IWIND_FORECAST_DEMO_DEFAULT_REGION || "").trim(); const apiOrigin = window.location.hostname === "www.iwind.at" || window.location.hostname === "iwind.at" ? "https://weather.iwind.at" : ""; const dom = { modelSelect: document.getElementById("modelSelect"), basemapSelect: document.getElementById("basemapSelect"), opacityRange: document.getElementById("opacityRange"), opacityValue: document.getElementById("opacityValue"), threadsToggle: document.getElementById("threadsToggle"), threadsDensityRange: document.getElementById("threadsDensityRange"), threadsDensityValue: document.getElementById("threadsDensityValue"), threadsLengthRange: document.getElementById("threadsLengthRange"), threadsLengthValue: document.getElementById("threadsLengthValue"), threadsSpeedRange: document.getElementById("threadsSpeedRange"), threadsSpeedValue: document.getElementById("threadsSpeedValue"), threadsWidthRange: document.getElementById("threadsWidthRange"), threadsWidthValue: document.getElementById("threadsWidthValue"), threadsFadeRange: document.getElementById("threadsFadeRange"), threadsFadeValue: document.getElementById("threadsFadeValue"), reloadButton: document.getElementById("reloadButton"), followPointButton: document.getElementById("followPointButton"), runValue: document.getElementById("runValue"), validValue: document.getElementById("validValue"), timelineLabel: document.getElementById("timelineLabel"), timelineMeta: document.getElementById("timelineMeta"), prevButton: document.getElementById("prevButton"), playButton: document.getElementById("playButton"), nextButton: document.getElementById("nextButton"), timeRange: document.getElementById("timeRange"), timelineTicks: document.getElementById("timelineTicks"), pointPanel: document.getElementById("pointPanel"), pointTitle: document.getElementById("pointTitle"), pointSubtitle: document.getElementById("pointSubtitle"), pointCurrent: document.getElementById("pointCurrent"), pointSeries: document.getElementById("pointSeries"), resetViewButton: document.getElementById("resetViewButton"), openMainMapButton: document.getElementById("openMainMapButton"), viewStateValue: document.getElementById("viewStateValue"), }; const DEMO_VIEW_STORAGE_KEY = "iwind:forecast-demo:view:v1"; const jawgToken = "jgKifz7zUmdACbcf8eWvpk4jwVNtCykl7prTVhb7NKPMOmqitsVSEVp1Oc1mhGk6"; const jawgAttribution = '© JawgMaps | © OpenStreetMap'; const jawgBaseOptions = { attribution: jawgAttribution, maxZoom: 22, updateWhenZooming: false, updateWhenIdle: true, keepBuffer: 1, crossOrigin: true, }; const state = { model: qs.get("model") || DEFAULT_MODEL, manifest: null, entries: [], activeIndex: 0, region: REQUESTED_REGION || PUBLISHED_REGION, basemapKey: "carto-light", hasFocusedDataBounds: false, hasPinnedInitialView: false, latestGridBounds: null, latestParticleBounds: null, activeSurfaceKey: "", surfaceAbortController: null, surfaceRequestId: 0, }; let timelineController = null; let panelShell = null; let pointInteraction = null; let forecastData = null; function parseFinite(value, fallback) { const num = Number(value); return Number.isFinite(num) ? num : fallback; } function setPointPanelOpen(open) { if (panelShell) { panelShell.setPointPanelOpen(open); } } function readStoredViewState() { try { const raw = window.localStorage.getItem(DEMO_VIEW_STORAGE_KEY); if (!raw) return null; const parsed = JSON.parse(raw); return parsed && typeof parsed === "object" ? parsed : null; } catch (_) { return null; } } const storedView = readStoredViewState(); const initialLat = parseFinite(qs.get("lat"), parseFinite(storedView && storedView.lat, 47.5)); const initialLng = parseFinite(qs.get("lng"), parseFinite(storedView && storedView.lng, 14.0)); const initialZoom = parseFinite(qs.get("z"), parseFinite(storedView && storedView.z, 6)); const requestedBaseKey = String(qs.get("base") || (storedView && storedView.base) || "carto-light").trim(); state.basemapKey = requestedBaseKey || "carto-light"; state.hasPinnedInitialView = qs.has("lat") || qs.has("lng") || qs.has("z") || !!storedView; dom.modelSelect.value = state.model; const standard = L.tileLayer("https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png", { attribution: '© CartoDB', subdomains: "abcd", maxZoom: 19, updateWhenIdle: true, updateWhenZooming: false, keepBuffer: 2, crossOrigin: true, }); const satellite = L.tileLayer("https://{s}.google.com/vt/lyrs=s&x={x}&y={y}&z={z}", { subdomains: ["mt0", "mt1", "mt2", "mt3"], attribution: "© Google Maps", updateWhenZooming: false, updateWhenIdle: true, keepBuffer: 1, }); const openTopoMap = L.tileLayer("https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png", { maxZoom: 17, attribution: "© OpenTopoMap (CC-BY-SA), © OpenStreetMap contributors", updateWhenZooming: false, updateWhenIdle: true, keepBuffer: 1, }); const esriTopo = L.tileLayer("https://server.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/{z}/{y}/{x}", { attribution: "Tiles © Esri", maxZoom: 17, updateWhenZooming: false, updateWhenIdle: true, keepBuffer: 1, }); const openMapTiles = L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", { attribution: "© OpenStreetMap contributors", updateWhenZooming: false, updateWhenIdle: true, keepBuffer: 1, }); const jawgTerrain = L.tileLayer(`https://tile.jawg.io/jawg-terrain/{z}/{x}/{y}{r}.png?access-token=${jawgToken}`, jawgBaseOptions); const esriWorldImagery = L.layerGroup([ L.tileLayer("https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}", { attribution: "© Esri World Imagery", maxZoom: 18, }), L.tileLayer("https://server.arcgisonline.com/ArcGIS/rest/services/Reference/World_Boundaries_and_Places/MapServer/tile/{z}/{y}/{x}", { attribution: "© Esri Labels", maxZoom: 18, }), ]); const baseLayersByKey = { "carto-light": standard, satellite: satellite, "open-topo": openTopoMap, "esri-topo": esriTopo, openmaptiles: openMapTiles, "jawg-terrain": jawgTerrain, "esri-3d": esriWorldImagery, }; const initialBaseLayer = baseLayersByKey[state.basemapKey] || standard; const map = L.map("map", { zoomControl: true, preferCanvas: true, zoomSnap: 1, zoomDelta: 1, worldCopyJump: false, layers: [initialBaseLayer], }).setView([initialLat, initialLng], initialZoom); if (dom.basemapSelect) { dom.basemapSelect.value = baseLayersByKey[state.basemapKey] ? state.basemapKey : "carto-light"; } L.control.scale({ imperial: false }).addTo(map); const windColorPane = map.createPane("windColor"); windColorPane.style.zIndex = "450"; windColorPane.style.pointerEvents = "none"; windColorPane.style.mixBlendMode = "normal"; windColorPane.style.opacity = "1"; let forecastLayer = L.tileLayer("", { opacity: parseInt(dom.opacityRange.value, 10) / 100, className: "forecast-tiles", pane: "windColor", crossOrigin: true, updateWhenIdle: true, keepBuffer: 2, maxZoom: 19, }); let gridFallbackLayer = null; const particles = L.windParticlesLayer ? L.windParticlesLayer(null, { active: true, particleCount: Number(dom.threadsDensityRange.value), speedScale: Number(dom.threadsLengthRange.value) / 100, velocityScale: Number(dom.threadsSpeedRange.value) / 100, fadeAlpha: Number(dom.threadsFadeRange.value) / 100, lineWidth: Number(dom.threadsWidthRange.value), minSpeed: 0.2, useColors: true, color: "rgba(22, 87, 128, 0.3)", }) : null; if (particles) { particles.addTo(map); } function parseForecastDate(value) { if (!value) return null; if (/^\d{8}T\d{6}Z$/.test(value)) { const iso = value.slice(0, 4) + "-" + value.slice(4, 6) + "-" + value.slice(6, 8) + "T" + value.slice(9, 11) + ":" + value.slice(11, 13) + ":" + value.slice(13, 15) + "Z"; return new Date(iso); } const date = new Date(value); return Number.isNaN(date.getTime()) ? null : date; } function formatDateLabel(value) { const date = parseForecastDate(value); if (!date) return "-"; return date.toLocaleString(undefined, { weekday: "short", day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit", }); } function formatTimelineHour(value) { const date = parseForecastDate(value); if (!date) return "-"; return date.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit", }); } function timelineDayKey(value) { const date = parseForecastDate(value); if (!date) return "unknown"; return date.getFullYear() + "-" + String(date.getMonth() + 1).padStart(2, "0") + "-" + String(date.getDate()).padStart(2, "0"); } function formatTimelineDayCompact(value) { const date = parseForecastDate(value); if (!date) return "-"; const weekday = date.toLocaleDateString(undefined, { weekday: "long" }); const day = String(date.getDate()).padStart(2, "0"); const month = String(date.getMonth() + 1).padStart(2, "0"); const year = String(date.getFullYear()).slice(-2); return weekday + ", " + day + "." + month + "." + year; } function formatTimelineLabelCompact(value) { const date = parseForecastDate(value); if (!date) return "-"; const weekday = date.toLocaleDateString(undefined, { weekday: "short" }).replace(/\.$/, "").toUpperCase(); return weekday + " " + date.getDate() + "." + (date.getMonth() + 1) + ". " + formatTimelineHour(value); } function formatForecastModelLabel(modelKey) { const key = String(modelKey || "").trim().toLowerCase(); if (FORECAST_MODEL_LABELS[key]) return FORECAST_MODEL_LABELS[key]; return key ? key.replace(/-/g, " ").replace(/\b\w/g, function (char) { return char.toUpperCase(); }) : "Forecast"; } function activeBasemapKey() { return baseLayersByKey[state.basemapKey] ? state.basemapKey : "carto-light"; } function buildDemoUrl() { const url = new URL(window.location.pathname, window.location.origin); const center = map.getCenter(); url.searchParams.set("model", state.model); url.searchParams.set("lat", center.lat.toFixed(6)); url.searchParams.set("lng", center.lng.toFixed(6)); url.searchParams.set("z", String(Math.round(map.getZoom()))); url.searchParams.set("base", activeBasemapKey()); if (REQUESTED_REGION || (state.region && state.region !== PUBLISHED_REGION)) { url.searchParams.set("region", REQUESTED_REGION || state.region); } return url; } function buildMainMapUrl() { const url = new URL("/map", window.location.origin); const center = map.getCenter(); url.searchParams.set("lat", center.lat.toFixed(6)); url.searchParams.set("lng", center.lng.toFixed(6)); url.searchParams.set("z", String(Math.round(map.getZoom()))); url.searchParams.set("base", activeBasemapKey()); return url.toString(); } function updateViewStateUi() { if (!dom.viewStateValue) return; const center = map.getCenter(); dom.viewStateValue.textContent = activeBasemapKey() + " | z" + Math.round(map.getZoom()) + " | " + center.lat.toFixed(2) + ", " + center.lng.toFixed(2); } function persistDemoView() { const center = map.getCenter(); const payload = { lat: Number(center.lat.toFixed(6)), lng: Number(center.lng.toFixed(6)), z: Math.round(map.getZoom()), base: activeBasemapKey(), }; try { window.localStorage.setItem(DEMO_VIEW_STORAGE_KEY, JSON.stringify(payload)); } catch (_) {} try { window.history.replaceState({}, "", buildDemoUrl().toString()); } catch (_) {} if (dom.openMainMapButton) { dom.openMainMapButton.dataset.targetUrl = buildMainMapUrl(); } updateViewStateUi(); } function setBasemap(key, persist) { const nextKey = baseLayersByKey[key] ? key : "carto-light"; state.basemapKey = nextKey; Object.keys(baseLayersByKey).forEach(function (layerKey) { const layer = baseLayersByKey[layerKey]; if (layerKey === nextKey) { if (!map.hasLayer(layer)) map.addLayer(layer); } else if (map.hasLayer(layer)) { map.removeLayer(layer); } }); if (dom.basemapSelect) { dom.basemapSelect.value = nextKey; } if (persist !== false) { persistDemoView(); } else { updateViewStateUi(); } } function createAbortError() { try { return new DOMException("Surface request superseded", "AbortError"); } catch (_) { const error = new Error("Surface request superseded"); error.name = "AbortError"; return error; } } function entrySurfaceKey(entry) { if (!entry) return ""; if (entry.source === "published-bin") { return String(entry.binary || entry.valid || ""); } return String(entry.tiles || entry.vector || entry.valid || ""); } function ensureLatestSurfaceRequest(requestId, signal) { if ((signal && signal.aborted) || requestId !== state.surfaceRequestId) { throw createAbortError(); } } function setActiveIndex(nextIndex, options) { if (!state.entries.length) return; const opts = options || {}; const size = state.entries.length; const normalized = ((nextIndex % size) + size) % size; const changed = normalized !== state.activeIndex; state.activeIndex = normalized; const entry = state.entries[normalized]; if (timelineController) { timelineController.refreshActive(entry, opts); } if (!changed && opts.fromScroll) return; if (!changed && state.activeSurfaceKey === entrySurfaceKey(entry)) return; preloadNextTile(); requestSurfaceSync(entry).catch(function (error) { if (error && error.name === "AbortError") { return; } if (entry && entry.source === "published-bin") { findFirstUsableEntryIndex(normalized + 1).then(function (fallbackIndex) { if (fallbackIndex >= 0 && fallbackIndex !== normalized) { setActiveIndex(fallbackIndex, { center: true, recovered: true }); return; } dom.timelineLabel.textContent = "Forecast konnte nicht geladen werden"; dom.timelineMeta.textContent = "Keine gueltige Forecast-Datei fuer diesen Zeitpunkt gefunden."; }); return; } if (entry && entry.tiles && surfaceZoomAllowed(entry)) { forecastLayer.setUrl(forecastData.toAbsolute(entry.tiles)); } else { clearSurfaceLayer(); } }); if (pointInteraction) { pointInteraction.handleActiveEntryChange(); } } async function requestSurfaceSync(entry) { if (state.surfaceAbortController) { state.surfaceAbortController.abort(); } const controller = new AbortController(); const requestId = state.surfaceRequestId + 1; state.surfaceAbortController = controller; state.surfaceRequestId = requestId; try { await syncSurface(entry, { signal: controller.signal, requestId: requestId, }); ensureLatestSurfaceRequest(requestId, controller.signal); state.activeSurfaceKey = entrySurfaceKey(entry); } finally { if (state.surfaceAbortController === controller) { state.surfaceAbortController = null; } } } function preloadNextTile() { if (state.entries.length < 2) return; const nextEntry = state.entries[(state.activeIndex + 1) % state.entries.length]; if (!nextEntry || nextEntry.source === "published-bin" || !nextEntry.tiles) return; if (!surfaceZoomAllowed(nextEntry)) return; const center = map.getCenter(); const zoom = surfaceRequestZoom(nextEntry); const tilePoint = map.project(center, zoom).divideBy(256).floor(); const url = forecastData.toAbsolute(nextEntry.tiles) .replace("{z}", String(zoom)) .replace("{x}", String(tilePoint.x)) .replace("{y}", String(tilePoint.y)); const image = new Image(); image.crossOrigin = "anonymous"; image.src = url; } function updateOpacity() { const value = parseInt(dom.opacityRange.value, 10); dom.opacityValue.textContent = value + "%"; forecastLayer.setOpacity(value / 100); if (gridFallbackLayer) { if (typeof gridFallbackLayer.setOpacity === "function") { gridFallbackLayer.setOpacity(value / 100); } else { gridFallbackLayer.options.fillOpacity = value / 100; if (typeof gridFallbackLayer._draw === "function") { gridFallbackLayer._draw(); } } } } function updateParticlesUi() { dom.threadsDensityValue.textContent = dom.threadsDensityRange.value; dom.threadsLengthValue.textContent = dom.threadsLengthRange.value; dom.threadsSpeedValue.textContent = dom.threadsSpeedRange.value + "%"; dom.threadsWidthValue.textContent = Number(dom.threadsWidthRange.value).toFixed(1) + " px"; dom.threadsFadeValue.textContent = dom.threadsFadeRange.value + "%"; } function updateParticleOptions() { updateParticlesUi(); if (!particles) return; particles.setOptions({ active: !!dom.threadsToggle.checked, particleCount: Number(dom.threadsDensityRange.value), speedScale: Number(dom.threadsLengthRange.value) / 100, velocityScale: Number(dom.threadsSpeedRange.value) / 100, fadeAlpha: Number(dom.threadsFadeRange.value) / 100, lineWidth: Number(dom.threadsWidthRange.value), }); } function escapeHtml(value) { return String(value) .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } function regionArea(region) { if (!region) return Number.POSITIVE_INFINITY; return Math.max(0, Number(region.east) - Number(region.west)) * Math.max(0, Number(region.north) - Number(region.south)); } function normalizeLongitude(value) { let lng = Number(value); if (!Number.isFinite(lng)) return lng; while (lng > 180) lng -= 360; while (lng < -180) lng += 360; return lng; } function regionContainsLatLng(region, latlng) { if (!region || !latlng) return false; const lat = Number(latlng.lat); const lng = normalizeLongitude(latlng.lng); if (region.key === "mediterranean" && lat > 45.5) { return false; } return lat >= Number(region.south) && lat <= Number(region.north) && lng >= Number(region.west) && lng <= Number(region.east); } function resolvePreferredRegion(latlng) { if (REQUESTED_REGION) { return REQUESTED_REGION; } const matches = FORECAST_REGIONS .filter(function (region) { return regionContainsLatLng(region, latlng); }) .sort(function (a, b) { return regionArea(a) - regionArea(b); }); if (matches.length) { return matches[0].key || PUBLISHED_REGION; } return PUBLISHED_REGION; } function regionQuery(regionKey) { const key = String(regionKey || "").trim(); return key ? "?region=" + encodeURIComponent(key) : ""; } function buildRegionFallbackChain(preferredRegion, defaultRegion) { if (String(preferredRegion || "").trim() === WORLD_REGION) { return [WORLD_REGION]; } return [preferredRegion, WORLD_REGION, defaultRegion].filter(function (regionKey, index, list) { const key = String(regionKey || "").trim(); return key && list.findIndex(function (candidate) { return String(candidate || "").trim() === key; }) === index; }); } function isWorldMode() { return REQUESTED_REGION === WORLD_REGION || state.region === WORLD_REGION; } function isWorldTileEntry(entry) { if (!entry || !entry.tiles) return false; const region = String(entry.region || "").trim(); const tiles = String(entry.tiles || ""); return region === WORLD_REGION || tiles.indexOf("/world/") !== -1; } function configureForecastLayerZoom(entry) { if (isWorldTileEntry(entry)) { forecastLayer.options.maxNativeZoom = WORLD_TILE_MAX_ZOOM; forecastLayer.options.maxZoom = WORLD_TILE_DISPLAY_MAX_ZOOM; return; } delete forecastLayer.options.maxNativeZoom; forecastLayer.options.maxZoom = 19; } function surfaceRequestZoom(entry) { const currentZoom = Math.round(map.getZoom()); if (isWorldTileEntry(entry)) { return Math.min(currentZoom, WORLD_TILE_MAX_ZOOM); } const entryMaxZoom = Number(entry && entry.maxZoom); return Number.isFinite(entryMaxZoom) ? Math.min(currentZoom, entryMaxZoom) : currentZoom; } function entriesForRegion(entries, regionKey) { const list = Array.isArray(entries) ? entries : []; if (String(regionKey || "").trim() !== WORLD_REGION) { return list; } return list.filter(isWorldTileEntry); } async function findFirstUsableEntryIndex(startIndex) { if (!state.entries.length) return -1; for (let offset = 0; offset < state.entries.length; offset += 1) { const index = (startIndex + offset) % state.entries.length; const entry = state.entries[index]; if (!entry) continue; if (isWorldMode()) { if (isWorldTileEntry(entry)) { return index; } continue; } if (entry.source !== "published-bin" || !entry.binary) { return index; } if (await forecastData.publishedBinaryExists(entry.binary)) { return index; } } return -1; } async function loadForecast() { if (timelineController) { timelineController.stop(); } let manifest; let entries; const preferredRegion = resolvePreferredRegion(map.getCenter()); const preferApiTimesOnly = preferredRegion === WORLD_REGION; state.region = preferredRegion; const defaultRegion = PUBLISHED_REGION; const fallbackRegions = buildRegionFallbackChain(preferredRegion, defaultRegion); try { if (preferApiTimesOnly) { throw new Error("world region uses api times"); } let publishedError = null; for (const regionKey of fallbackRegions) { try { const latest = await forecastData.fetchJson( forecastData.publishedLatestUrlForRegion(state.model, regionKey), { cache: "no-store" } ); const publishedManifest = await forecastData.fetchJson(latest.manifest, { cache: "no-store" }); const publishedEntries = entriesForRegion( forecastData.buildEntriesFromPublishedManifest(publishedManifest), regionKey ); if (!publishedEntries.length) { throw new Error("published manifest has no files"); } manifest = publishedManifest; entries = publishedEntries; manifest.run = manifest.run_at || manifest.run || latest.run; manifest.region = manifest.region || latest.region || regionKey || PUBLISHED_REGION; state.region = manifest.region; publishedError = null; break; } catch (error) { publishedError = error; } } if (publishedError) { throw publishedError; } } catch (publishedError) { let fallbackError = publishedError; entries = []; for (const regionKey of fallbackRegions) { try { manifest = await forecastData.fetchJson( "/api/forecast/" + encodeURIComponent(state.model) + "/manifest" + regionQuery(regionKey) ); try { const resolvedRegion = manifest.region || regionKey || defaultRegion; if (resolvedRegion !== WORLD_REGION) { const publishedManifestUrl = forecastData.publishedRunManifestUrl( state.model, manifest.run_key || manifest.run || manifest.run_at || "", resolvedRegion ); if (publishedManifestUrl) { const publishedManifest = await forecastData.fetchJson(publishedManifestUrl, { cache: "no-store" }); const publishedEntries = entriesForRegion( forecastData.buildEntriesFromPublishedManifest(publishedManifest), resolvedRegion ); if (publishedEntries.length) { manifest = publishedManifest; manifest.run = manifest.run_key || manifest.run || manifest.run_at; manifest.region = manifest.region || resolvedRegion || PUBLISHED_REGION; entries = publishedEntries; break; } } } } catch (_) { } const times = await forecastData.fetchJson( "/api/forecast/" + encodeURIComponent(state.model) + "/times" + regionQuery(regionKey) ); entries = entriesForRegion(Array.isArray(times.valid) ? times.valid : [], regionKey); if (entries.length) { manifest.region = manifest.region || regionKey || defaultRegion; break; } } catch (error) { fallbackError = error; entries = []; } } if (!entries.length) { throw fallbackError; } } state.manifest = manifest; state.region = manifest.region || preferredRegion || PUBLISHED_REGION; state.entries = entries; state.activeIndex = 0; dom.runValue.textContent = formatDateLabel(manifest.run_at || manifest.run || entries[0].valid); dom.timeRange.max = String(entries.length - 1); dom.timeRange.value = "0"; if (timelineController) { timelineController.renderTicks(); } const firstUsableIndex = await findFirstUsableEntryIndex(0); setActiveIndex(firstUsableIndex >= 0 ? firstUsableIndex : 0, { center: true }); } function gridBounds(grid) { const south = Math.min(grid.lat0, grid.lat0 + grid.dlat * (grid.nlat - 1)); const north = Math.max(grid.lat0, grid.lat0 + grid.dlat * (grid.nlat - 1)); const west = Math.min(grid.lon0, grid.lon0 + grid.dlon * (grid.nlon - 1)); const east = Math.max(grid.lon0, grid.lon0 + grid.dlon * (grid.nlon - 1)); return L.latLngBounds([south, west], [north, east]); } function applyGridFocus(grid) { const bounds = gridBounds(grid); state.latestGridBounds = bounds; state.latestParticleBounds = bounds; if (particles && typeof particles.setBounds === "function") { particles.setBounds(bounds); } if (state.hasFocusedDataBounds || state.hasPinnedInitialView) return; state.hasFocusedDataBounds = true; map.fitBounds(bounds.pad(0.08), { animate: false, padding: [24, 24], }); persistDemoView(); } function centerTileUrl(tileTemplate, entry) { const center = map.getCenter(); const zoom = surfaceRequestZoom(entry); const tilePoint = map.project(center, zoom).divideBy(256).floor(); return forecastData.toAbsolute(tileTemplate) .replace("{z}", String(zoom)) .replace("{x}", String(tilePoint.x)) .replace("{y}", String(tilePoint.y)); } function entryRegion(entry) { return String((entry && entry.region) || state.region || "").trim(); } function surfaceZoomAllowed(entry) { if (!entry || !entry.tiles) return true; const minZoom = Number.isFinite(Number(entry.minZoom)) ? Number(entry.minZoom) : null; const configuredMaxZoom = Number.isFinite(Number(entry.maxZoom)) ? Number(entry.maxZoom) : null; const maxZoom = isWorldTileEntry(entry) ? WORLD_TILE_DISPLAY_MAX_ZOOM : configuredMaxZoom; const zoom = map.getZoom(); if (minZoom !== null && zoom < minZoom) return false; if (maxZoom !== null && zoom > maxZoom) return false; return true; } function clearSurfaceLayer() { if (map.hasLayer(forecastLayer)) { map.removeLayer(forecastLayer); } if (gridFallbackLayer && map.hasLayer(gridFallbackLayer)) { map.removeLayer(gridFallbackLayer); } } async function tileAvailable(entry, signal) { if (!entry || !entry.tiles) return false; if (isWorldMode() && !isWorldTileEntry(entry)) return false; if (!surfaceZoomAllowed(entry)) return false; try { const response = await fetch(centerTileUrl(entry.tiles, entry), { method: "HEAD", cache: "no-store", credentials: "same-origin", signal: signal, }); return response.ok; } catch (_) { return false; } } async function syncSurface(entry, runtimeOptions) { const opts = runtimeOptions || {}; if (isWorldMode() && entry && !isWorldTileEntry(entry)) { clearSurfaceLayer(); throw new Error("World forecast entry is not tile-backed"); } if (entry && entry.tiles && !surfaceZoomAllowed(entry)) { clearSurfaceLayer(); if (entryRegion(entry) === WORLD_REGION) { dom.timelineMeta.textContent = "World-Windlayer nativ ab Zoom " + WORLD_TILE_MIN_ZOOM + " bis " + WORLD_TILE_MAX_ZOOM + ", sichtbar bis Zoom " + WORLD_TILE_DISPLAY_MAX_ZOOM + "."; } return; } if (entry && entry.source === "published-bin" && entry.binary) { const binaryExists = await forecastData.publishedBinaryExists(entry.binary, { signal: opts.signal }); ensureLatestSurfaceRequest(opts.requestId, opts.signal); if (!binaryExists) { throw new Error("Published binary missing for " + entry.valid); } const grid = await forecastData.loadPublishedGrid(entry, { signal: opts.signal }); ensureLatestSurfaceRequest(opts.requestId, opts.signal); applyGridFocus(grid); if (particles && typeof particles.setField === "function") { particles.setField(grid); if (state.latestParticleBounds && typeof particles.setBounds === "function") { particles.setBounds(state.latestParticleBounds); } updateParticleOptions(); } if (!gridFallbackLayer) { gridFallbackLayer = L.windGridLayer(grid, { pane: "windColor", fillOpacity: parseInt(dom.opacityRange.value, 10) / 100, blurPx: 0.8, featherPx: 10, }).addTo(map); } else { gridFallbackLayer.setData(grid); if (!map.hasLayer(gridFallbackLayer)) { gridFallbackLayer.addTo(map); } } if (map.hasLayer(forecastLayer)) { map.removeLayer(forecastLayer); } return; } const hasTile = await tileAvailable(entry, opts.signal); ensureLatestSurfaceRequest(opts.requestId, opts.signal); if (hasTile) { configureForecastLayerZoom(entry); if (!map.hasLayer(forecastLayer)) { forecastLayer.addTo(map); } forecastLayer.setUrl(forecastData.toAbsolute(entry.tiles)); if (gridFallbackLayer && map.hasLayer(gridFallbackLayer)) { map.removeLayer(gridFallbackLayer); } return; } if (!entry.vector || typeof L.windGridLayer !== "function") { if (isWorldMode()) { clearSurfaceLayer(); dom.timelineMeta.textContent = "World-Windlayer fuer diesen Zeitpunkt nicht verfuegbar."; return; } configureForecastLayerZoom(entry); if (!map.hasLayer(forecastLayer)) { forecastLayer.addTo(map); } forecastLayer.setUrl(forecastData.toAbsolute(entry.tiles)); return; } const grid = forecastData.normalizeGrid(await forecastData.fetchJson(entry.vector, { signal: opts.signal })); ensureLatestSurfaceRequest(opts.requestId, opts.signal); applyGridFocus(grid); if (particles && typeof particles.setField === "function") { particles.setField(grid); if (state.latestParticleBounds && typeof particles.setBounds === "function") { particles.setBounds(state.latestParticleBounds); } updateParticleOptions(); } if (!gridFallbackLayer) { gridFallbackLayer = L.windGridLayer(grid, { pane: "windColor", fillOpacity: parseInt(dom.opacityRange.value, 10) / 100, blurPx: 0.8, featherPx: 10, }).addTo(map); } else { gridFallbackLayer.setData(grid); if (!map.hasLayer(gridFallbackLayer)) { gridFallbackLayer.addTo(map); } } if (map.hasLayer(forecastLayer)) { map.removeLayer(forecastLayer); } } dom.modelSelect.addEventListener("change", function () { const url = buildDemoUrl(); url.searchParams.set("model", dom.modelSelect.value); window.location.href = url.toString(); }); dom.opacityRange.addEventListener("input", updateOpacity); dom.threadsToggle.addEventListener("change", updateParticleOptions); dom.threadsDensityRange.addEventListener("input", updateParticleOptions); dom.threadsLengthRange.addEventListener("input", updateParticleOptions); dom.threadsSpeedRange.addEventListener("input", updateParticleOptions); dom.threadsWidthRange.addEventListener("input", updateParticleOptions); dom.threadsFadeRange.addEventListener("input", updateParticleOptions); dom.reloadButton.addEventListener("click", function () { loadForecast().catch(function () { dom.timelineLabel.textContent = "Forecast konnte nicht geladen werden"; dom.timelineMeta.textContent = "Bitte API und Run-Daten pruefen."; }); }); if (dom.basemapSelect) { dom.basemapSelect.addEventListener("change", function () { setBasemap(dom.basemapSelect.value); }); } if (dom.resetViewButton) { dom.resetViewButton.addEventListener("click", function () { if (!state.latestGridBounds) return; state.hasPinnedInitialView = false; state.hasFocusedDataBounds = false; map.fitBounds(state.latestGridBounds.pad(0.08), { animate: true, padding: [24, 24], }); }); } if (dom.openMainMapButton) { dom.openMainMapButton.addEventListener("click", function () { window.open(buildMainMapUrl(), "_blank", "noopener"); }); } map.on("click", function (event) { if (pointInteraction) { pointInteraction.showPreview(event.latlng); } }); map.on("moveend zoomend", function (event) { persistDemoView(); const activeEntry = state.entries[state.activeIndex]; if (event && event.type === "zoomend" && activeEntry && activeEntry.tiles && entryRegion(activeEntry) === WORLD_REGION) { state.activeSurfaceKey = ""; requestSurfaceSync(activeEntry).catch(function (error) { if (!error || error.name !== "AbortError") { console.warn("[forecast-demo] world surface refresh failed", error); } }); } }); forecastData = window.iwindForecastDataSource.create({ apiOrigin: apiOrigin, publishedRegion: PUBLISHED_REGION, defaultRegion: PUBLISHED_REGION, lookbackHours: 3, getModel: function () { return state.model; }, getManifest: function () { return state.manifest; }, parseForecastDate: parseForecastDate, }); panelShell = window.iwindForecastPanelShell.create({ dom: dom, closePointDetailsPanel: function () { if (pointInteraction) { pointInteraction.closeDetailsPanel(); } }, clearPointMarker: function () { if (pointInteraction) { pointInteraction.clearMarker(); } }, }); panelShell.setup(); pointInteraction = window.iwindForecastPointInteraction.create({ leaflet: L, map: map, dom: dom, getEntries: function () { return state.entries; }, getActiveIndex: function () { return state.activeIndex; }, getModel: function () { return state.model; }, getRegion: function () { return state.region || PUBLISHED_REGION; }, getRun: function () { return state.manifest && state.manifest.run ? state.manifest.run : ""; }, setPointPanelOpen: setPointPanelOpen, fetchJson: forecastData.fetchJson, loadPublishedGrid: forecastData.loadPublishedGrid, parseForecastDate: parseForecastDate, timelineDayKey: timelineDayKey, formatTimelineLabelCompact: formatTimelineLabelCompact, formatForecastModelLabel: formatForecastModelLabel, }); timelineController = window.iwindForecastTimeline.create({ dom: dom, getEntries: function () { return state.entries; }, getActiveIndex: function () { return state.activeIndex; }, getModel: function () { return state.model; }, getRegion: function () { return (state.manifest && state.manifest.region) || PUBLISHED_REGION; }, setActiveIndex: setActiveIndex, parseForecastDate: parseForecastDate, formatDateLabel: formatDateLabel, formatTimelineLabelCompact: formatTimelineLabelCompact, timelineDayKey: timelineDayKey, formatTimelineDayCompact: formatTimelineDayCompact, escapeHtml: escapeHtml, }); timelineController.setupPanel(panelShell.getTimelinePanel()); updateOpacity(); updateParticlesUi(); updateParticleOptions(); setBasemap(state.basemapKey, false); persistDemoView(); loadForecast().catch(function () { dom.timelineLabel.textContent = "Forecast konnte nicht geladen werden"; dom.timelineMeta.textContent = "Bitte Manifest, Times und Tile-Auslieferung pruefen."; }); })(); Windverlauf: Gothenburg
Datum:
Datum:

Windverlauf: Gothenburg

11. June - 17. June 2026
Spot local time (Europe/Stockholm)
00:00
24:00
00:00 06:00 12:00 18:00 24:00