Hinweis: Leere nach dem Veröffentlichen den Browser-Cache, um die Änderungen sehen zu können.

  • Firefox/Safari: Umschalttaste drücken und gleichzeitig Aktualisieren anklicken oder entweder Strg+F5 oder Strg+R (⌘+R auf dem Mac) drücken
  • Google Chrome: Umschalttaste+Strg+R (⌘+Umschalttaste+R auf dem Mac) drücken
  • Edge: Strg+F5 drücken oder Strg drücken und gleichzeitig Aktualisieren anklicken
/**
 * MediaWiki:Gadget-Wetter.js
 * =====================================================================
 * Wetterwidget für Gemeindeseiten: zeigt aktuelle Messwerte der
 * eingetragenen GeoSphere-Wetterstation sowie eine 3-Tages-Vorhersage
 * (AROME-Modell, 2,5-km-Gitter) für die Koordinaten des Ortes.
 *
 * Datenquelle: GeoSphere Austria Dataset-API (Open Data, CC BY 4.0),
 * kein API-Schlüssel nötig. Doku: https://dataset.api.hub.geosphere.at/v1/docs/
 *
 * EINRICHTUNG (einmalig):
 * 1) Diesen Code nach [[MediaWiki:Gadget-Wetter.js]] kopieren.
 * 2) In [[MediaWiki:Gadgets-definition]] ergänzen:
 *      * Wetter[ResourceLoader|default|hidden]|Wetter.js
 * 3) [[MediaWiki:Gadget-Wetter]] anlegen, Inhalt z. B.:
 *      Wetterwidget mit GeoSphere-Daten.
 * 4) [[Vorlage:Wetterwidget]] mit dem Inhalt der mitgelieferten Datei
 *    Vorlage-Wetterwidget.wikitext anlegen.
 *
 * VERWENDUNG:
 * Auf der Gemeindeseite den {{Wetterwidget|...}}-Baustein einfügen –
 * am einfachsten erzeugt über die Generator-Seite (Formular). Das Gadget
 * findet alle Bausteine der Seite und befüllt sie beim Seitenaufruf.
 *
 * TECHNISCHE HINWEISE:
 * - Pro Widget und Seitenaufruf entstehen zwei API-Anfragen (Messwerte,
 *   Vorhersage); das GeoSphere-Limit von 240 Anfragen pro Stunde gilt je
 *   Besucher und wird damit bei weitem nicht erreicht.
 * - Die Parameternamen der Vorhersage werden mit Ausweichvarianten
 *   angefragt, da der Vorhersage-Endpunkt laut GeoSphere noch als
 *   "prerelease" gilt. Das AROME-Modell rechnet alle 3 Stunden neu und
 *   liefert 60 Stunden Vorhersage; laut GeoSphere wird der Datensatz
 *   Anfang 2027 durch einen Nachfolger (neuer Endpunkt) abgelöst.
 * =====================================================================
 */
( function () {
	'use strict';

	var BASIS = 'https://dataset.api.hub.geosphere.at/v1';
	var ZEITZONE = 'Europe/Vienna';
	var MAX_TAGE = 3;

	/* Parametersätze in absteigender Wunschreihenfolge. Antwortet der
	 * Server mit Status 400 (unbekannter Parameter), wird der nächste,
	 * kleinere Satz versucht. */
	var MESS_SAETZE = [
		[ 'TL', 'RF', 'DD', 'FFAM', 'FFX', 'RR', 'P0', 'SH' ],
		[ 'TL', 'RF', 'DD', 'FFAM', 'RR' ],
		[ 'TL' ]
	];
	var PROGNOSE_SAETZE = [
		[ 't2m', 'rr_acc' ],
		[ 't2m', 'rr' ],
		[ 't2m' ]
	];

	$( function () {
		$( '.wetterwidget' ).each( function () {
			starteWidget( $( this ) );
		} );
	} );

	/* ------------------------------------------------------------------
	 * Ablaufsteuerung je Widget
	 * ------------------------------------------------------------------ */
	function starteWidget( $box ) {
		var cfg = {
			ort: $.trim( $box.attr( 'data-ort' ) || '' ),
			latText: $.trim( $box.attr( 'data-lat' ) || '' ),
			lonText: $.trim( $box.attr( 'data-lon' ) || '' ),
			station: $.trim( $box.attr( 'data-station' ) || '' ),
			stationsname: $.trim( $box.attr( 'data-stationsname' ) || '' ),
			stationshoehe: $.trim( $box.attr( 'data-stationshoehe' ) || '' )
		};
		var latOk = isFinite( parseFloat( cfg.latText ) );
		var lonOk = isFinite( parseFloat( cfg.lonText ) );
		cfg.mitMesswerten = cfg.station !== '';
		cfg.mitVorhersage = latOk && lonOk;

		gestalteBox( $box );
		$box.empty().append(
			titelZeile( cfg ),
			$( '<div>' ).css( kleinGrau() ).text( 'Wetterdaten werden geladen …' )
		);

		if ( !cfg.mitMesswerten && !cfg.mitVorhersage ) {
			zeichneWidget( $box, cfg, {
				messFehler: 'Im Baustein fehlen Stations-ID und Koordinaten – ' +
					'bitte über die Generator-Seite neu erstellen.',
				prognoseFehler: ''
			} );
			return;
		}

		var stand = { mess: null, messFehler: '', prognose: null, prognoseFehler: '' };
		var offen = ( cfg.mitMesswerten ? 1 : 0 ) + ( cfg.mitVorhersage ? 1 : 0 );

		function fertigMelden() {
			offen -= 1;
			if ( offen <= 0 ) {
				zeichneWidget( $box, cfg, stand );
			}
		}

		if ( cfg.mitMesswerten ) {
			ladeMitVarianten( MESS_SAETZE, function ( satz ) {
				return BASIS + '/station/current/tawes-v1-10min' +
					'?parameters=' + satz.join( ',' ) +
					'&station_ids=' + encodeURIComponent( cfg.station );
			}, function ( fehler, antwort, satz ) {
				if ( fehler ) {
					stand.messFehler = fehler;
				} else {
					stand.mess = leseMesswerte( antwort, satz );
					if ( !stand.mess ) {
						stand.messFehler = 'Die Station liefert derzeit keine Werte.';
					}
				}
				fertigMelden();
			} );
		}

		if ( cfg.mitVorhersage ) {
			ladeMitVarianten( PROGNOSE_SAETZE, function ( satz ) {
				return BASIS + '/timeseries/forecast/nwp-v1-1h-2500m' +
					'?parameters=' + satz.join( ',' ) +
					'&lat_lon=' + encodeURIComponent( cfg.latText + ',' + cfg.lonText );
			}, function ( fehler, antwort, satz ) {
				if ( fehler ) {
					stand.prognoseFehler = fehler;
				} else {
					stand.prognose = leseVorhersage( antwort, satz );
					if ( !stand.prognose ) {
						stand.prognoseFehler = 'Die Vorhersagedaten waren unvollständig.';
					}
				}
				fertigMelden();
			} );
		}
	}

	/* Fragt der Reihe nach die Parametersätze an; bei Status 400 wird der
	 * nächste Satz versucht (falls sich Parameternamen der API ändern). */
	function ladeMitVarianten( saetze, baueUrl, fertig ) {
		var i = 0;
		function versuch() {
			$.getJSON( baueUrl( saetze[ i ] ) )
				.done( function ( antwort ) {
					fertig( null, antwort, saetze[ i ] );
				} )
				.fail( function ( x ) {
					if ( x && x.status === 400 && i + 1 < saetze.length ) {
						i += 1;
						versuch();
						return;
					}
					fertig( netzFehlerText( x ), null, null );
				} );
		}
		versuch();
	}

	function netzFehlerText( x ) {
		if ( !x || x.status === 0 ) {
			return 'Der Browser hat die Anfrage blockiert (etwa wegen CORS) ' +
				'oder es besteht keine Internetverbindung.';
		}
		return 'Der GeoSphere-Server antwortete mit Status ' + x.status + '.';
	}

	/* ------------------------------------------------------------------
	 * API-Antworten auswerten
	 * ------------------------------------------------------------------ */

	/* Liefert das Parameterobjekt (mit data/unit) aus einer GeoJSON-
	 * Antwort, tolerant gegenüber Gross-/Kleinschreibung und Struktur. */
	function holeParameter( antwort, name ) {
		var feature = antwort && antwort.features && antwort.features[ 0 ];
		var params = feature && feature.properties && feature.properties.parameters;
		var schluessel, i;
		if ( !params ) {
			return null;
		}
		if ( $.isArray( params ) ) {
			for ( i = 0; i < params.length; i++ ) {
				if ( params[ i ] && String( params[ i ].name ).toLowerCase() ===
						name.toLowerCase() ) {
					return params[ i ];
				}
			}
			return null;
		}
		if ( params[ name ] ) {
			return params[ name ];
		}
		for ( schluessel in params ) {
			if ( schluessel.toLowerCase() === name.toLowerCase() ) {
				return params[ schluessel ];
			}
		}
		return null;
	}

	function holeZeiten( antwort ) {
		var roh = ( antwort && antwort.timestamps ) || [];
		var zeiten = [];
		var i, d;
		for ( i = 0; i < roh.length; i++ ) {
			d = new Date( roh[ i ] );
			zeiten.push( isFinite( d.getTime() ) ? d : null );
		}
		return zeiten;
	}

	function zahlOderNull( wert ) {
		var z = parseFloat( wert );
		return isFinite( z ) ? z : null;
	}

	/* Aktuelle Messwerte: jeweils der letzte Eintrag der Zeitreihe. */
	function leseMesswerte( antwort, satz ) {
		var zeiten = holeZeiten( antwort );
		var idx = zeiten.length - 1;
		var werte = {};
		var gefunden = false;
		var i, p, wert;
		if ( idx < 0 ) {
			return null;
		}
		for ( i = 0; i < satz.length; i++ ) {
			p = holeParameter( antwort, satz[ i ] );
			wert = ( p && p.data ) ? zahlOderNull( p.data[ idx ] ) : null;
			werte[ satz[ i ] ] = wert;
			if ( wert !== null ) {
				gefunden = true;
			}
		}
		return gefunden ? { werte: werte, zeit: zeiten[ idx ] } : null;
	}

	/* Vorhersage: stündliche Werte zu Tagen (Wiener Zeit) bündeln. */
	function leseVorhersage( antwort, satz ) {
		var zeiten = holeZeiten( antwort );
		var temperatur = holeParameter( antwort, 't2m' );
		var tDaten = temperatur && temperatur.data;
		var regenName = $.inArray( 'rr_acc', satz ) !== -1 ? 'rr_acc' :
			( $.inArray( 'rr', satz ) !== -1 ? 'rr' : null );
		var kumuliert = regenName === 'rr_acc';
		var regen = regenName ? holeParameter( antwort, regenName ) : null;
		var rDaten = regen && regen.data;
		var inKelvin = false;
		var proTag = {};
		var reihenfolge = [];
		var ab = Date.now() - 45 * 60000; /* laufende Stunde noch mitnehmen */
		var i, t, r, vorher, schluessel, eintrag, tage, tag;

		if ( !tDaten || !tDaten.length || !zeiten.length ) {
			return null;
		}
		inKelvin = istKelvin( temperatur, tDaten );

		for ( i = 0; i < zeiten.length && i < tDaten.length; i++ ) {
			if ( !zeiten[ i ] ) {
				continue;
			}
			/* Niederschlag der Stunde: bei kumulierten Werten Differenz
			 * zum vorigen Zeitschritt, sonst der Wert selbst. */
			r = null;
			if ( rDaten ) {
				if ( kumuliert ) {
					vorher = i > 0 ? zahlOderNull( rDaten[ i - 1 ] ) : 0;
					r = zahlOderNull( rDaten[ i ] );
					r = ( r === null || vorher === null ) ? null :
						Math.max( 0, r - vorher );
				} else {
					r = zahlOderNull( rDaten[ i ] );
				}
			}
			if ( zeiten[ i ].getTime() < ab ) {
				continue; /* Vergangenheit erst nach der Differenzbildung verwerfen */
			}
			t = zahlOderNull( tDaten[ i ] );
			if ( t !== null && inKelvin ) {
				t -= 273.15;
			}
			schluessel = tagesSchluessel( zeiten[ i ] );
			if ( !proTag[ schluessel ] ) {
				proTag[ schluessel ] = {
					ersteZeit: zeiten[ i ], min: null, max: null,
					regen: null, stunden: 0
				};
				reihenfolge.push( schluessel );
			}
			eintrag = proTag[ schluessel ];
			eintrag.stunden += 1;
			if ( t !== null ) {
				eintrag.min = ( eintrag.min === null ) ? t : Math.min( eintrag.min, t );
				eintrag.max = ( eintrag.max === null ) ? t : Math.max( eintrag.max, t );
			}
			if ( r !== null ) {
				eintrag.regen = ( eintrag.regen === null ) ? r : eintrag.regen + r;
			}
		}

		tage = [];
		for ( i = 0; i < reihenfolge.length && tage.length < MAX_TAGE; i++ ) {
			tag = proTag[ reihenfolge[ i ] ];
			if ( tag.stunden >= 3 && tag.min !== null ) {
				tage.push( {
					name: tagesName( tag.ersteZeit, reihenfolge[ i ] ),
					min: tag.min,
					max: tag.max,
					regen: tag.regen
				} );
			}
		}
		return tage.length ? { tage: tage } : null;
	}

	function istKelvin( parameter, daten ) {
		var einheit = parameter && parameter.unit ? String( parameter.unit ) : '';
		var i, w;
		if ( /^k(elvin)?$/i.test( $.trim( einheit ) ) ) {
			return true;
		}
		if ( /c/i.test( einheit ) ) {
			return false;
		}
		for ( i = 0; i < daten.length; i++ ) {
			w = zahlOderNull( daten[ i ] );
			if ( w !== null ) {
				return w > 150; /* plausible Celsiuswerte liegen weit darunter */
			}
		}
		return false;
	}

	function tagesSchluessel( datum ) {
		try {
			return datum.toLocaleDateString( 'de-AT', { timeZone: ZEITZONE } );
		} catch ( e ) {
			return datum.toDateString();
		}
	}

	function tagesName( datum, schluessel ) {
		var heute = tagesSchluessel( new Date() );
		var morgen = tagesSchluessel( new Date( Date.now() + 86400000 ) );
		if ( schluessel === heute ) {
			return 'Heute';
		}
		if ( schluessel === morgen ) {
			return 'Morgen';
		}
		try {
			return datum.toLocaleDateString( 'de-AT',
				{ weekday: 'long', timeZone: ZEITZONE } );
		} catch ( e ) {
			return schluessel;
		}
	}

	function uhrzeit( datum ) {
		try {
			return datum.toLocaleTimeString( 'de-AT',
				{ hour: '2-digit', minute: '2-digit', timeZone: ZEITZONE } );
		} catch ( e ) {
			return '';
		}
	}

	/* ------------------------------------------------------------------
	 * Darstellung
	 * ------------------------------------------------------------------ */
	function zeichneWidget( $box, cfg, stand ) {
		$box.empty().append( titelZeile( cfg ) );

		/* --- Abschnitt: aktuelle Messwerte --- */
		if ( stand.mess ) {
			$box.append( messwerteBlock( cfg, stand.mess ) );
		} else if ( cfg.mitMesswerten || stand.messFehler ) {
			$box.append( fehlerZeile( 'Aktuelle Messwerte nicht verfügbar: ' +
				stand.messFehler ) );
		}

		/* --- Abschnitt: Vorhersage --- */
		if ( stand.prognose ) {
			$box.append( trennlinie(), vorhersageBlock( stand.prognose ) );
		} else if ( cfg.mitVorhersage ) {
			$box.append( trennlinie(), fehlerZeile( 'Vorhersage nicht verfügbar: ' +
				stand.prognoseFehler ) );
		}

		$box.append(
			$( '<div>' )
				.css( { 'margin-top': '0.6em', 'font-size': '80%', color: '#54595d' } )
				.text( 'Datenquelle: GeoSphere Austria (CC BY 4.0)' +
					( stand.prognose ? ' · Vorhersage: AROME-Modell' : '' ) )
		);
	}

	function messwerteBlock( cfg, mess ) {
		var w = mess.werte;
		var $block = $( '<div>' );
		var zeilen = [];
		var windKmh, boeenKmh, stationsZeile;

		if ( w.TL !== null && w.TL !== undefined ) {
			$block.append(
				$( '<div>' )
					.css( { 'font-size': '2.1em', 'font-weight': 'bold',
						'line-height': '1.15' } )
					.text( formatZahl( w.TL, 1 ) + ' °C' )
			);
		}

		if ( istZahl( w.FFAM ) ) {
			windKmh = Math.round( w.FFAM * 3.6 );
			boeenKmh = istZahl( w.FFX ) ? Math.round( w.FFX * 3.6 ) : null;
			zeilen.push( 'Wind: ' + windKmh + ' km/h' +
				( istZahl( w.DD ) ? ' aus ' + windrichtung( w.DD ) : '' ) +
				( boeenKmh !== null && boeenKmh > windKmh + 5 ?
					', Böen ' + boeenKmh + ' km/h' : '' ) );
		}
		if ( istZahl( w.RF ) ) {
			zeilen.push( 'Luftfeuchtigkeit: ' + Math.round( w.RF ) + ' %' );
		}
		if ( istZahl( w.RR ) && w.RR >= 0.05 ) {
			zeilen.push( 'Niederschlag: ' + formatZahl( w.RR, 1 ) +
				' mm in 10 Minuten' );
		}
		if ( istZahl( w.P0 ) ) {
			zeilen.push( 'Luftdruck: ' + Math.round( w.P0 ) + ' hPa' );
		}
		if ( istZahl( w.SH ) && w.SH >= 1 ) {
			zeilen.push( 'Schneehöhe: ' + Math.round( w.SH ) + ' cm' );
		}
		$.each( zeilen, function ( i, text ) {
			$block.append( $( '<div>' ).css( { 'font-size': '92%',
				margin: '0.15em 0' } ).text( text ) );
		} );

		stationsZeile = 'Station ' + ( cfg.stationsname || 'Nr. ' + cfg.station );
		if ( cfg.stationshoehe ) {
			stationsZeile += ', ' + cfg.stationshoehe + ' m';
		}
		if ( mess.zeit ) {
			stationsZeile = 'Gemessen um ' + uhrzeit( mess.zeit ) + ' Uhr · ' +
				stationsZeile;
		}
		$block.append( $( '<div>' ).css( kleinGrau() ).text( stationsZeile ) );
		return $block;
	}

	function vorhersageBlock( prognose ) {
		var $block = $( '<div>' );
		var $reihe = $( '<div>' ).css( { display: 'flex', 'flex-wrap': 'wrap' } );
		$block.append(
			$( '<div>' )
				.css( { 'font-weight': 'bold', 'font-size': '92%',
					'margin-bottom': '0.3em' } )
				.text( 'Vorhersage (Höchst- / Tiefstwert)' )
		);
		$.each( prognose.tage, function ( i, tag ) {
			var regenText = ( tag.regen === null ) ? '' :
				( tag.regen < 0.05 ? 'trocken' :
					formatZahl( tag.regen, 1 ) + ' mm' );
			var $zelle = $( '<div>' ).css( { flex: '1 1 6em',
				'min-width': '6em', margin: '0.15em 0' } );
			$zelle.append(
				$( '<div>' ).css( { 'font-size': '88%', 'font-weight': 'bold' } )
					.text( tag.name ),
				$( '<div>' ).text( Math.round( tag.max ) + '° / ' +
					Math.round( tag.min ) + '°' )
			);
			if ( regenText ) {
				$zelle.append( $( '<div>' ).css( { 'font-size': '85%',
					color: '#54595d' } ).text( regenText ) );
			}
			$reihe.append( $zelle );
		} );
		$block.append( $reihe );
		return $block;
	}

	function titelZeile( cfg ) {
		return $( '<div>' )
			.css( { 'font-weight': 'bold', 'font-size': '108%',
				'margin-bottom': '0.35em' } )
			.text( 'Wetter in ' + ( cfg.ort || 'der Region' ) );
	}

	function trennlinie() {
		return $( '<div>' ).css( { 'border-top': '1px solid #c8ccd1',
			margin: '0.6em 0' } );
	}

	function fehlerZeile( text ) {
		return $( '<div>' ).css( { 'font-size': '88%', color: '#b32424',
			margin: '0.3em 0' } ).text( text );
	}

	function gestalteBox( $box ) {
		$box.css( {
			border: '1px solid #a2a9b1',
			'border-radius': '2px',
			background: '#f8f9fa',
			padding: '0.8em 1em',
			'max-width': '26em',
			'box-sizing': 'border-box'
		} );
	}

	function kleinGrau() {
		return { 'font-size': '85%', color: '#54595d', 'margin-top': '0.35em' };
	}

	/* ------------------------------------------------------------------
	 * Kleine Helfer
	 * ------------------------------------------------------------------ */
	function istZahl( wert ) {
		return typeof wert === 'number' && isFinite( wert );
	}

	function formatZahl( wert, nachkomma ) {
		return wert.toFixed( nachkomma ).replace( '.', ',' ).replace( '-0,0', '0,0' );
	}

	function windrichtung( grad ) {
		var richtungen = [ 'Nord', 'Nordost', 'Ost', 'Südost',
			'Süd', 'Südwest', 'West', 'Nordwest' ];
		return richtungen[ Math.round( grad / 45 ) % 8 ];
	}
}() );