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
// <nowiki> /**  * VoyageData  * Durchsucht den aktuellen Artikel nach Markern und vCards ohne Wikidata-ID und listet mögliche Kandidaten auf.  *  * Dokumentation: [[m:User:Nw520/Gadgets#VoyageData]]  * Maintainer: [[voy:de:User:nw520]]  *   * Entwicklungsversion: [[voy:de:User:Nw520/VoyageData.js]]  * Produktiv-Version: [[voy:de:MediaWiki:Gadget-VoyageData.js]]  */ /* eslint-disable mediawiki/class-doc */ $.when( mw.loader.using( [ 'mediawiki.notification', 'mediawiki.util' ] ), $.ready ).then( function () { 	const strings = { 		'voy-voyagedata-advancedsettings': { 			de: 'Fortgeschrittene Einstellungen', 			en: 'Advanced settings' 		}, 		'voy-voyagedata-bybbox-description': { 			de: 'Im in der Karte sichtbaren Ausschnitt alle Wikidata-Datenobjekte laden / [+Shift] Wikidata Query Service öffnen', 			en: 'In the bounding box visible in the map, load all Wikidata data objects / [+Shift] Open Wikidata Query Service' 		}, 		'voy-voyagedata-bybbox-label': { 			de: 'Bbox', 			en: 'Bbox' 		}, 		'voy-voyagedata-bynamequery-description': { 			de: 'Anhand des Namens jedes Markers Wikidata-Datenobjekte suchen. Die hier verwendete Schnittstelle ist gröber als die Standardmethode / [+Shift] Suffix für Namen festlegen', 			en: 'Search wikidata data objects by the name of each marker. The used interface is more relaxed than the default one / [+Shift] Specify suffix for names' 		}, 		'voy-voyagedata-bynamequery-label': { 			de: 'Name (grob)', 			en: 'Name (coarse)' 		}, 		'voy-voyagedata-byradius-description': { 			de: 'In einem bestimmen Radius um jeden Marker mit Koordinaten alle Wikidata-Datenobjekte laden / [+Shift] Wikidata Query Service öffnen', 			en: 'Load all Wikidata data objects in a specified radius around each marker with coordinates / [+Shift] Open Wikidata Query Service' 		}, 		'voy-voyagedata-byradius-label': { 			de: 'Radius', 			en: 'Radius' 		}, 		'voy-voyagedata-byradius-prompt-radius': { 			de: 'Bitte gib einen Radius in Kilometern ein.', 			en: 'Please enter a radius in kilometres.' 		}, 		'voy-voyagedata-bynamestrict-description': { 			de: 'Anhand des Namens jedes Markers Wikidata-Datenobjekte suchen / [+Shift] Suffix für Namen festlegen', 			en: 'Search wikidata data objects by the name of each marker / [+Shift] Specify suffix for names' 		}, 		'voy-voyagedata-bynamestrict-label': { 			de: 'Name', 			en: 'Name' 		}, 		'voy-voyagedata-clipboard-name-fail': { 			de: 'vCard-Name konnte nicht in Zwischenablage kopiert werden.', 			en: 'Failed to copy name of vCard to clipboard.' 		}, 		'voy-voyagedata-clipboard-name-success': { 			de: 'vCard-Name in Zwischenablage kopiert.', 			en: 'Name of vCard copied to clipboard!' 		}, 		'voy-voyagedata-clipboard-wdid-fail': { 			de: 'Wikidata-ID konnte nicht in Zwischenablage kopiert werden.', 			en: 'Failed to copy Wikidata ID to clipboard.' 		}, 		'voy-voyagedata-clipboard-wdid-success': { 			de: 'Wikidata-ID in Zwischenablage kopiert.', 			en: 'Wikidata ID copied to clipboard!' 		}, 		'voy-voyagedata-config-title': { 			de: 'VoyageData-Einstellungen', 			en: 'VoyageData config' 		}, 		'voy-voyagedata-cornerlayout-label': { 			de: 'VoyageData in Fensterecke anzeigen', 			en: 'Display VoyageData in window corner' 		}, 		'voy-voyagedata-discard': { 			de: 'Verwerfen', 			en: 'Discard' 		}, 		'voy-voyagedata-initialradius-label': { 			de: 'Vorgeschlagener Radius', 			en: 'Initial radius' 		}, 		'voy-voyagedata-kill-description': { 			de: 'VoyageData schließen', 			en: 'Close VoyageData' 		}, 		'voy-voyagedata-minimise-description': { 			de: 'VoyageData minimieren', 			en: 'Minimise VoyageData' 		}, 		'voy-voyagedata-please-wait': { 			de: 'Nur eine Sekunde, bitte, VoyageData wird geladen.', 			en: 'Just a second, please, VoyageData is loading.' 		}, 		'voy-voyagedata-nameparameterchain-label': { 			de: 'Parameter-Reihenfolge für Namenssuche', 			en: 'Order of parameters for lookup by name' 		}, 		'voy-voyagedata-no-name-placeholder': { 			de: '⟨kein Name⟩', 			en: '⟨no name⟩' 		}, 		'voy-voyagedata-orphans': { 			de: 'Verwaiste Einträge', 			en: 'Orphans' 		}, 		'voy-voyagedata-pin-label': { 			de: 'VoyageData an- bzw. abpinnen / [+Shift] VoyageData beenden', 			en: '(Un)pin VoyageData / [+Shift] Exit VoyageData' 		}, 		'voy-voyagedata-portlet-load': { 			de: 'Wikidata-IDs mit VoyageData', 			en: 'Wikidata IDs via VoyageData' 		}, 		'voy-voyagedata-save': { 			de: 'Speichern', 			en: 'Save' 		}, 		'voy-voyagedata-search-failed': { 			de: 'Bei der Suche trat ein Fehler auf', 			en: 'An error occurred during the search' 		}, 		'voy-voyagedata-settings-label': { 			de: 'Erweiterte Einstellungen', 			en: 'Advanced settings' 		}, 		'voy-voyagedata-wdclassblacklist-label': { 			de: 'Klassen bei Wikidata-Datenobjekten ausschließen (ODER)', 			en: 'Exclude classes for wikidata items (OR)' 		}, 		'voy-voyagedata-wdclasswhitelist-label': { 			de: 'Klassen bei Wikidata-Datenobjekten erfordern (ODER)', 			en: 'Require classes for wikidata items (OR)' 		} 	};  	/** 	 * @type {string} 	 */ 	let cachedLangLocal = null;  	/** 	 * @typedef {string} ItemManagerEvent 	 */  	class VoyageData { 		/** 		 * @readonly 		 */ 		static COORDINATES_MAX_LENGTH = 8;  		/** 		 * @readonly 		 */ 		static INITIAL_ZOOM = 12;  		/** 		 * @readonly 		 */ 		static SEARCH_LIMIT = 15;  		/** 		 * @readonly 		 */ 		static SPARQL_LIMIT = 1500;  		static WIKIDATA_CLASS_BLACKLIST = null;  		static WIKIDATA_CLASS_SUGGESTIONS = [ 			[ 'Befestigungen', 'Q57821' ], 			[ 'Friedhöfe', 'Q39614' ], 			[ 'militärische Gebäude', 'Q6852233' ], 			[ 'Mühle', 'Q44494' ], 			[ 'Paläste', 'Q16560' ], 			[ 'Parks', 'Q22698' ], 			[ 'Plätze', 'Q174782' ], 			[ 'Rathäuser', 'Q543654' ], 			[ 'Schlösser', 'Q751876' ], 			[ 'Theater', 'Q11635' ], 			'GLAM', 			[ 'GLAM: Galerien, Bibliotheken, Archive und Museen', 'Q1030034' ], 			[ 'Bibliotheken', 'Q7075' ], 			[ 'Galerien', 'Q164419' ], 			[ 'Museen', 'Q33506' ], 			'Mobilität', 			[ 'Bahnhöfe', 'Q55488' ], 			[ 'Haltestellen', 'Q548662' ], 			'religiöses Gebäude', 			[ 'religiöses Gebäude', 'Q24398318' ], 			[ 'Kirchengebäude', 'Q16970' ], 			[ 'Moscheen', 'Q32815' ], 			[ 'Synagogen', 'Q34627' ], 			[ 'Tempel', 'Q44539' ] 		];  		static WIKIDATA_CLASS_WHITELIST = null;  		/** 		 * @property {VoyageData.ItemManager} 		 */ 		itemManager = null;  		/** 		 * @property {VoyageData.Queue} 		 */ 		queue = null;  		/** 		 * @property {[EventTarget, string, EventListenerOrEventListenerObject][]} 		 */ 		#eventListeners = [];  		/** 		 * @property {VoyageData.Settings} 		 */ 		#settings = null;  		/** 			* @property {number} 			*/ 		#taskCounter = 0;  		/** 			* @property {OO.ui.ProgressBarWidget} 			*/ 		#taskProgressBar = null;  		/** 		 * @readonly 		 * @property {Set<string>} 		 */ 		wikidataIdsInMap = new Set();  		/** 		 * @readonly 		 * @property {Set<string>} 		 */ 		wikidataIdsLoaded = new Set();  		/** 		 * @typedef Coordinate 		 * @property {number} lat 		 * @property {number} long 		 */  		constructor() { 			this.#settings = new VoyageData.Settings(); 		}  		/** 		 * @typedef Feature 		 * @property {string} type 		 * @property {Object} properties 		 * (property {string} properties.marker-color) # Causes errors 		 * (property {string} properties.marker-size) # Causes errors 		 * (property {string} properties.marker-symbol) # Causes errors 		 * (property {string} properties.title) # Causes errors 		 * @property {Object} geometry 		 * @property {string} geometry.type 		 * @property {number[]} geometry.coordinates 		 */ 		  		static async copyFromMarkerToClipboard( e, wdId ) { 			if ( e.shiftKey ) { 				return; 			}  			e.preventDefault();  			try { 				await navigator.clipboard.writeText( wdId ); 				mw.notify( mw.msg( 'voy-voyagedata-clipboard-wdid-success' ), { 					tag: 'voy-voyagedata-clipboard', 					title: 'VoyageData', 					type: 'success' 				} ); 			} catch ( ex ) { 				console.error( ex ); 				mw.notify( mw.msg( 'voy-voyagedata-clipboard-wdid-fail' ), { 					tag: 'voy-voyagedata-clipboard', 					title: 'VoyageData', 					type: 'error' 				} ); 			} 		}  		static setupCss() { 			mw.util.addCSS( ` .voy-voyagedata-only-banner, .voy-voyagedata-only-corner { 	display: none; } .voy-voyagedata, .voy-voyagedata * { 	box-sizing: border-box; } .voy-voyagedata--banner .voy-voyagedata-only-banner { 	display: unset; } .voy-voyagedata--banner .voy-voyagedata-sticky:not(.voy-voyagedata-nosticky) { 	position: fixed; 	z-index: 10; } .voy-voyagedata--banner .voy-voyagedata-wrapper { 	height: 400px; 	max-height: 100vh; 	resize: vertical; } .voy-voyagedata--corner { 	bottom: 0; 	position: fixed; 	right: 0; 	transform: none; 	transition: transform .25s ease-in-out; 	z-index: 10; } .voy-voyagedata--corner .voy-voyagedata-only-corner { 	display: unset; } .voy-voyagedata--corner .voy-voyagedata-page-main { 	flex-direction: column; } .voy-voyagedata--corner .voy-voyagedata-page-main > div { 	overflow: hidden; } .voy-voyagedata--corner .voy-voyagedata-wrapper { 	border: 1px solid #c8ccd1; 	box-shadow: 0 2px 2px 0 rgba(0,0,0,0.25); 	height: calc(100vh - 6em); 	width: max(40vw, 40em); } .voy-voyagedata-list { 	flex: 1; 	overflow-y: auto; } .voy-voyagedate-listpanel { 	display: flex; 	flex-direction: column; } .voy-voyagedata--minimised { 	transform: translateY(calc(100% - 2.4em)); } .voy-voyagedata--minimised .voy-voyagedata-wrapper { 	border: none; } .voy-voyagedata-msgbox { 	border-style: solid; 	color: #000; 	font-weight: bold; 	margin: 2em 0 1em; 	margin-top: 2em; 	padding: 0.5em 1em; } .voy-voyagedata-noresults { 	color: #888; } .voy-voyagedata-notice { 	background-color: #f8f8f8; 	border-color: #ccc; 	display: flex; 	margin-top: 1em; } .voy-voyagedata-notice-text { 	flex: 1; 	margin-bottom: 0; } .voy-voyagedata-page-main { 	display: flex; 	flex: 1; 	overflow: hidden; } .voy-voyagedata-page-main > div { 	flex: 1; } .voy-voyagedata-resolved { 	background-color: lightgreen; } .voy-voyagedata-wrapper { 	background-color: white; 	display: flex; 	flex-direction: column; 	overflow: hidden; } .voy-voyagedata-wrapper h3 { 	margin-top: .45em; 	padding: 0; } .voy-voyagedata-wrapper .oo-ui-progressBarWidget { 	max-width: unset; } .skin-timeless .voy-voyagedata--banner { 	margin: 0 -2em; } .skin-timeless .voy-voyagedata--banner .voy-voyagedata-wrapper { 	border-bottom: .5em solid #eaecf0; 	border-top: thin solid #eaecf0; 	padding: 0 0 0 2em; } .skin-vector-legacy .voy-voyagedata-wrapper { 	border-bottom: 1px solid #a7d7f9; } @media screen and (max-width: 850px) { 	.skin-timeless .voy-voyagedata { 		margin: 0 !important; 	} 	.skin-timeless .voy-voyagedata-wrapper { 		padding: 0 0 0 .45em; 	} } ` ); 		}  		/** 		 * @param {Coordinate} sw 		 * @param {Coordinate} ne 		 * @param {string} langUser 		 * @param {string} langLocal 		 * @param {string[]} blacklistItem 		 * @param {string[]} whitelistClass 		 * @param {number} limit 		 * @param {string} customRules 		 * @return {string} 		 */ 		static sparqlQueryBox( sw, ne, langUser, langLocal, blacklistItem, whitelistClass, limit, customRules ) { 			return `SELECT DISTINCT ?item ?location ?labelUser ?labelEn ?labelLocal ?description ?ref WHERE { SERVICE wikibase:box { 	?item wdt:P625 ?location . 	bd:serviceParam wikibase:cornerSouthWest ${coordinateToGeoLiteral( sw )}. 	bd:serviceParam wikibase:cornerNorthEast ${coordinateToGeoLiteral( ne )}. } OPTIONAL {?item rdfs:label ?labelUser. FILTER(LANG(?labelUser)="${langUser}")} OPTIONAL {?item rdfs:label ?labelEn. FILTER(LANG(?labelEn)="en")} OPTIONAL {?item rdfs:label ?labelLocal. FILTER(LANG(?labelLocal)="${langLocal}")} OPTIONAL {?item schema:description ?description. FILTER(LANG(?description)="${langUser}")} ${VoyageData.#sparqlBlackWhiteListFragment( blacklistItem, whitelistClass, customRules )} } LIMIT ${limit}`; 		}  		/** 		 * @param {Array<any>} claims 		 * @return {any} 		 */ 		static #bestClaim( claims ) { 			const preferred = []; 			const normal = [];  			for ( const claim of claims ) { 				if ( claim.mainsnak.snaktype !== 'value' ) { 					// Skip novalue and somevalue 				}  				// TODO: Check for end qualifier  				if ( claim.rank === 'preferred' ) { 					preferred.push( claim ); 				} else if ( claim.rank === 'normal' ) { 					normal.push( claim ); 				} 			}  			if ( preferred.length > 0 ) { 				return preferred[ 0 ]; 			} else if ( normal.length > 0 ) { 				return normal[ 0 ]; 			} else { 				return null; 			} 		}  		/** 		 * @param {string} lemma 		 * @param {string|number} offset 		 * @param {string|number} searchLimit 		 * @return {Promise<[Array<VoyageData.ItemResult>, number]>} Search results and new offset 		 */ 		static async #querySearch( lemma, offset, searchLimit ) { 			const data = await ( await VoyageData.#fetchGet( 'https://www.wikidata.org/w/api.php', { 				action: 'query', 				format: 'json', 				list: 'search', 				origin: '*', 				srenablerewrites: 1, 				srlimit: searchLimit, 				srnamespace: 0, 				sroffset: offset, 				srsearch: lemma 			}, { 				cache: 'no-cache', 				headers: { 					Accept: 'application/json, text/plain, */*' 				}, 				mode: 'cors' 			} ) ).json();  			const results = data.query.search; 			const newOffset = data?.continue?.sroffset ?? 0; 			const items = await VoyageData.#wdItems( results.map( ( searchResult ) => { 				return searchResult.title; 			} ) );  			return [ items.filter( ( entity ) => { 				return entity !== null; 			} ).map( ( entity ) => { 				const coordinateClaim = ( entity.claims?.P625 ?? null ) !== null ? VoyageData.#bestClaim( entity.claims?.P625 ) : null; 				const coordinates = coordinateClaim !== null ? { 					lat: coordinateClaim.mainsnak.datavalue.value.latitude, 					long: coordinateClaim.mainsnak.datavalue.value.longitude 				} : null;  				return new VoyageData.ItemResult( entity.id, entity.labels?.de?.value, entity.labels?.en?.value, null, entity.description?.de?.value ?? entity.description?.en?.value, coordinates ); 			} ), newOffset ]; 		}  		/** 		 * @param {VoyageData.Item[]} referenceItems 		 * @param {string} langUser 		 * @param {string} langLocal 		 * @param {string[]} blacklistItem 		 * @param {string[]} whitelistClass 		 * @param {number} radius 		 * @param {number} limit 		 * @param {string} customRules 		 * @return {string} 		 */ 		static sparqlQueryRadius( referenceItems, langUser, langLocal, blacklistItem, whitelistClass, radius, limit, customRules ) { 			const referenceList = referenceItems.map( ( item ) => { 				return `(${coordinateToGeoLiteral( item.coordinates )} ${item.uid})`; 			} ).join( ' ' );  			return `SELECT DISTINCT ?item ?location (geof:distance(?reference, ?location) AS ?locationRefDist) ?labelUser ?labelEn ?labelLocal ?description ?ref WHERE { VALUES (?reference ?ref) {${referenceList}} SERVICE wikibase:around { 	?item wdt:P625 ?location. 	bd:serviceParam wikibase:center ?reference. 	bd:serviceParam wikibase:radius "${String( radius )}". } OPTIONAL {?item rdfs:label ?labelUser. FILTER(LANG(?labelUser)="${langUser}")} OPTIONAL {?item rdfs:label ?labelEn. FILTER(LANG(?labelEn)="en")} OPTIONAL {?item rdfs:label ?labelLocal. FILTER(LANG(?labelLocal)="${langLocal}")} OPTIONAL {?item schema:description ?description. FILTER(LANG(?description)="${langUser}")} ${VoyageData.#sparqlBlackWhiteListFragment( blacklistItem, whitelistClass, customRules )} } ORDER BY ?locationRefDist LIMIT ${limit}`; 		}  		static #sparqlBlackWhiteListFragment( blacklistItem, whitelistClass, customRules ) { 			const blacklistItemList = blacklistItem.map( ( item ) => { 				return `wd:${item}`; 			} ).join( ',' ); 			const whitelistClassList = whitelistClass.map( ( classification ) => { 				return `wd:${classification}`; 			} ).join( ' ' );  			const blacklistFragment = blacklistItem.length > 0 ? `FILTER (?item NOT IN (${blacklistItemList})).` : ''; 			const whitelistClassFragment = whitelistClass.length > 0 ? `VALUES ?whitelistClass {${whitelistClassList}}. 			?item wdt:P31/wdt:P279* ?whitelistClass.` : '';  			return `${blacklistFragment} 	${whitelistClassFragment} 	${customRules}`; 		}  		/** 		 * @param {Array<string>} entities 		 * @return {Promise<Array<any>>} 		 */ 		static async #wdItems( entities ) { 			if ( entities.length === 0 ) { 				return []; 			} 			 			const data = await ( await VoyageData.#fetchGet( 'https://www.wikidata.org/w/api.php', { 				action: 'wbgetentities', 				format: 'json', 				ids: entities.join( '|' ), 				languages: [ 'de', 'en' ].join( '|' ), 				origin: '*', 				props: [ 'claims', 'descriptions', 'info', 'labels' ].join( '|' ) 			}, { 				cache: 'no-cache', 				headers: { 					Accept: 'application/json, text/plain, */*' 				}, 				mode: 'cors' 			} ) ).json();  			if ( data.success !== 1 ) { 				throw new Error( 'Invalid response from API' ); 			}  			return entities.map( ( entity ) => { 				return data.entities[ entity ]; 			} ); 		}  		/** 		 * @param {string} lemma 		 * @param {number|string} offset 		 * @param {number|string} searchLimit 		 * @return {Promise<[Array<VoyageData.ItemResult>, number]>} Search results and new offset 		 */ 		static async #wbsearchentities( lemma, offset, searchLimit ) { 			const data = await ( await VoyageData.#fetchGet( 'https://www.wikidata.org/w/api.php', { 				action: 'wbsearchentities', 				continue: offset, 				format: 'json', 				language: mw.config.get( 'wgUserLanguage' ), 				limit: searchLimit, 				origin: '*', 				search: lemma, 				type: 'item' 			}, { 				cache: 'no-cache', 				headers: { 					Accept: 'application/json, text/plain, */*' 				}, 				mode: 'cors' 			} ) ).json();  			if ( data.success !== 1 ) { 				throw new Error( 'Request failed' ); 			}  			const newOffset = ( data[ 'search-continue' ] ?? null ) !== null ? parseInt( data[ 'search-continue' ] ) : 0; 			return [ data.search.map( ( searchResult ) => { 				const labelUser = searchResult.match.language === mw.config.get( 'wgUserLanguage' ) ? searchResult.label : null; 				const labelEn = searchResult.match.language === 'en' ? searchResult.label : null; 				const labelLocal = searchResult.match.language === getLangLocal() ? searchResult.label : null; 				return new VoyageData.ItemResult( searchResult.id, labelUser, labelEn, labelLocal, searchResult.description ?? null, null ); 			} ), newOffset ]; 		}  		/** 		 * @param {HTMLElement} mainElement 		 */ 		destroy( mainElement ) { 			mainElement.remove();  			for ( const [ subject, event, listener ] of this.#eventListeners ) { 				( /** @type {EventTarget} */ subject ).removeEventListener( event, listener ); 			} 		}  		/** 		 * @return {[Coordinate, number]} 		 */ 		determineMapCenter() { 			let coordinatesInArticle = this.itemManager.getItemsInArticle( true, true ); 			let center = null; 			if ( coordinatesInArticle.length !== 0 ) { 				// vCards, that need to be resolved but have coordinates 				const bboxCoordinatesInArticle = VoyageData.Bbox.containCoordinates( coordinatesInArticle.map( ( item ) => { 					return item.coordinates; 				} ) ); 				center = bboxCoordinatesInArticle.center(); 				return [ center, VoyageData.INITIAL_ZOOM ]; 			} else if ( document.querySelector( '.voy-coord-indicator[data-lat][data-lon]' ) !== null ) { 				// From Indicator 				/** 				 * @type {HTMLElement} 				 */ 				const indicatorElement = document.querySelector( '.voy-coord-indicator[data-lat][data-lon]' ); 				let zoom = VoyageData.INITIAL_ZOOM; 				center = { 					lat: parseFloat( indicatorElement.dataset.lat ), 					long: parseFloat( indicatorElement.dataset.lon ) 				}; 				if ( indicatorElement.dataset.zoom !== undefined ) { 					zoom = parseInt( indicatorElement.dataset.zoom ); 				} 				return [ center, zoom ]; 			} else { 				// vCards, that have coordinates including vCards with Wikidata-IDs 				coordinatesInArticle = this.itemManager.getItemsInArticle( false, true );  				if ( coordinatesInArticle.length !== 0 ) { 					const bboxCoordinatesInArticle = VoyageData.Bbox.containCoordinates( coordinatesInArticle.map( ( item ) => { 						return item.coordinates; 					} ) ); 					return [ bboxCoordinatesInArticle.center(), VoyageData.INITIAL_ZOOM ]; 				} else { 					// Null-Island 					return [ 						{ 							lat: 0, 							long: 0 						}, VoyageData.INITIAL_ZOOM 					]; 				} 			} 		}  		reportTaskFinished() { 			this.#taskCounter -= 1;  			if ( this.#taskCounter > 0 ) { 				this.#taskProgressBar?.$element.stop().show( 250 ); 			} else { 				this.#taskProgressBar?.$element.stop().hide( 250 ); 			} 		}  		reportTaskStarted() { 			this.#taskCounter += 1;  			if ( this.#taskCounter > 0 ) { 				this.#taskProgressBar?.$element.stop().show( 250 ); 			} else { 				this.#taskProgressBar?.$element.stop().hide( 250 ); 			} 		}  		/** 		 * @param {number} itemUid 		 * @param {string} lemma 		 * @param {number} offset 		 * @param {boolean} useQuerySearch 		 * @return {Promise} 		 */ 		searchRequest( itemUid, lemma, offset, useQuerySearch ) { 			return this.queue.enqueue( async () => { 				let searchResults; 				let newOffset; 				 				try { 					if ( useQuerySearch ) { 						[ searchResults, newOffset ] = await VoyageData.#querySearch( lemma, offset, VoyageData.SEARCH_LIMIT ); 					} else { 						[ searchResults, newOffset ] = await VoyageData.#wbsearchentities( lemma, offset, VoyageData.SEARCH_LIMIT ); 					} 				} catch ( ex ) { 					console.error( ex ); 					mw.notify( mw.msg( 'voy-voyagedata-search-failed' ), { 						tag: 'voy-voyagedata-search-failed', 						title: 'VoyageData', 						type: 'error' 					} ); 					return; 				}  				this.itemManager.setOffset( itemUid, newOffset ); 				this.itemManager.registerItemResults( itemUid, searchResults ); 			} ); 		}  		async setup() { 			const waitNotification = mw.notification.notify( mw.msg( 'voy-voyagedata-please-wait' ), { 				autoHide: false, 				title: 'VoyageData', 				type: 'info' 			} ); 			await mw.loader.using( [ 'ext.kartographer.box', 'oojs-ui', 'oojs-ui.styles.icons-accessibility', 'oojs-ui.styles.icons-interactions', 'oojs-ui.styles.icons-location', 'oojs-ui.styles.icons-media', 'oojs-ui.styles.icons-moderation', 'oojs-ui.styles.icons-wikimedia' ] ); 			waitNotification.close();  			this.itemManager = new VoyageData.ItemManager(); 			this.queue = new VoyageData.Queue();  			const [ center, zoom ] = this.determineMapCenter(); 			const { map, taskProgressBar, wrapper } = await this.setupUi( center, zoom ); 			this.#taskProgressBar = taskProgressBar; 			const markersForVcardsInArticle = getMarkersForVcardsInArticle( true ); 			for ( const groupId of Object.keys( markersForVcardsInArticle ) ) { 				const group = markersForVcardsInArticle[ groupId ];  				map.addGeoJSONLayer( group.items, { 					name: group.name 				} ); 			} 		}  		/** 		 * @param {Coordinate} center 		 * @param {number} [zoom=VoyageData.INITIAL_ZOOM] 		 * @return {Promise<{map: any, taskProgressBar: OO.ui.ProgressBarWidget, wrapper: HTMLElement}>} 		 */ 		async setupUi( center, zoom = VoyageData.INITIAL_ZOOM ) { 			await mw.loader.using( [ 'ext.kartographer.box', 'mediawiki.util', 'oojs-ui-core' ] );  			VoyageData.setupCss();  			const mainElement = document.createElement( 'div' ); 			mainElement.classList.add( 'voy-voyagedata' ); 			mainElement.classList.add( `voy-voyagedata--${this.#settings.getLayout()}` );  			// # Controls 			const controls = document.createElement( 'div' ); 			controls.classList.add( 'voy-voyagedata-controls' ); 			controls.classList.add( 'voy-voyagedata-only-corner' );  			// ## Minimise 			let minimised = false; 			const buttonMinimise = new OO.ui.ButtonWidget( { 				icon: 'eye', 				title: mw.msg( 'voy-voyagedata-minimise-description' ) 			} ); 			buttonMinimise.$element.on( 'click', ( /** @type {MouseEvent} */ e ) => { 				e.preventDefault();  				if ( minimised ) { 					document.querySelector( '.voy-voyagedata' ).classList.remove( 'voy-voyagedata--minimised' ); 					buttonMinimise.setIcon( 'eye' ); 				} else { 					document.querySelector( '.voy-voyagedata' ).classList.add( 'voy-voyagedata--minimised' ); 					buttonMinimise.setIcon( 'eyeClosed' ); 				} 				minimised = !minimised; 			} );  			// ## Kill 			const buttonKill = new OO.ui.ButtonWidget( { 				flags: [ 'destructive' ], 				icon: 'close', 				title: mw.msg( 'voy-voyagedata-kill-description' ) 			} ); 			buttonKill.$element.on( 'click', ( /** @type {MouseEvent} */ e ) => { 				e.preventDefault(); 				this.destroy( mainElement ); 				addPortlet(); 			} );  			const controlsWrapper = new OO.ui.ButtonGroupWidget( { 				items: [ 					buttonMinimise, 					buttonKill 				] 			} ); 			controlsWrapper.$element[ 0 ].style.float = 'right'; 			controls.appendChild( controlsWrapper.$element[ 0 ] );  			mainElement.appendChild( controls );  			// # Wrapper 			const wrapper = document.createElement( 'div' ); 			wrapper.classList.add( 'voy-voyagedata-wrapper' ); 			mainElement.appendChild( wrapper );  			// ## ProgressBar 			const progressBar = new OO.ui.ProgressBarWidget(); 			progressBar.$element.hide(); 			wrapper.appendChild( progressBar.$element[ 0 ] );  			// ## Page Main 			const pageMain = document.createElement( 'div' ); 			pageMain.classList.add( 'voy-voyagedata-page-main' ); 			wrapper.appendChild( pageMain );  			// ## MapWrapper 			const mapWrapper = document.createElement( 'div' ); 			const kartoBox = mw.loader.require( 'ext.kartographer.box' ); 			const map = kartoBox.map( { 				allowFullScreen: true, 				alwaysInteractive: true, 				center: [ center.lat, center.long ], 				container: mapWrapper, 				toggleNearby: false, 				zoom: zoom 			} ); 			new ResizeObserver( () => { 				// Invalidate map, if wrapper was resized 				map.invalidateSize(); 			} ).observe( mapWrapper );  			// ### ListPanel 			const listPanel = document.createElement( 'div' ); 			listPanel.classList.add( 'voy-voyagedate-listpanel' ); 			listPanel.insertAdjacentHTML( 'beforeend', '<h3>VoyageData</h3>' ); 			pageMain.appendChild( listPanel ); 			pageMain.appendChild( mapWrapper );  			// #### ListWrapper 			const listWrapper = document.createElement( 'div' ); 			listWrapper.classList.add( 'voy-voyagedata-list' );  			// ##### List 			/** 			 * @type {Object.<string, HTMLElement>} 			 */ 			const itemMap = {}; 			const list = document.createElement( 'ul' ); 			listWrapper.appendChild( list ); 			listPanel.appendChild( listWrapper ); 			this.itemManager.on( VoyageData.ItemManager.Event.itemAdded, ( /** @type {VoyageData.Item} */ item ) => { 				const node = item.getListNode( this.itemManager ); 				itemMap[ item.uid ] = node; 				list.appendChild( node ); 			} ); 			this.itemManager.on( VoyageData.ItemManager.Event.itemResolved, ( /** @type {VoyageData.Item} */ item ) => { 				const oldItemElement = itemMap[ item.uid ]; 				const newNode = item.getListNode( this.itemManager ); 				itemMap[ item.uid ] = newNode; 				oldItemElement.parentNode.replaceChild( newNode, oldItemElement ); 			} ); 			this.itemManager.on( VoyageData.ItemManager.Event.itemResultsUpdated, ( /** @type {VoyageData.Item} */ item ) => { 				const itemElement = itemMap[ item.uid ]; 				item.updateResultList( this.itemManager, itemElement ); 			} ); 			for ( const item of this.itemManager.getItemsInArticle( true, false ) ) { 				this.itemManager.appendItem( item ); 			}  			// #### Buttons 			// ##### buttonByRadius 			const buttonByRadius = new OO.ui.ButtonWidget( { 				icon: 'mapPin', 				label: mw.msg( 'voy-voyagedata-byradius-label' ), 				title: mw.msg( 'voy-voyagedata-byradius-description' ) 			} ); 			buttonByRadius.$element.on( 'click', async ( /** @type {MouseEvent} */ e ) => { 				e.preventDefault();  				// TODO: Wert merken 				const radius = parseFloat( await OO.ui.prompt( mw.msg( 'voy-voyagedata-byradius-prompt-radius' ), { 					textInput: { 						value: String( this.#settings.getInitialRadius() ) 					} 				} ) );  				if ( !isNaN( radius ) ) { 					const sparqlQuery = VoyageData.sparqlQueryRadius( this.itemManager.getItemsInArticle( true, true ), mw.config.get( 'wgUserLanguage' ), getLangLocal(), Array.from( new Set( getWikidataIdsArticle().concat( Array.from( this.wikidataIdsLoaded ) ) ) ), this.#settings.getWdClassWhitelist() ?? [], radius, VoyageData.SPARQL_LIMIT, '' );  					this.wdqsRequest( map, sparqlQuery, e.shiftKey ); 				} 			} ); 			this.#registerEventListener( window, 'keydown', ( /** @type {KeyboardEvent} */ e ) => { 				if ( e.key === 'Shift' ) { 					buttonByRadius.setIcon( 'logoWikidata' ); 				} 			} ); 			this.#registerEventListener( window, 'keyup', ( /** @type {KeyboardEvent} */ e ) => { 				if ( e.key === 'Shift' ) { 					buttonByRadius.setIcon( 'mapPin' ); 				} 			} );  			// ##### buttonByVisBbox 			const buttonByVisBbox = new OO.ui.ButtonWidget( { 				icon: 'fullScreen', 				label: mw.msg( 'voy-voyagedata-bybbox-label' ), 				title: mw.msg( 'voy-voyagedata-bybbox-description' ) 			} ); 			buttonByVisBbox.$element.on( 'click', ( /** @type {MouseEvent} */ e ) => { 				e.preventDefault(); 				const bounds = map.getBounds(); 				const sw = { 					lat: bounds.getSouth(), 					long: bounds.getWest() 				}; 				const ne = { 					lat: bounds.getNorth(), 					long: bounds.getEast() 				};  				const sparqlQuery = VoyageData.sparqlQueryBox( sw, ne, mw.config.get( 'wgUserLanguage' ), getLangLocal(), Array.from( new Set( getWikidataIdsArticle().concat( Array.from( this.wikidataIdsLoaded ) ) ) ), this.#settings.getWdClassWhitelist() ?? [], VoyageData.SPARQL_LIMIT, '' ); 				this.wdqsRequest( map, sparqlQuery, e.shiftKey ); 			} ); 			this.#registerEventListener( window, 'keydown', ( /** @type {KeyboardEvent} */ e ) => { 				if ( e.key === 'Shift' ) { 					buttonByVisBbox.setIcon( 'logoWikidata' ); 				} 			} ); 			this.#registerEventListener( window, 'keyup', ( /** @type {KeyboardEvent} */ e ) => { 				if ( e.key === 'Shift' ) { 					buttonByVisBbox.setIcon( 'fullScreen' ); 				} 			} );  			// ##### buttonNameStrict 			const buttonNameStrict = new OO.ui.ButtonWidget( { 				icon: 'largerText', 				label: mw.msg( 'voy-voyagedata-bynamestrict-label' ), 				title: mw.msg( 'voy-voyagedata-bynamestrict-description' ) 			} ); 			buttonNameStrict.$element.on( 'click', async ( /** @type {MouseEvent} */ e ) => { 				e.preventDefault();  				const suffix = e.shiftKey ? ( prompt( 'Bitte gib eine Suffix an.', mw.config.get( 'wgTitle' ) ) ?? '' ) : ''; // TODO: l10n  				for ( const item of this.itemManager.getItemsInArticle( true, false ) ) { 					const bestNameParameter = this.#settings.getNameParameterChain().map( ( nameParameter ) => { 						return item[ nameParameter ]; 					} ).find( ( value ) => { 						return value !== undefined && value !== null; 					} );  					if ( bestNameParameter !== undefined ) { 						// FIXME: Offset 						this.reportTaskStarted(); 						try { 							await this.searchRequest( item.uid, `${bestNameParameter}${suffix === '' ? '' : ` ${suffix}`}`, 0, false ); 						} catch ( ex ) { 							// TODO 						} finally { 							this.reportTaskFinished(); 						} 					} 				} 			} );  			// ##### buttonNameQuery 			const buttonNameQuery = new OO.ui.ButtonWidget( { 				icon: 'largerText', 				label: mw.msg( 'voy-voyagedata-bynamequery-label' ), 				title: mw.msg( 'voy-voyagedata-bynamequery-description' ) 			} ); 			buttonNameQuery.$element.on( 'click', async ( /** @type {MouseEvent} */ e ) => { 				e.preventDefault();  				const suffix = e.shiftKey ? ( prompt( 'Bitte gib eine Suffix an.', mw.config.get( 'wgTitle' ) ) ?? '' ) : ''; // TODO: l10n  				for ( const item of this.itemManager.getItemsInArticle( true, false ) ) { 					const bestNameParameter = this.#settings.getNameParameterChain().map( ( nameParameter ) => { 						return item[ nameParameter ]; 					} ).find( ( value ) => { 						return value !== undefined && value !== null; 					} );  					if ( bestNameParameter !== undefined ) { 						// FIXME: Offset 						this.reportTaskStarted(); 						try { 							await this.searchRequest( item.uid, `${bestNameParameter}${suffix === '' ? '' : ` ${suffix}`}`, 0, true ); 						} catch ( ex ) { 							// TODO 						} finally { 							this.reportTaskFinished(); 						} 					} 				} 			} );  			// ##### buttonConfig 			const buttonConfig = new OO.ui.ButtonWidget( { 				icon: 'settings', 				title: mw.msg( 'voy-voyagedata-settings-label' ) 			} ); 			buttonConfig.$element.on( 'click', ( /** @type {MouseEvent} */ e ) => { 				e.preventDefault(); 				this.#settings.openConfigWindow(); 			} );  			// ##### buttonPin 			const buttonPin = new OO.ui.ButtonWidget( { 				classes: [ 'voy-voyagedata-only-banner' ], 				icon: 'pushPin', 				title: mw.msg( 'voy-voyagedata-pin-label' ) 			} ); 			buttonPin.$element.on( 'click', ( /** @type {MouseEvent} */ e ) => { 				e.preventDefault(); 				if ( e.shiftKey ) { 					this.destroy( mainElement ); 					addPortlet(); 				} else { 					if ( wrapper.classList.contains( 'voy-voyagedata-nosticky' ) ) { 						wrapper.classList.remove( 'voy-voyagedata-nosticky' ); 					} else { 						wrapper.classList.add( 'voy-voyagedata-nosticky' ); 					} 				} 			} ); 			this.#registerEventListener( window, 'keydown', ( /** @type {KeyboardEvent} */ e ) => { 				if ( e.key === 'Shift' ) { 					buttonPin.setFlags( { 						destructive: true 					} ); 					buttonPin.setIcon( 'close' ); 				} 			} ); 			this.#registerEventListener( window, 'keyup', ( /** @type {KeyboardEvent} */ e ) => { 				if ( e.key === 'Shift' ) { 					buttonPin.setFlags( { 						destructive: false 					} ); 					buttonPin.setIcon( 'pushPin' ); 				} 			} );  			const buttonWrapper = new OO.ui.ButtonGroupWidget( { 				items: [ 					buttonByRadius, 					buttonByVisBbox, 					buttonNameStrict, 					buttonNameQuery, 					buttonConfig, 					buttonPin 				] 			} ); 			listPanel.appendChild( buttonWrapper.$element[ 0 ] );  			document.getElementById( 'bodyContent' ).insertAdjacentElement( 'beforebegin', mainElement );  			this.#setupStickynessWatcher( mainElement, wrapper );  			return { map: map, taskProgressBar: progressBar, wrapper: mainElement }; 		}  		/** 		 * @param {any} map 		 * @param {string} sparqlQuery 		 * @param {boolean} openWdqs 		 * @return {Promise<boolean>} 		 */ 		async wdqsRequest( map, sparqlQuery, openWdqs ) { 			if ( openWdqs ) { 				openSparqlQuery( sparqlQuery ); 				return false; 			} else { 				this.reportTaskStarted();  				try { 					const data = await ( await VoyageData.#fetchPost( 'https://query.wikidata.org/sparql', { 						query: sparqlQuery.replace( /\n\t*/g, ' ' ) 					}, { 						cache: 'no-cache', 						headers: { 							Accept: 'application/json, text/plain, */*' 						}, 						mode: 'cors' 					} ) ).json();  					const markersWikidata = [];  					for ( const val of data.results.bindings ) { 						const coordinates = val.location?.value.match( /Point\(([^ ]+) ([^)]+)\)/i ) ?? null; 						const wdid = val.item.value.match( /(Q[0-9]+)$/ )[ 1 ];  						const labelUser = val.labelUser?.value; 						const labelEn = val.labelEn?.value; 						const labelLocal = val.labelLocal?.value; 						const ref = val.ref !== undefined ? parseInt( val.ref.value ) : null;  						if ( coordinates !== null && !this.wikidataIdsInMap.has( wdid ) ) { 							this.wikidataIdsInMap.add( wdid ); 							markersWikidata.push( { 								type: 'Feature', 								properties: { 									'marker-color': '#9a0000', 									'marker-size': 'medium', 									'marker-symbol': 'marker', 									title: labelUser ?? labelEn ?? labelLocal ?? mw.msg( 'voy-voyagedata-no-name-placeholder' ), 									description: `<a href="${val.item.value}" target="_blank" onclick="VoyageData.copyFromMarkerToClipboard( event, '${wdid}');">${wdid}</a>` 								}, 								geometry: { 									type: 'Point', 									coordinates: [ 										parseFloat( coordinates[ 1 ] ), 										parseFloat( coordinates[ 2 ] ) 									] 								} 							} ); 						} 						this.itemManager.registerItemResult( ref, new VoyageData.ItemResult( wdid, labelUser, labelEn, labelLocal, val.description?.value, coordinates !== null ? { 							lat: parseFloat( coordinates[ 1 ] ), 							long: parseFloat( coordinates[ 2 ] ) 						} : null ) ); 						this.wikidataIdsLoaded.add( wdid ); 					} 					map.addGeoJSONLayer( markersWikidata, { 						name: 'WD' 					} ); 					return true; 				} catch ( ex ) { 					mw.notify( mw.msg( 'voy-voyagedata-search-failed' ), { 						tag: 'voy-voyagedata-search-failed', 						title: 'VoyageData', 						type: 'error' 					} ); 					throw ex; 				} finally { 					this.reportTaskFinished(); 				} 			} 		}  		/** 		 * Executes a GET-request via `fetch`. 		 * @param {string} urlString  		 * @param {Object.<string, string>} searchParams  		 * @returns URL-string. 		 */ 		static async #fetchGet( urlString, searchParams = {}, config = {} ) { 			const url = new URL( urlString ); 		 			for ( const [ key, value ] of Object.entries( searchParams ?? {} ) ) { 				url.searchParams.append( key, value ); 			} 		 			const fullUrlString = url.href;  			return fetch( fullUrlString, { 				...{ 					method: 'GET' 				}, 				...(config ?? {}) 			} ); 		}  		/** 		 * Executes a POST-request via `fetch`. 		 * @param {string} urlString  		 * @param {Object.<string, string>} searchParams  		 * @returns URL-string. 		 */ 		static async #fetchPost( urlString, searchParams = {}, config = {} ) { 			const urlLSearchParams = new URLSearchParams(); 		 			for ( const [ key, value ] of Object.entries( searchParams ?? {} ) ) { 				urlLSearchParams.append( key, value ); 			} 	 			return await fetch( urlString, { 				...{ 					body: urlLSearchParams, 					method: 'POST' 				}, 				...(config ?? {}) 			} ); 		}  		/** 		 * @param {EventTarget} subject 		 * @param {string} event 		 * @param {EventListenerOrEventListenerObject} listener 		 */ 		#registerEventListener( subject, event, listener ) { 			this.#eventListeners.push( [ subject, event, listener ] ); 			subject.addEventListener( event, listener ); 		}  		/** 		 * @param {HTMLElement} mainElement 		 * @param {HTMLElement} wrapperElement 		 */ 		#setupStickynessWatcher( mainElement, wrapperElement ) { 			const that = this; 			window.addEventListener( 'resize', setStickyness ); 			document.addEventListener( 'scroll', setStickyness );  			new ResizeObserver( setStickyness ).observe( wrapperElement ); 			setStickyness();  			function setStickyness() { 				let topTriggerOffset = 0;  				if ( document.body.classList.contains( 'skin-timeless' ) ) { 					topTriggerOffset = Math.max( 0, document.getElementById( 'mw-header-hack' ).getBoundingClientRect().top ); 				}  				const mainClientRect = mainElement.getBoundingClientRect(); 				const maxHeight = window.innerHeight - topTriggerOffset; 				wrapperElement.style.maxHeight = `${maxHeight}px`;  				if ( mainClientRect.top <= topTriggerOffset && that.#settings.getLayout() === 'banner' ) { 					wrapperElement.classList.add( 'voy-voyagedata-sticky' ); 					wrapperElement.style.top = `${Math.floor( topTriggerOffset )}px`; 					wrapperElement.style.width = `${mainElement.clientWidth}px`; 					mainElement.style.height = `${wrapperElement.clientHeight}px`; 				} else { 					wrapperElement.classList.remove( 'voy-voyagedata-sticky' ); 					wrapperElement.style.top = null; 					wrapperElement.style.width = null; 					mainElement.style.height = null; 				} 			} 		} 	}  	/** 	 * @class 	 */ 	VoyageData.Bbox = class { 		/** 		 * @property {number} 		 */ 		n = null;  		/** 		 * @property {number} 		 */ 		e = null;  		/** 		 * @property {number} 		 */ 		s = null;  		/** 		 * @property {number} 		 */ 		w = null;  		/** 		 * @param {number} n 		 * @param {number} e 		 * @param {number} s 		 * @param {number} w 		 */ 		constructor( n, e, s, w ) { 			this.n = n; 			this.e = e; 			this.s = s; 			this.w = w; 		}  		/** 		 * @param {Coordinate[]} coordinates 		 * @return {VoyageData.Bbox} 		 */ 		static containCoordinates( coordinates ) { 			const bbox = new VoyageData.Bbox( null, null, null, null ); 			for ( const coordinate of coordinates ) { 				bbox.extend( coordinate ); 			} 			return bbox; 		}  		/** 		 * @return {Coordinate} 		 */ 		center() { 			return { 				lat: ( this.n + this.s ) / 2, 				long: ( this.w + this.e ) / 2 			}; 		}  		/** 		 * @param {Coordinate} coordinate 		 */ 		extend( { lat, long } ) { 			if ( this.n === null || this.n < lat ) { 				this.n = lat; 			} 			if ( this.s === null || this.s > lat ) { 				this.s = lat; 			}  			if ( this.w === null || this.w > long ) { 				this.w = long; 			} 			if ( this.e === null || this.e < long ) { 				this.e = long; 			} 		}  		/** 		 * @param {number} extendBy 		 */ 		pad( extendBy ) { 			this.n += extendBy; 			this.e += extendBy; 			this.s -= extendBy; 			this.w -= extendBy; 		} 	};  	/** 	 * @class 	 */ 	VoyageData.Item = class { 		/** 		 * @property {Coordinate} 		 */ 		coordinates = null;  		/** 		 * @property {HTMLElement} 		 */ 		element = null;  		/** 		 * @property {string} 		 */ 		name = null;  		/** 		 * @property {offset} 		 */ 		offset = 0;  		/** 		 * @property {string} 		 */ 		selectedWdid = null;  		/** 		 * @property {number} 		 */ 		uid = null;  		/** 		 * @param {string} name 		 * @param {string} nameLocal 		 * @param {Coordinate} coordinates 		 * @param {HTMLElement} element 		 * @param {number} uid 		 */ 		constructor( name, nameLocal, coordinates, element, uid ) { 			this.coordinates = coordinates; 			this.element = element; 			this.name = name; 			this.nameLocal = nameLocal; 			this.uid = uid; 		}  		/** 		 * @param {VoyageData.ItemManager} itemManager 		 * @param {Object.<string, VoyageData.ItemResult>} itemResults 		 * @return {HTMLElement} 		 */ 		getItemResultsNode( itemManager, itemResults ) { 			const that = this; 			const ul = document.createElement( 'ul' ); 			ul.classList.add( 'voy-voyagedata-itemresults' ); 			if ( this.selectedWdid !== null ) { 				ul.style.display = 'none'; 			} 			if ( itemResults !== null ) { 				for ( const wdid of Object.keys( itemResults ) ) { 					ul.appendChild( itemResults[ wdid ].getListNode( resultChosen ) ); 				} 			} 			return ul;  			/** 			 * @param {VoyageData.ItemResult} itemResult 			 */ 			async function resultChosen( itemResult ) { 				if ( that.uid !== -1 ) { 					// Orphan group cannot be resolved 					that.selectedWdid = itemResult.wdid; 				} 				itemManager.resolveItem( that ); 				try { 					await navigator.clipboard.writeText( itemResult.wdid ); 					mw.notify( mw.msg( 'voy-voyagedata-clipboard-wdid-success' ), { 						tag: 'voy-voyagedata-clipboard', 						title: 'VoyageData', 						type: 'success' 					} ); 				} catch ( ex ) { 					console.error( ex ); 					mw.notify( mw.msg( 'voy-voyagedata-clipboard-wdid-fail' ), { 						tag: 'voy-voyagedata-clipboard', 						title: 'VoyageData', 						type: 'error' 					} ); 				} 			} 		}  		/** 		 * @param {VoyageData.ItemManager} itemManager 		 * @return {HTMLElement} 		 */ 		getListNode( itemManager ) { 			const li = document.createElement( 'li' ); 			const results = this.getResults( itemManager ); 			if ( this.selectedWdid !== null ) { 				li.classList.add( 'voy-voyagedata-resolved' ); 			} 			if ( results === null || Object.keys( results ).length <= 0 ) { 				li.classList.add( 'voy-voyagedata-noresults' ); 			}  			let nameFragment = null; 			if ( this.nameLocal !== null ) { 				nameFragment = `<span class="voy-voyagedata-term-name" title="name" style="font-size:.8em">${mw.html.escape( this.name ?? '-' )}</span> <span style="font-weight:bold">/</span><span class="voy-voyagedata-term-name-local" title="name-local">${mw.html.escape( this.nameLocal )}</span>`; 			} else if ( this.name === null && this.nameLocal === null ) { 				nameFragment = mw.msg( 'voy-voyagedata-no-name-placeholder' ); 			} else { 				nameFragment = `<span class="voy-voyagedata-term-name" title="name">${mw.html.escape( this.name )}</span> <span style="font-weight:bold">/</span><span title="name-local" style="font-size:.8em">-</span>`; 			}  			li.innerHTML = `<span class="voy-voyagedata-term">${nameFragment}</span> ${this.coordinates !== null ? '<span title="vCard hat Koordinaten">[🌐]</span>' : ''}[<a href="#jumpto" title="Zu Marker in Artikel springen">⚓</a>][<a href="#collapse" title="Suchergebnisse umschalten">👁️</a>]<span class="action-loadmore" style="display:none">[<a href="#loadmore" title="Nach weiteren Elementen anhand des Namens suchen">🔍</a>]</span>`;  			li.querySelector( '.voy-voyagedata-term-name' )?.addEventListener( 'click', async ( /** @type {MouseEvent} */ e ) => { 				e.preventDefault(); 				if ( e.ctrlKey ) { 					window.open( `https://www.wikidata.org/w/index.php?title=Special:Search&search=${encodeURIComponent( this.name )}&ns0=1`, '_blank' ); 				} else { 					try { 						await navigator.clipboard.writeText( this.name ); 						mw.notify( mw.msg( 'voy-voyagedata-clipboard-name-success' ), { 							tag: 'voy-voyagedata-clipboard', 							title: 'VoyageData', 							type: 'success' 						} ); 					} catch ( ex ) { 						console.error( ex ); 						mw.notify( mw.msg( 'voy-voyagedata-clipboard-name-fail' ), { 							tag: 'voy-voyagedata-clipboard', 							title: 'VoyageData', 							type: 'error' 						} ); 					} 				} 			} ); 			li.querySelector( '.voy-voyagedata-term-name-local' )?.addEventListener( 'click', async ( /** @type {MouseEvent} */ e ) => { 				e.preventDefault(); 				if ( e.ctrlKey ) { 					window.open( `https://www.wikidata.org/w/index.php?title=Special:Search&search=${encodeURIComponent( this.nameLocal )}&ns0=1`, '_blank' ); 				} else { 					try { 						await navigator.clipboard.writeText( this.nameLocal ); 						mw.notify( mw.msg( 'voy-voyagedata-clipboard-name-success' ), { 							tag: 'voy-voyagedata-clipboard', 							title: 'VoyageData', 							type: 'success' 						} ); 					} catch ( ex ) { 						console.error( ex ); 						mw.notify( mw.msg( 'voy-voyagedata-clipboard-name-fail' ), { 							tag: 'voy-voyagedata-clipboard', 							title: 'VoyageData', 							type: 'error' 						} ); 					} 				} 			} ); 			li.querySelector( 'a[href="#collapse"]' ).addEventListener( 'click', ( e ) => { 				e.preventDefault(); 				$( li ).find( '.voy-voyagedata-itemresults' ).toggle(); 			} ); 			li.querySelector( 'a[href="#jumpto"]' ).addEventListener( 'click', ( e ) => { 				e.preventDefault(); 				window.scrollTo( 0, this.element.offsetTop ); 			} ); 			li.querySelector( 'a[href="#loadmore"]' ).addEventListener( 'click', ( e ) => { 				e.preventDefault(); 				// TODO: 			} ); 			li.querySelector( '.voy-voyagedata-term' ).addEventListener( 'click', ( e ) => { 				e.preventDefault(); 				// TODO: 			} ); 			li.appendChild( this.getItemResultsNode( itemManager, results ) ); 			return li; 		}  		/** 		 * @param {VoyageData.ItemManager} itemManager 		 * @return {Object.<string, VoyageData.ItemResult>} 		 */ 		getResults( itemManager ) { 			return itemManager.getResults( this.uid ); 		}  		/** 		 * @param {VoyageData.ItemManager} itemManager 		 * @param {HTMLElement} itemElement 		 */ 		updateResultList( itemManager, itemElement ) { 			const results = this.getResults( itemManager ); 			const oldItemResults = itemElement.querySelector( '.voy-voyagedata-itemresults' ); 			const newItemResults = this.getItemResultsNode( itemManager, results ); 			oldItemResults.parentElement.replaceChild( newItemResults, oldItemResults );  			if ( results === null || Object.keys( results ).length <= 0 ) { 				itemElement.classList.add( 'voy-voyagedata-noresults' ); 			} else { 				itemElement.classList.remove( 'voy-voyagedata-noresults' ); 			} 		} 	};  	/** 	 * @class 	 */ 	VoyageData.ItemManager = class { 		/** 		 * @enum {ItemManagerEvent} 		 */ 		static Event = { 			itemAdded: 'item_added', 			itemResolved: 'item_resolved', 			itemResultsUpdated: 'item_results_updated' 		};  		/** 		 * @property {number} 		 */ 		static itemCounter = 0;  		/** 		 * @property {Object.<number, VoyageData.Item>} 		 */ 		items = {};  		/** 		 * @property {Object.<string, Array<Function>>} 		 */ 		subscriptions = {};  		/** 		 * @property {Object.<string, Object.<string, VoyageData.ItemResult>>} 		 */ 		#results = {};  		/** 		 * @param {VoyageData.Item} item 		 */ 		appendItem( item ) { 			this.items[ item.uid ] = item;  			this.#trigger( VoyageData.ItemManager.Event.itemAdded, item ); 		}  		/** 		 * @param {boolean} filterNoWikidata 		 * @param {boolean} filterCoordinates 		 * @return {VoyageData.Item[]} 		 */ 		getItemsInArticle( filterNoWikidata, filterCoordinates ) { 			let container = document; 			if ( document.querySelector( '.ext-WikiEditor-realtimepreview-preview' ) !== null ) { 				container = document.querySelector( '.ext-WikiEditor-realtimepreview-preview' ); 			}  			let vcards = Array.from( container.querySelectorAll( `.vcard${filterNoWikidata ? ':not([data-wikidata])' : ''}` ) );  			if ( filterCoordinates ) { 				// Filter out vCards without coordinates 				vcards = vcards.filter( ( vcard ) => { 					return vcard.querySelector( 'a[data-lat][data-lon]' ); 				} ); 			}  			return vcards.map( ( /** @type {HTMLElement} */ vcard ) => { 				let uid = null; 				if ( 'wvVoyagedataId' in vcard.dataset ) { 					uid = parseInt( vcard.dataset.wvVoyagedataId ); 				} else { 					uid = VoyageData.ItemManager.itemCounter++; 					vcard.dataset.wvVoyagedataId = String( uid ); 				}  				let coordinates = null; 				const coordinateElement = vcard.querySelector( 'a[data-lat][data-lon]' ); 				if ( coordinateElement !== null ) { 					coordinates = { 						lat: parseFloat( /** @type {HTMLElement} */ ( coordinateElement ).dataset.lat ), 						long: parseFloat( /** @type {HTMLElement} */ ( coordinateElement ).dataset.lon ) 					}; 				}  				const item = new VoyageData.Item( /** @type {HTMLElement} */ ( vcard.closest( '[data-name]' ) )?.dataset.name ?? null, /** @type {HTMLElement} */ ( vcard.closest( '[data-name-local]' ) )?.dataset.nameLocal ?? null, coordinates, vcard, uid ); 				return item; 			} ); 		}  		/** 		 * @param {number} itemUid 		 * @return {Object.<string, VoyageData.ItemResult>} 		 */ 		getResults( itemUid ) { 			if ( this.#results[ itemUid ] === undefined ) { 				return null; 			} else { 				return this.#results[ itemUid ]; 			} 		}  		/** 		 * @param {ItemManagerEvent} event 		 * @param {Function} callback 		 */ 		on( event, callback ) { 			if ( this.subscriptions[ event ] === undefined ) { 				this.subscriptions[ event ] = []; 			} 			this.subscriptions[ event ].push( callback ); 		}  		/** 		 * @param {number} itemUid 		 * @param {number} newOffset 		 */ 		setOffset( itemUid, newOffset ) { 			this.items[ itemUid ].offset = newOffset; 		}  		/** 		 * @param {number} itemUid 		 * @param {VoyageData.ItemResult} itemResult 		 */ 		registerItemResult( itemUid, itemResult ) { 			this.registerItemResults( itemUid, [ itemResult ] ); 		}  		/** 		 * @param {number} itemUid 		 * @param {VoyageData.ItemResult[]} itemResults 		 */ 		registerItemResults( itemUid, itemResults ) { 			const itemUidNum = itemUid === null ? -1 : itemUid;  			if ( this.#results[ itemUidNum ] === undefined ) { 				this.#results[ itemUidNum ] = {}; 			} 			for ( const itemResult of itemResults ) { 				if ( this.#results[ itemUidNum ][ itemResult.wdid ] === undefined ) { 					this.#results[ itemUidNum ][ itemResult.wdid ] = itemResult; 				} else { 					// FIXME: Merge 					this.#results[ itemUidNum ][ itemResult.wdid ] = itemResult; 				} 			}  			if ( itemUidNum === -1 && this.items[ itemUidNum ] === undefined ) { 				const orphansItem = new VoyageData.Item( mw.msg( 'voy-voyagedata-orphans' ), null, null, null, itemUidNum ); 				this.items[ itemUidNum ] = orphansItem; 				this.#trigger( VoyageData.ItemManager.Event.itemAdded, orphansItem ); 			}  			this.#trigger( VoyageData.ItemManager.Event.itemResultsUpdated, this.items[ itemUidNum ] ); 		}  		/** 		 * @param {VoyageData.Item} item 		 */ 		resolveItem( item ) { 			this.#trigger( VoyageData.ItemManager.Event.itemResolved, item ); 		}  		/** 		 * @param {ItemManagerEvent} event 		 * @param {any} args 		 */ 		#trigger( event, ...args ) { 			if ( this.subscriptions[ event ] !== undefined ) { 				for ( const callback of this.subscriptions[ event ] ) { 					callback.call( this, ...args ); 				} 			} 		} 	};  	/** 	 * @class 	 */ 	VoyageData.ItemResult = class { 		/** 		 * @property {string} 		 */ 		wdid = null;  		/** 		 * @property {string} 		 */ 		labelUser = null;  		/** 		 * @property {string} 		 */ 		labelEn = null;  		/** 		 * @property {string} 		 */ 		labelLocal = null;  		/** 		 * @property {string} 		 */ 		description = null;  		/** 		 * @property {Coordinate} 		 */ 		coordinates = null;  		/** 		 * @param {string} wdid 		 * @param {string} labelUser 		 * @param {string} labelEn 		 * @param {string} labelLocal 		 * @param {string} description 		 * @param {Coordinate} coordinates 		 */ 		constructor( wdid, labelUser, labelEn, labelLocal, description, coordinates ) { 			this.wdid = wdid; 			this.labelUser = labelUser ?? null; 			this.labelEn = labelEn ?? null; 			this.labelLocal = labelLocal ?? null; 			this.description = description ?? null; 			this.coordinates = coordinates ?? null; 		}  		/** 		 * @param {Function} selectionCallback 		 * @return {HTMLElement} 		 */ 		getListNode( selectionCallback ) { 			const labels = [ 				[ this.labelUser, mw.config.get( 'wgUserLanguage' ) ], 				[ this.labelEn, 'en' ], 				[ this.labelLocal, getLangLocal() ] 			].map( ( [ label, lang ], i ) => { 				return `<span style="${i !== 0 ? 'font-size:.8em;' : ''}" title="${lang}">${label === null ? '-' : mw.html.escape( label )}</span>`; 			} ).join( ' <span style="font-weight:bold">/</span>' );  			const li = document.createElement( 'li' ); 			li.innerHTML = `<li data-entity="${this.wdid}"><span class="voy-voyagedata-label">${labels}</span> (<a class="voy-voyagedata-wdid" href="https://www.wikidata.org/wiki/${this.wdid}" rel="nofollow noopener">${this.wdid}</a>)${this.description !== null ? `<br/><i class="voy-voyagedata-description">${mw.html.escape( this.description )}</i>` : ''}</li>`; 			li.querySelector( '.voy-voyagedata-wdid' ).addEventListener( 'click', ( /** @type {MouseEvent} */ e ) => { 				if ( !e.ctrlKey ) { 					e.preventDefault(); 					selectionCallback( this ); 				} 			} ); 			return li; 		} 	};  	/** 	 * @class 	 */ 	VoyageData.Queue = class { 		/** 		 * @property {number} 		 */ 		max = 0;  		/** 		 * @property {number} 		 */ 		nextPointer = 0;  		/** 		 * @property {number} 		 */ 		performing = 0;  		/** 		 * @property {[Promise, Function, Function][]} 		 */ 		tasks = [];  		/** 		 * @param {number} [max=5] 		 */ 		constructor( max = 5 ) { 			this.max = max || 5; 			this.nextPointer = 0; 			this.performing = 0; 		}  		/** 		 * @template T 		 * @param {Promise<T>} task 		 * @return {Promise<T>} 		 */ 		enqueue( task ) { 			return new Promise( ( resolve, reject ) => { 				this.tasks.push( [ task, resolve, reject ] ); 				this.work(); 			} ); 		}  		work() { 			if ( this.max > this.performing ) { 				if ( this.tasks[ this.nextPointer ] === undefined ) { 					this.nextPointer = 0; 					this.tasks = []; 					this.performing = 0; 				} else { 					const next = this.tasks[ this.nextPointer ]; 					this.nextPointer += 1; 					this.#executeTask( next ); 				} 			} 		}  		#executeTask( [ task, resolve, reject ] ) { 			this.performing += 1; 			task().then( () => { 				resolve(); 				this.#finishTask(); 			}, () => { 				reject(); 				this.#finishTask(); 			} ); 		}  		#finishTask() { 			this.performing -= 1; 			this.work(); 		} 	};  	/** 	 * @class 	 */ 	VoyageData.Settings = class {  		/** 		 * @type {ConfigWindow} 		 */ 		#configWindow = null;  		/** 		 * @type {OO.ui.TextInputWidget} 		 */ 		#initialRadius = null;  		/** 		 * @type {OO.ui.MenuTagMultiselectWidget} 		 */ 		#inputNameParameterChain = null;  		/** 		 * @type {OO.ui.MenuTagMultiselectWidget} 		 */ 		#inputWdClassBlacklist = null;  		/** 		 * @type {OO.ui.MenuTagMultiselectWidget} 		 */ 		#inputWdClassWhitelist = null;  		/** 		 * @type {string} 		 */ 		#settingInitialRadius = mw.user.options.get( 'userjs-voy-voyagedata-initialRadius' ) ?? '0.05';  		/** 		 * @type {string} 		 */ 		#settingLayout = mw.user.options.get( 'userjs-voy-voyagedata-layout' ) === 'corner' ? 'corner' : 'banner';  		/** 		 * @type {string} 		 */ 		#settingNameParameterChain = [ 'nameLocal', 'name' ];  		/** 		 * @type {string[]} 		 */ 		#settingWdClassBlacklist = null;  		/** 		 * @type {string[]} 		 */ 		#settingWdClassWhitelist = null;  		/** 		 * @type {OO.ui.WindowManager} 		 */ 		#windowManager = null;  		constructor() { 			this.#settingWdClassBlacklist = VoyageData.WIKIDATA_CLASS_BLACKLIST ?? null; 			this.#settingWdClassWhitelist = VoyageData.WIKIDATA_CLASS_WHITELIST ?? null; 		}  		/** 		 * @return {string} 		 */ 		getInitialRadius() { 			return this.#settingInitialRadius; 		}  		/** 		 * @return {string} 		 */ 		getLayout() { 			return this.#settingLayout; 		}  		/** 		 * @return {string} 		 */ 		getNameParameterChain() { 			return this.#settingNameParameterChain; 		}  		/** 		 * @return {string[]} 		 */ 		getWdClassBlacklist() { 			return this.#settingWdClassBlacklist; 		}  		/** 		 * @param {string[]} newValue 		 */ 		setWdClassBlacklist( newValue ) { 			this.#settingWdClassBlacklist = newValue; 		}  		/** 		 * @return {string[]} 		 */ 		getWdClassWhitelist() { 			return this.#settingWdClassWhitelist; 		}  		/** 		 * @param {string[]} newValue 		 */ 		setWdClassWhitelist( newValue ) { 			this.#settingWdClassWhitelist = newValue; 		}  		openConfigWindow() { 			if ( this.#configWindow === null ) { 				this.#setupConfigWindow(); 			}  			this.#loadSettings(); 			this.#windowManager.openWindow( this.#configWindow ); 		}  		async #loadSettings() { 			this.#initialRadius.setValue( this.#settingInitialRadius ?? '0.05' ); 			this.#inputWdClassBlacklist.setValue( this.#settingWdClassBlacklist ?? [] ); 			this.#inputWdClassWhitelist.setValue( this.#settingWdClassWhitelist ?? [] ); 		}  		async #saveSettings() { 			if ( this.#initialRadius.getValue() !== this.#settingInitialRadius ) { 				this.#settingInitialRadius = this.#initialRadius.getValue(); 				( new mw.Api() ).saveOption( 'userjs-voy-voyagedata-initialRadius', this.#settingInitialRadius ); 				mw.user.options.set( 'userjs-voy-voyagedata-initialRadius', this.#settingInitialRadius ); 			}  			this.#settingNameParameterChain = this.#inputNameParameterChain.getValue(); 			this.#settingWdClassBlacklist = this.#inputWdClassBlacklist.getValue(); 			this.#settingWdClassWhitelist = this.#inputWdClassWhitelist.getValue(); 		}  		#setupConfigWindow() { 			const that = this; 			const ConfigWindow = function ( config ) { 				ConfigWindow.super.call( this, config ); 			}; 			OO.inheritClass( ConfigWindow, OO.ui.ProcessDialog ); 			ConfigWindow.static.actions = [ 				{ action: 'save', label: mw.msg( 'voy-voyagedata-save' ), flags: [ 'primary', 'progressive' ] }, 				{ label: mw.msg( 'voy-voyagedata-discard' ), flags: 'safe' } 			]; 			ConfigWindow.static.name = 'configWindow'; 			ConfigWindow.static.title = mw.msg( 'voy-voyagedata-config-title' ); 			ConfigWindow.prototype.initialize = function () { 				ConfigWindow.super.prototype.initialize.call( this ); 				this.content = new OO.ui.PanelLayout( { padded: true, expanded: false } );  				that.#inputNameParameterChain = new OO.ui.MenuTagMultiselectWidget( { 					label: mw.msg( 'voy-voyagedata-nameparameterchain-label' ), 					options: [ 						{ 							data: 'name', 							label: 'name' 						}, 						{ 							data: 'nameLocal', 							label: 'name-local' 						} 					], 					selected: [ 						'nameLocal', 						'name' 					], 					title: mw.msg( 'voy-voyagedata-nameparameterchain-label' ) 				} );  				that.#inputWdClassBlacklist