Keine Bearbeitungszusammenfassung
Keine Bearbeitungszusammenfassung
 
(19 dazwischenliegende Versionen desselben Benutzers werden nicht angezeigt)
Zeile 20: Zeile 20:
  * data-Attribute:
  * data-Attribute:
  *  data-aktiv-ort  ID des hervorgehobenen Ortes (rot, zentriert, Popup auf)
  *  data-aktiv-ort  ID des hervorgehobenen Ortes (rot, zentriert, Popup auf)
  *  data-filter      nur Orte dieser "kategorie" zeigen
  *  data-filter      nur Orte dieser "kategorie" zeigen. Mehrere Kategorien
*                    kommagetrennt moeglich, z.B. "asiatisch,gasthaus,imbiss"
*  data-pin        Ad-hoc-Pin: "lat,lon" zeigt GENAU EINEN Pin an diesen
*                    Koordinaten und KEINE Orte aus der zentralen JSON.
*                    Fuer Veranstaltungsseiten (Eventform) gedacht.
*  data-pin-name    Optional zum Pin: Text im Popup.
  *  data-gemeinden  "ja" => Gemeinde-Umrisse einblenden (Klick => Seite)
  *  data-gemeinden  "ja" => Gemeinde-Umrisse einblenden (Klick => Seite)
*  data-nur-aktiv  "ja" => nur den aktiven Ort zeigen, alle anderen Pins
*                    ausblenden (braucht data-aktiv-ort)
*  data-layer      Start-Ansicht per Menü-Name, z.B. "Zug" oder
*                    "Zug + Luftbild". Unbekannter Name => normaler Default.
  *  data-hoehe      Pixel-Höhe der Karte (optional, sonst CSS-Default)
  *  data-hoehe      Pixel-Höhe der Karte (optional, sonst CSS-Default)
*
* ORTE-FELDER (WikiMap-Orte.json)
* -------------------------------
*  id        Pflicht. Anzeigename im Popup UND Ziel-Wikiseite beim Klick.
*  geo        Koordinaten als String "lat,lon" – direkt aus OSM.org oder
*              OrganicMaps kopierbar, Leerzeichen nach dem Komma erlaubt.
*              Beispiel: "47.7073509,15.9933643"
*  lat, lon  Alternative zu geo: Koordinaten als zwei Zahlenfelder.
*  kategorie  Optional. Für Filter und Farben.
*  seite      Optional. Übersteuert das Klick-Ziel. Wiki-Seitenname ODER
*              externe Adresse (beginnend mit http:// oder https://).
  *
  *
  * ABHÄNGIGKEIT: Leaflet 1.9.x + Leaflet.fullscreen. Dieses Gadget lädt sie
  * ABHÄNGIGKEIT: Leaflet 1.9.x + Leaflet.fullscreen. Dieses Gadget lädt sie
Zeile 37: Zeile 57:
orteSeite: 'MediaWiki:WikiMap-Orte.json',
orteSeite: 'MediaWiki:WikiMap-Orte.json',
gemeindenSeite: 'MediaWiki:WikiMap-Gemeinden.json',
gemeindenSeite: 'MediaWiki:WikiMap-Gemeinden.json',
defaultCenter: [ 47.5, 13.5 ],
defaultCenter: [ 47.6, 16.1 ],
defaultZoom: 7,
defaultZoom: 7,
focusZoom: 14
focusZoom: 14
Zeile 72: Zeile 92:
*
*
* basemap.at: seit 2023 neue Service-URLs unter mapsneu.wien.gv.at.
* basemap.at: seit 2023 neue Service-URLs unter mapsneu.wien.gv.at.
* Die alten maps[1-4].wien.gv.at-Adressen liefern nicht mehr zuverlässig.
* Quelle: https://cdn.basemap.at/basemap.at_URL_Umstellung_2023.pdf
* Quelle: https://cdn.basemap.at/basemap.at_URL_Umstellung_2023.pdf
*
*
Zeile 82: Zeile 101:
'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
{ maxZoom: 19, attribution: '© OpenStreetMap-Mitwirkende' }
{ maxZoom: 19, attribution: '© OpenStreetMap-Mitwirkende' }
);
var osmRelief = L.tileLayer(
'https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png',
{
maxZoom: 17,
attribution: 'Kartendaten: © OpenStreetMap-Mitwirkende, SRTM | ' +
'Darstellung: © OpenTopoMap (CC-BY-SA)'
}
);
);
var tracestrack = L.tileLayer(
var tracestrack = L.tileLayer(
Zeile 108: Zeile 135:
}
}
);
);
var gemischt = L.layerGroup( [ luftbild, overlay ] );
// Zweite Luftbild-Instanz für die Gruppe, damit Layer-Wechsel nicht
// zwischen Gruppe und Solo-Luftbild kollidieren (Race Condition).
var luftbildKopie = L.tileLayer(
'https://mapsneu.wien.gv.at/basemap/bmaporthofoto30cm/' +
'normal/google3857/{z}/{y}/{x}.jpeg',
{
maxZoom: 19,
attribution: 'Datenquelle: <a href="https://basemap.at">basemap.at</a>'
}
);
var gemischt = L.layerGroup( [ luftbildKopie, overlay ] );
 
// OpenRailwayMap ist ein transparentes Gleis-Overlay und braucht eine
// Basiskarte darunter. Eigene OSM-Instanz für die Gruppe (gleiches
// Race-Condition-Muster wie beim Luftbild). Der Tileserver liefert
// 512px-Kacheln, daher tileSize/zoomOffset. Die a/b/c-Subdomains sind
// deprecated, daher URL ohne {s}.
var osmFuerBahn = L.tileLayer(
'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
{ maxZoom: 19, attribution: '© OpenStreetMap-Mitwirkende' }
);
var bahnOverlay = L.tileLayer(
'https://tiles.openrailwaymap.org/standard/{z}/{x}/{y}.png',
{
maxZoom: 19,
tileSize: 512,
zoomOffset: -1,
attribution: 'Overlay: <a href="https://www.openrailwaymap.org/">OpenRailwayMap</a> (CC-BY-SA 2.0)'
}
);
var eisenbahn = L.layerGroup( [ osmFuerBahn, bahnOverlay ] );
 
// Hilfsfunktionen für frische Instanzen: jede LayerGroup braucht
// EIGENE Tile-Layer, sonst kollidieren die Gruppen beim Umschalten
// (Race Condition, siehe luftbildKopie oben).
function neuesLuftbild() {
return L.tileLayer(
'https://mapsneu.wien.gv.at/basemap/bmaporthofoto30cm/' +
'normal/google3857/{z}/{y}/{x}.jpeg',
{
maxZoom: 19,
attribution: 'Datenquelle: <a href="https://basemap.at">basemap.at</a>'
}
);
}
function neuesBmapOverlay() {
return L.tileLayer(
'https://mapsneu.wien.gv.at/basemap/bmapoverlay/' +
'normal/google3857/{z}/{y}/{x}.png',
{
maxZoom: 19,
attribution: 'Datenquelle: <a href="https://basemap.at">basemap.at</a>'
}
);
}
function neuesBahnOverlay() {
return L.tileLayer(
'https://tiles.openrailwaymap.org/standard/{z}/{x}/{y}.png',
{
maxZoom: 19,
tileSize: 512,
zoomOffset: -1,
attribution: 'Overlay: <a href="https://www.openrailwaymap.org/">OpenRailwayMap</a> (CC-BY-SA 2.0)'
}
);
}
function neuesOsm() {
return L.tileLayer(
'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
{ maxZoom: 19, attribution: '© OpenStreetMap-Mitwirkende' }
);
}
function neuesOpenTopo() {
return L.tileLayer(
'https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png',
{
maxZoom: 17,
attribution: 'Kartendaten: © OpenStreetMap-Mitwirkende, SRTM | ' +
'Darstellung: © OpenTopoMap (CC-BY-SA)'
}
);
}
function neuesTracestrack() {
return L.tileLayer(
'https://tile.tracestrack.com/topo__/{z}/{x}/{y}.png' +
'?key=bc0464a46d41b1107569bd81112f05ab',
{
maxZoom: 19,
attribution: '© <a href="https://www.tracestrack.com/">Tracestrack</a>, ' +
'© OpenStreetMap-Mitwirkende'
}
);
}
 
// Zug-Kombis: jeweils Basiskarte(n) unten, Gleise obendrauf.
var zugLuftbildKarte = L.layerGroup(
[ neuesLuftbild(), neuesBmapOverlay(), neuesBahnOverlay() ]
);
var zugLuftbild = L.layerGroup( [ neuesLuftbild(), neuesBahnOverlay() ] );
var zugKarte = eisenbahn; // OSM + Gleise (bereits oben gebaut)
var zugTopo = L.layerGroup( [ neuesOpenTopo(), neuesBahnOverlay() ] );
var zugTopo2 = L.layerGroup( [ neuesTracestrack(), neuesBahnOverlay() ] );
 
// Bus: ÖPNVKarte (memomaps.de) ist eine VOLLSTÄNDIGE Karte mit
// Buslinien/Haltestellen, KEIN transparentes Overlay. Deshalb gibt
// es keine Bus+Luftbild-Kombis. Hinweis: Datenpflege des Projekts
// ist laut OSM-Wiki unregelmäßig.
var bus = L.tileLayer(
'https://tileserver.memomaps.de/tilegen/{z}/{x}/{y}.png',
{
maxZoom: 18,
attribution: 'Karte: <a href="https://memomaps.de/">memomaps.de</a> ' +
'(CC-BY-SA), Daten: © OpenStreetMap-Mitwirkende'
}
);
 
return {
return {
basis: {
basis: {
Zeile 114: Zeile 256:
'Luftbild': luftbild,
'Luftbild': luftbild,
'Karte': osmStandard,
'Karte': osmStandard,
'Topo-Karte': tracestrack
'Topokarte': osmRelief,
'Topokarte2': tracestrack,
'Zug + Luftbild + Karte': zugLuftbildKarte,
'Zug + Luftbild': zugLuftbild,
'Zug + Karte': zugKarte,
'Zug + Topokarte': zugTopo,
'Zug + Topokarte2': zugTopo2,
'Bus': bus
},
},
standard: gemischt
standard: gemischt
Zeile 156: Zeile 305:
* 5) EINE EINZELNE KARTE BAUEN
* 5) EINE EINZELNE KARTE BAUEN
* ==================================================================== */
* ==================================================================== */
function baueKarte( container, orte, gemeinden ) {
function baueKarte( container, orte, gemeinden, kategorien ) {
var aktivId = container.getAttribute( 'data-aktiv-ort' ) || null;
var aktivId = container.getAttribute( 'data-aktiv-ort' ) || null;
var filter = container.getAttribute( 'data-filter' ) || null;
 
// Ad-hoc-Pin-Modus: data-pin="lat,lon" zeigt GENAU EINEN Pin an
// diesen Koordinaten und KEINE Orte aus der zentralen JSON.
// Gedacht z.B. für Veranstaltungsseiten (Eventform), deren Ort
// nicht in WikiMap-Orte.json gepflegt wird.
// Optional: data-pin-name für den Popup-Text.
var pinAttr = container.getAttribute( 'data-pin' ) || '';
var pinKoord = null;
if ( pinAttr ) {
var pinTeile = pinAttr.split( ',' );
if ( pinTeile.length === 2 ) {
var pLat = parseFloat( pinTeile[ 0 ] );
var pLon = parseFloat( pinTeile[ 1 ] );
if ( !isNaN( pLat ) && !isNaN( pLon ) ) {
pinKoord = [ pLat, pLon ];
}
}
}
var pinName = container.getAttribute( 'data-pin-name' ) || '';
 
// Nur den aktiven Ort zeigen (alle anderen Pins ausblenden).
var nurAktiv = container.getAttribute( 'data-nur-aktiv' ) === 'ja';
 
// Start-Layer per Menü-Name wählen, z.B. data-layer="Eisenbahnkarte".
// Unbekannter Name => normaler Default.
var layerWunsch = container.getAttribute( 'data-layer' ) || '';
 
// Filter: eine oder mehrere Kategorien, kommagetrennt.
// "asiatisch,gasthaus" => nur Orte, deren kategorie EINE davon ist.
// Leer/nicht gesetzt => alle Orte.
var filterAttr = container.getAttribute( 'data-filter' ) || '';
var filterListe = filterAttr
.split( ',' )
.map( function ( s ) { return s.trim(); } )
.filter( function ( s ) { return s.length > 0; } );
 
var hoehe = container.getAttribute( 'data-hoehe' );
var hoehe = container.getAttribute( 'data-hoehe' );
if ( hoehe ) {
if ( hoehe ) {
Zeile 167: Zeile 351:


var layer = baueLayer();
var layer = baueLayer();
// Start-Layer: per data-layer gewünschter Menü-Name, sonst Default.
var startLayer = ( layerWunsch && layer.basis[ layerWunsch ] ) ?
layer.basis[ layerWunsch ] : layer.standard;
var map = L.map( container, {
var map = L.map( container, {
center: CONFIG.defaultCenter,
center: CONFIG.defaultCenter,
zoom: CONFIG.defaultZoom,
zoom: CONFIG.defaultZoom,
layers: [ layer.standard ],
layers: [ startLayer ],
fullscreenControl: hatFullscreen
fullscreenControl: hatFullscreen
} );
} );
Zeile 204: Zeile 391:
var aktiverMarker = null;
var aktiverMarker = null;
var sichtbare = [];
var sichtbare = [];
// Ad-hoc-Pin-Modus: nur dieser eine Pin, keine Orte aus der JSON.
if ( pinKoord ) {
var pinMarker = L.marker( pinKoord, {
icon: icon( true ),
zIndexOffset: 1000
} );
if ( pinName ) {
pinMarker.bindPopup( '<b>' + mw.html.escape( pinName ) + '</b>' );
}
pinMarker.addTo( map );
map.setView( pinKoord, CONFIG.focusZoom );
if ( pinName ) {
pinMarker.openPopup();
}
map.on( 'resize fullscreenchange', function () {
setTimeout( function () {
map.invalidateSize();
}, 100 );
} );
return map;
}
orte.forEach( function ( ort ) {
orte.forEach( function ( ort ) {
if ( filter && ort.kategorie !== filter ) {
// Nur-aktiv-Modus: alles außer dem aktiven Ort überspringen.
if ( nurAktiv && ( !aktivId || ort.id !== aktivId ) ) {
return;
return;
}
}
if ( typeof ort.lat !== 'number' || typeof ort.lon !== 'number' ) {
// Filter: wenn eine Filterliste gesetzt ist, muss die Kategorie
// des Ortes in der Liste vorkommen. Sonst wird der Ort ausgelassen.
if ( filterListe.length && filterListe.indexOf( ort.kategorie ) === -1 ) {
return;
}
 
// Koordinaten: bevorzugt aus dem geo-Feld (String "lat,lon",
// direkt aus OSM/OrganicMaps kopierbar, Leerzeichen egal),
// sonst aus den klassischen lat/lon-Zahlenfeldern.
var lat = ort.lat;
var lon = ort.lon;
if ( typeof ort.geo === 'string' ) {
var teile = ort.geo.split( ',' );
if ( teile.length === 2 ) {
lat = parseFloat( teile[ 0 ] );
lon = parseFloat( teile[ 1 ] );
}
}
if ( typeof lat !== 'number' || typeof lon !== 'number' ||
isNaN( lat ) || isNaN( lon ) ) {
return;
return;
}
}
var istAktiv = aktivId && ort.id === aktivId;
var istAktiv = aktivId && ort.id === aktivId;
var m = L.marker( [ ort.lat, ort.lon ], {
// id ist Anzeigename und Ziel. seite (optional) übersteuert das
// Ziel; beginnt sie mit http:// oder https://, wird direkt auf
// die externe Adresse verlinkt statt auf eine Wiki-Seite.
var ziel = ort.seite || ort.id || '';
var istExtern = /^https?:\/\//i.test( ziel );
var link = istExtern ? ziel : mw.util.getUrl( ziel );
var m = L.marker( [ lat, lon ], {
icon: icon( istAktiv ),
icon: icon( istAktiv ),
zIndexOffset: istAktiv ? 1000 : 0
zIndexOffset: istAktiv ? 1000 : 0
} );
} );
var link = mw.util.getUrl( ort.seite || ort.name );
 
// Kategorie-Farbe (nur für nicht-aktive Pins; aktiver bleibt rot).
// Wird gesetzt, sobald das Marker-DOM-Element existiert.
var farbe = kategorien && ort.kategorie ? kategorien[ ort.kategorie ] : null;
if ( farbe && !istAktiv ) {
m.on( 'add', function () {
if ( this._icon ) {
this._icon.style.background = farbe;
}
} );
}
 
m.bindPopup(
m.bindPopup(
'<b>' + mw.html.escape( ort.name || '' ) + '</b><br>' +
'<b>' + mw.html.escape( ort.id || '' ) + '</b><br>' +
'<a href="' + link + '">Zur Seite →</a>'
'<a href="' + link + '"' +
( istExtern ? ' target="_blank" rel="noopener"' : '' ) +
'>Zur Seite →</a>'
);
);
m.addTo( map );
m.addTo( map );
sichtbare.push( [ ort.lat, ort.lon ] );
sichtbare.push( [ lat, lon ] );
if ( istAktiv ) {
if ( istAktiv ) {
aktiverMarker = m;
aktiverMarker = m;
Zeile 267: Zeile 517:
$.when.apply( $, aufgaben ).then( function ( orteData, gemeinden ) {
$.when.apply( $, aufgaben ).then( function ( orteData, gemeinden ) {
var orte = ( orteData && orteData.orte ) || [];
var orte = ( orteData && orteData.orte ) || [];
// Optionaler Farb-Block: Kategorie -> Farbe. Fehlt er, bleiben
// die Pins in der Standardfarbe.
var kategorien = ( orteData && orteData.kategorien ) || {};
Array.prototype.forEach.call( container, function ( c ) {
Array.prototype.forEach.call( container, function ( c ) {
try {
try {
baueKarte( c, orte, gemeinden );
baueKarte( c, orte, gemeinden, kategorien );
} catch ( e ) {
} catch ( e ) {
c.textContent = 'Karte konnte nicht geladen werden.';
c.textContent = 'Karte konnte nicht geladen werden.';

Aktuelle Version vom 3. Juli 2026, 22:07 Uhr

/**
 * Gadget-WikiMap.js
 * ============================================================================
 * Zentrale Leaflet-Karten-Engine für das Wiki.
 *
 * KONZEPT
 * -------
 * EINE zentrale Datenquelle (MediaWiki:WikiMap-Orte.json) hält ALLE Orte.
 * Jede Wiki-Seite bindet nur einen leeren <div class="wikimap"> ein und sagt
 * über data-Attribute, welcher Ort gerade "aktiv" ist. Dieses Gadget zeichnet
 * dann die Karte, alle Pins, hebt den aktiven Pin hervor und zentriert ihn.
 *
 * Verschiebst du einen Ort, änderst du EINE Zeile in der zentralen JSON –
 * jede Karte im ganzen Wiki ist sofort aktuell.
 *
 * EINBINDUNG AUF EINER SEITE
 * --------------------------
 *   <div class="wikimap" data-aktiv-ort="gaestehaus-mustertal"></div>
 *
 * data-Attribute:
 *   data-aktiv-ort   ID des hervorgehobenen Ortes (rot, zentriert, Popup auf)
 *   data-filter      nur Orte dieser "kategorie" zeigen. Mehrere Kategorien
 *                    kommagetrennt moeglich, z.B. "asiatisch,gasthaus,imbiss"
 *   data-pin         Ad-hoc-Pin: "lat,lon" zeigt GENAU EINEN Pin an diesen
 *                    Koordinaten und KEINE Orte aus der zentralen JSON.
 *                    Fuer Veranstaltungsseiten (Eventform) gedacht.
 *   data-pin-name    Optional zum Pin: Text im Popup.
 *   data-gemeinden   "ja" => Gemeinde-Umrisse einblenden (Klick => Seite)
 *   data-nur-aktiv   "ja" => nur den aktiven Ort zeigen, alle anderen Pins
 *                    ausblenden (braucht data-aktiv-ort)
 *   data-layer       Start-Ansicht per Menü-Name, z.B. "Zug" oder
 *                    "Zug + Luftbild". Unbekannter Name => normaler Default.
 *   data-hoehe       Pixel-Höhe der Karte (optional, sonst CSS-Default)
 *
 * ORTE-FELDER (WikiMap-Orte.json)
 * -------------------------------
 *   id         Pflicht. Anzeigename im Popup UND Ziel-Wikiseite beim Klick.
 *   geo        Koordinaten als String "lat,lon" – direkt aus OSM.org oder
 *              OrganicMaps kopierbar, Leerzeichen nach dem Komma erlaubt.
 *              Beispiel: "47.7073509,15.9933643"
 *   lat, lon   Alternative zu geo: Koordinaten als zwei Zahlenfelder.
 *   kategorie  Optional. Für Filter und Farben.
 *   seite      Optional. Übersteuert das Klick-Ziel. Wiki-Seitenname ODER
 *              externe Adresse (beginnend mit http:// oder https://).
 *
 * ABHÄNGIGKEIT: Leaflet 1.9.x + Leaflet.fullscreen. Dieses Gadget lädt sie
 * bei Bedarf selbst (siehe LEAFLET_QUELLEN unten).
 * ============================================================================
 */
( function () {
	'use strict';

	/* ====================================================================
	 * 1) KONFIGURATION
	 * ==================================================================== */
	var CONFIG = {
		orteSeite: 'MediaWiki:WikiMap-Orte.json',
		gemeindenSeite: 'MediaWiki:WikiMap-Gemeinden.json',
		defaultCenter: [ 47.6, 16.1 ],
		defaultZoom: 7,
		focusZoom: 14
	};

	var LEAFLET_MODUS = 'lokal'; // 'cdn' zum Testen, 'lokal' für Produktion
	var LEAFLET_LOKAL_BASIS = '/resources/lib/leaflet/';

	var LEAFLET_QUELLEN = {
		cdn: {
			css: [
				'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css',
				'https://unpkg.com/leaflet.fullscreen@5.3.1/dist/Control.FullScreen.css'
			],
			js: [
				'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js',
				'https://unpkg.com/leaflet.fullscreen@5.3.1/dist/Control.FullScreen.umd.js'
			]
		},
		lokal: {
			css: [
				LEAFLET_LOKAL_BASIS + 'leaflet.css',
				LEAFLET_LOKAL_BASIS + 'Control.FullScreen.css'
			],
			js: [
				LEAFLET_LOKAL_BASIS + 'leaflet.js',
				LEAFLET_LOKAL_BASIS + 'Control.FullScreen.umd.js'
			]
		}
	};

	/* ====================================================================
	 * 2) HINTERGRUNDKARTEN
	 *
	 * basemap.at: seit 2023 neue Service-URLs unter mapsneu.wien.gv.at.
	 * Quelle: https://cdn.basemap.at/basemap.at_URL_Umstellung_2023.pdf
	 *
	 * Tracestrack: API-Schlüssel auf Referer schwarzatal.org beschränkt.
	 * Quelle: https://console.tracestrack.com/
	 * ==================================================================== */
	function baueLayer() {
		var osmStandard = L.tileLayer(
			'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
			{ maxZoom: 19, attribution: '© OpenStreetMap-Mitwirkende' }
		);
		var osmRelief = L.tileLayer(
			'https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png',
			{
				maxZoom: 17,
				attribution: 'Kartendaten: © OpenStreetMap-Mitwirkende, SRTM | ' +
					'Darstellung: © OpenTopoMap (CC-BY-SA)'
			}
		);
		var tracestrack = L.tileLayer(
			'https://tile.tracestrack.com/topo__/{z}/{x}/{y}.png' +
			'?key=bc0464a46d41b1107569bd81112f05ab',
			{
				maxZoom: 19,
				attribution: '© <a href="https://www.tracestrack.com/">Tracestrack</a>, ' +
					'© OpenStreetMap-Mitwirkende'
			}
		);
		var luftbild = L.tileLayer(
			'https://mapsneu.wien.gv.at/basemap/bmaporthofoto30cm/' +
			'normal/google3857/{z}/{y}/{x}.jpeg',
			{
				maxZoom: 19,
				attribution: 'Datenquelle: <a href="https://basemap.at">basemap.at</a>'
			}
		);
		var overlay = L.tileLayer(
			'https://mapsneu.wien.gv.at/basemap/bmapoverlay/' +
			'normal/google3857/{z}/{y}/{x}.png',
			{
				maxZoom: 19,
				attribution: 'Datenquelle: <a href="https://basemap.at">basemap.at</a>'
			}
		);
		// Zweite Luftbild-Instanz für die Gruppe, damit Layer-Wechsel nicht
		// zwischen Gruppe und Solo-Luftbild kollidieren (Race Condition).
		var luftbildKopie = L.tileLayer(
			'https://mapsneu.wien.gv.at/basemap/bmaporthofoto30cm/' +
			'normal/google3857/{z}/{y}/{x}.jpeg',
			{
				maxZoom: 19,
				attribution: 'Datenquelle: <a href="https://basemap.at">basemap.at</a>'
			}
		);
		var gemischt = L.layerGroup( [ luftbildKopie, overlay ] );

		// OpenRailwayMap ist ein transparentes Gleis-Overlay und braucht eine
		// Basiskarte darunter. Eigene OSM-Instanz für die Gruppe (gleiches
		// Race-Condition-Muster wie beim Luftbild). Der Tileserver liefert
		// 512px-Kacheln, daher tileSize/zoomOffset. Die a/b/c-Subdomains sind
		// deprecated, daher URL ohne {s}.
		var osmFuerBahn = L.tileLayer(
			'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
			{ maxZoom: 19, attribution: '© OpenStreetMap-Mitwirkende' }
		);
		var bahnOverlay = L.tileLayer(
			'https://tiles.openrailwaymap.org/standard/{z}/{x}/{y}.png',
			{
				maxZoom: 19,
				tileSize: 512,
				zoomOffset: -1,
				attribution: 'Overlay: <a href="https://www.openrailwaymap.org/">OpenRailwayMap</a> (CC-BY-SA 2.0)'
			}
		);
		var eisenbahn = L.layerGroup( [ osmFuerBahn, bahnOverlay ] );

		// Hilfsfunktionen für frische Instanzen: jede LayerGroup braucht
		// EIGENE Tile-Layer, sonst kollidieren die Gruppen beim Umschalten
		// (Race Condition, siehe luftbildKopie oben).
		function neuesLuftbild() {
			return L.tileLayer(
				'https://mapsneu.wien.gv.at/basemap/bmaporthofoto30cm/' +
				'normal/google3857/{z}/{y}/{x}.jpeg',
				{
					maxZoom: 19,
					attribution: 'Datenquelle: <a href="https://basemap.at">basemap.at</a>'
				}
			);
		}
		function neuesBmapOverlay() {
			return L.tileLayer(
				'https://mapsneu.wien.gv.at/basemap/bmapoverlay/' +
				'normal/google3857/{z}/{y}/{x}.png',
				{
					maxZoom: 19,
					attribution: 'Datenquelle: <a href="https://basemap.at">basemap.at</a>'
				}
			);
		}
		function neuesBahnOverlay() {
			return L.tileLayer(
				'https://tiles.openrailwaymap.org/standard/{z}/{x}/{y}.png',
				{
					maxZoom: 19,
					tileSize: 512,
					zoomOffset: -1,
					attribution: 'Overlay: <a href="https://www.openrailwaymap.org/">OpenRailwayMap</a> (CC-BY-SA 2.0)'
				}
			);
		}
		function neuesOsm() {
			return L.tileLayer(
				'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
				{ maxZoom: 19, attribution: '© OpenStreetMap-Mitwirkende' }
			);
		}
		function neuesOpenTopo() {
			return L.tileLayer(
				'https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png',
				{
					maxZoom: 17,
					attribution: 'Kartendaten: © OpenStreetMap-Mitwirkende, SRTM | ' +
						'Darstellung: © OpenTopoMap (CC-BY-SA)'
				}
			);
		}
		function neuesTracestrack() {
			return L.tileLayer(
				'https://tile.tracestrack.com/topo__/{z}/{x}/{y}.png' +
				'?key=bc0464a46d41b1107569bd81112f05ab',
				{
					maxZoom: 19,
					attribution: '© <a href="https://www.tracestrack.com/">Tracestrack</a>, ' +
						'© OpenStreetMap-Mitwirkende'
				}
			);
		}

		// Zug-Kombis: jeweils Basiskarte(n) unten, Gleise obendrauf.
		var zugLuftbildKarte = L.layerGroup(
			[ neuesLuftbild(), neuesBmapOverlay(), neuesBahnOverlay() ]
		);
		var zugLuftbild = L.layerGroup( [ neuesLuftbild(), neuesBahnOverlay() ] );
		var zugKarte = eisenbahn; // OSM + Gleise (bereits oben gebaut)
		var zugTopo = L.layerGroup( [ neuesOpenTopo(), neuesBahnOverlay() ] );
		var zugTopo2 = L.layerGroup( [ neuesTracestrack(), neuesBahnOverlay() ] );

		// Bus: ÖPNVKarte (memomaps.de) ist eine VOLLSTÄNDIGE Karte mit
		// Buslinien/Haltestellen, KEIN transparentes Overlay. Deshalb gibt
		// es keine Bus+Luftbild-Kombis. Hinweis: Datenpflege des Projekts
		// ist laut OSM-Wiki unregelmäßig.
		var bus = L.tileLayer(
			'https://tileserver.memomaps.de/tilegen/{z}/{x}/{y}.png',
			{
				maxZoom: 18,
				attribution: 'Karte: <a href="https://memomaps.de/">memomaps.de</a> ' +
					'(CC-BY-SA), Daten: © OpenStreetMap-Mitwirkende'
			}
		);

		return {
			basis: {
				'Luftbild + Karte': gemischt,
				'Luftbild': luftbild,
				'Karte': osmStandard,
				'Topokarte': osmRelief,
				'Topokarte2': tracestrack,
				'Zug + Luftbild + Karte': zugLuftbildKarte,
				'Zug + Luftbild': zugLuftbild,
				'Zug + Karte': zugKarte,
				'Zug + Topokarte': zugTopo,
				'Zug + Topokarte2': zugTopo2,
				'Bus': bus
			},
			standard: gemischt
		};
	}

	/* ====================================================================
	 * 3) PIN-ICONS
	 * ==================================================================== */
	function icon( aktiv ) {
		return L.divIcon( {
			className: 'wikimap-pin' + ( aktiv ? ' wikimap-pin-aktiv' : '' ),
			iconSize: aktiv ? [ 28, 28 ] : [ 18, 18 ],
			iconAnchor: aktiv ? [ 14, 28 ] : [ 9, 18 ],
			popupAnchor: [ 0, aktiv ? -28 : -18 ]
		} );
	}

	/* ====================================================================
	 * 4) DATEN AUS WIKI-JSON-SEITE
	 * ==================================================================== */
	function ladeJson( seite ) {
		var api = new mw.Api();
		return api.get( {
			action: 'query',
			prop: 'revisions',
			titles: seite,
			rvprop: 'content',
			rvslots: 'main',
			formatversion: 2
		} ).then( function ( data ) {
			var page = data.query.pages[ 0 ];
			if ( !page || page.missing || !page.revisions ) {
				throw new Error( 'Seite fehlt: ' + seite );
			}
			return JSON.parse( page.revisions[ 0 ].slots.main.content );
		} );
	}

	/* ====================================================================
	 * 5) EINE EINZELNE KARTE BAUEN
	 * ==================================================================== */
	function baueKarte( container, orte, gemeinden, kategorien ) {
		var aktivId = container.getAttribute( 'data-aktiv-ort' ) || null;

		// Ad-hoc-Pin-Modus: data-pin="lat,lon" zeigt GENAU EINEN Pin an
		// diesen Koordinaten und KEINE Orte aus der zentralen JSON.
		// Gedacht z.B. für Veranstaltungsseiten (Eventform), deren Ort
		// nicht in WikiMap-Orte.json gepflegt wird.
		// Optional: data-pin-name für den Popup-Text.
		var pinAttr = container.getAttribute( 'data-pin' ) || '';
		var pinKoord = null;
		if ( pinAttr ) {
			var pinTeile = pinAttr.split( ',' );
			if ( pinTeile.length === 2 ) {
				var pLat = parseFloat( pinTeile[ 0 ] );
				var pLon = parseFloat( pinTeile[ 1 ] );
				if ( !isNaN( pLat ) && !isNaN( pLon ) ) {
					pinKoord = [ pLat, pLon ];
				}
			}
		}
		var pinName = container.getAttribute( 'data-pin-name' ) || '';

		// Nur den aktiven Ort zeigen (alle anderen Pins ausblenden).
		var nurAktiv = container.getAttribute( 'data-nur-aktiv' ) === 'ja';

		// Start-Layer per Menü-Name wählen, z.B. data-layer="Eisenbahnkarte".
		// Unbekannter Name => normaler Default.
		var layerWunsch = container.getAttribute( 'data-layer' ) || '';

		// Filter: eine oder mehrere Kategorien, kommagetrennt.
		// "asiatisch,gasthaus" => nur Orte, deren kategorie EINE davon ist.
		// Leer/nicht gesetzt => alle Orte.
		var filterAttr = container.getAttribute( 'data-filter' ) || '';
		var filterListe = filterAttr
			.split( ',' )
			.map( function ( s ) { return s.trim(); } )
			.filter( function ( s ) { return s.length > 0; } );

		var hoehe = container.getAttribute( 'data-hoehe' );
		if ( hoehe ) {
			container.style.height = parseInt( hoehe, 10 ) + 'px';
		}

		var hatFullscreen = !!( L.Control && L.Control.FullScreen );

		var layer = baueLayer();
		// Start-Layer: per data-layer gewünschter Menü-Name, sonst Default.
		var startLayer = ( layerWunsch && layer.basis[ layerWunsch ] ) ?
			layer.basis[ layerWunsch ] : layer.standard;
		var map = L.map( container, {
			center: CONFIG.defaultCenter,
			zoom: CONFIG.defaultZoom,
			layers: [ startLayer ],
			fullscreenControl: hatFullscreen
		} );
		L.control.layers( layer.basis ).addTo( map );

		// Sicherheitsnetz: Knopf explizit hinzufügen, falls die Option oben
		// nicht gegriffen hat (z.B. Plugin kam minimal verspätet).
		if ( hatFullscreen && !map.fullscreenControl ) {
			map.addControl( new L.Control.FullScreen() );
		}

		if ( container.getAttribute( 'data-gemeinden' ) === 'ja' && gemeinden ) {
			L.geoJSON( gemeinden, {
				style: { color: '#3367d6', weight: 1.5, fillOpacity: 0.05 },
				onEachFeature: function ( feature, lyr ) {
					var p = feature.properties || {};
					var name = p.name || 'Gemeinde';
					var ziel = p.seite || name;
					lyr.bindTooltip( name );
					lyr.on( 'click', function () {
						window.location.href = mw.util.getUrl( ziel );
					} );
					lyr.on( 'mouseover', function () {
						lyr.setStyle( { fillOpacity: 0.25 } );
					} );
					lyr.on( 'mouseout', function () {
						lyr.setStyle( { fillOpacity: 0.05 } );
					} );
				}
			} ).addTo( map );
		}

		var aktiverMarker = null;
		var sichtbare = [];

		// Ad-hoc-Pin-Modus: nur dieser eine Pin, keine Orte aus der JSON.
		if ( pinKoord ) {
			var pinMarker = L.marker( pinKoord, {
				icon: icon( true ),
				zIndexOffset: 1000
			} );
			if ( pinName ) {
				pinMarker.bindPopup( '<b>' + mw.html.escape( pinName ) + '</b>' );
			}
			pinMarker.addTo( map );
			map.setView( pinKoord, CONFIG.focusZoom );
			if ( pinName ) {
				pinMarker.openPopup();
			}

			map.on( 'resize fullscreenchange', function () {
				setTimeout( function () {
					map.invalidateSize();
				}, 100 );
			} );
			return map;
		}

		orte.forEach( function ( ort ) {
			// Nur-aktiv-Modus: alles außer dem aktiven Ort überspringen.
			if ( nurAktiv && ( !aktivId || ort.id !== aktivId ) ) {
				return;
			}
			// Filter: wenn eine Filterliste gesetzt ist, muss die Kategorie
			// des Ortes in der Liste vorkommen. Sonst wird der Ort ausgelassen.
			if ( filterListe.length && filterListe.indexOf( ort.kategorie ) === -1 ) {
				return;
			}

			// Koordinaten: bevorzugt aus dem geo-Feld (String "lat,lon",
			// direkt aus OSM/OrganicMaps kopierbar, Leerzeichen egal),
			// sonst aus den klassischen lat/lon-Zahlenfeldern.
			var lat = ort.lat;
			var lon = ort.lon;
			if ( typeof ort.geo === 'string' ) {
				var teile = ort.geo.split( ',' );
				if ( teile.length === 2 ) {
					lat = parseFloat( teile[ 0 ] );
					lon = parseFloat( teile[ 1 ] );
				}
			}
			if ( typeof lat !== 'number' || typeof lon !== 'number' ||
				isNaN( lat ) || isNaN( lon ) ) {
				return;
			}
			var istAktiv = aktivId && ort.id === aktivId;
			// id ist Anzeigename und Ziel. seite (optional) übersteuert das
			// Ziel; beginnt sie mit http:// oder https://, wird direkt auf
			// die externe Adresse verlinkt statt auf eine Wiki-Seite.
			var ziel = ort.seite || ort.id || '';
			var istExtern = /^https?:\/\//i.test( ziel );
			var link = istExtern ? ziel : mw.util.getUrl( ziel );
			var m = L.marker( [ lat, lon ], {
				icon: icon( istAktiv ),
				zIndexOffset: istAktiv ? 1000 : 0
			} );

			// Kategorie-Farbe (nur für nicht-aktive Pins; aktiver bleibt rot).
			// Wird gesetzt, sobald das Marker-DOM-Element existiert.
			var farbe = kategorien && ort.kategorie ? kategorien[ ort.kategorie ] : null;
			if ( farbe && !istAktiv ) {
				m.on( 'add', function () {
					if ( this._icon ) {
						this._icon.style.background = farbe;
					}
				} );
			}

			m.bindPopup(
				'<b>' + mw.html.escape( ort.id || '' ) + '</b><br>' +
				'<a href="' + link + '"' +
				( istExtern ? ' target="_blank" rel="noopener"' : '' ) +
				'>Zur Seite →</a>'
			);
			m.addTo( map );
			sichtbare.push( [ lat, lon ] );
			if ( istAktiv ) {
				aktiverMarker = m;
			}
		} );

		if ( aktiverMarker ) {
			map.setView( aktiverMarker.getLatLng(), CONFIG.focusZoom );
			aktiverMarker.openPopup();
		} else if ( sichtbare.length ) {
			map.fitBounds( sichtbare, { padding: [ 30, 30 ] } );
		}

		map.on( 'resize fullscreenchange', function () {
			setTimeout( function () {
				map.invalidateSize();
			}, 100 );
		} );
		return map;
	}

	/* ====================================================================
	 * 6) EINSTIEG
	 * ==================================================================== */
	function init() {
		var container = document.querySelectorAll( '.wikimap' );
		if ( !container.length ) {
			return;
		}
		var brauchtGemeinden = Array.prototype.some.call(
			container,
			function ( c ) {
				return c.getAttribute( 'data-gemeinden' ) === 'ja';
			}
		);
		var aufgaben = [ ladeJson( CONFIG.orteSeite ) ];
		aufgaben.push(
			brauchtGemeinden ?
				ladeJson( CONFIG.gemeindenSeite ).catch( function () {
					return null;
				} ) :
				$.Deferred().resolve( null )
		);
		$.when.apply( $, aufgaben ).then( function ( orteData, gemeinden ) {
			var orte = ( orteData && orteData.orte ) || [];
			// Optionaler Farb-Block: Kategorie -> Farbe. Fehlt er, bleiben
			// die Pins in der Standardfarbe.
			var kategorien = ( orteData && orteData.kategorien ) || {};
			Array.prototype.forEach.call( container, function ( c ) {
				try {
					baueKarte( c, orte, gemeinden, kategorien );
				} catch ( e ) {
					c.textContent = 'Karte konnte nicht geladen werden.';
					mw.log.error( e );
				}
			} );
		} ).catch( function ( e ) {
			Array.prototype.forEach.call( container, function ( c ) {
				c.textContent = 'Ortsdaten konnten nicht geladen werden.';
			} );
			mw.log.error( e );
		} );
	}

	/* ====================================================================
	 * 7) LEAFLET SICHERSTELLEN, DANN STARTEN
	 * ==================================================================== */

	// Lädt EIN Skript nach. Wichtig: manche Leaflet-Plugins sind UMD-Module
	// und registrieren sich bei vorhandenem AMD-Loader (z.B. ResourceLoader)
	// NICHT am globalen L. Wir deaktivieren define.amd kurzzeitig, damit das
	// Plugin den globalen-L-Pfad nimmt.
	function ladeSkript( src ) {
		return $.Deferred( function ( d ) {
			var amd = window.define;
			var hatAmd = amd && amd.amd;
			if ( hatAmd ) {
				window.define = undefined; // AMD kurz ausblenden
			}
			var s = document.createElement( 'script' );
			s.src = src;
			s.onload = function () {
				if ( hatAmd ) {
					window.define = amd; // AMD wiederherstellen
				}
				d.resolve();
			};
			s.onerror = function () {
				if ( hatAmd ) {
					window.define = amd;
				}
				mw.log.error( 'WikiMap: Datei nicht ladbar: ' + src );
				d.resolve(); // trotzdem weitermachen
			};
			document.head.appendChild( s );
		} ).promise();
	}

	function ladeCss( href ) {
		var l = document.createElement( 'link' );
		l.rel = 'stylesheet';
		l.href = href;
		document.head.appendChild( l );
	}

	function stelleLeafletBereit( fertig ) {
		var q = LEAFLET_QUELLEN[ LEAFLET_MODUS ];

		// CSS immer einhängen (auch wenn L schon da ist, könnte Plugin-CSS fehlen).
		q.css.forEach( ladeCss );

		// JS-Dateien strikt nacheinander laden und ERST starten, wenn sowohl
		// L.map als auch L.Control.FullScreen existieren.
		var kette = $.Deferred().resolve().promise();
		q.js.forEach( function ( src ) {
			kette = kette.then( function () {
				// Leaflet-Core nur laden, wenn noch nicht da.
				if ( /leaflet\.js$/.test( src ) && window.L && window.L.map ) {
					return;
				}
				return ladeSkript( src );
			} );
		} );

		kette.then( function () {
			// Sicherheitsnetz: kurz warten, falls Plugin minimal verzögert ist.
			var versuche = 0;
			( function pruefe() {
				if ( window.L && window.L.map ) {
					fertig();
				} else if ( versuche++ < 50 ) {
					setTimeout( pruefe, 50 );
				} else {
					mw.log.error( 'WikiMap: Leaflet nicht verfügbar.' );
				}
			}() );
		} );
	}

	mw.loader.using( [ 'mediawiki.api', 'mediawiki.util' ] ).then( function () {
		$( function () {
			if ( !document.querySelector( '.wikimap' ) ) {
				return;
			}
			stelleLeafletBereit( init );
		} );
	} );

}() );