Benutzer:Nw520/VoyageData.js

Aus Wikivoyage

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
  • Internet Explorer/Edge: Strg+F5 drücken oder Strg drücken und gleichzeitig Aktualisieren anklicken
  • Opera: Strg+F5
// <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 = new OO.ui.MenuTagMultiselectWidget( {
					allowArbitrary: true,
					label: mw.msg( 'voy-voyagedata-wdclassblacklist-label' ),
					menu: {
						items: VoyageData.WIKIDATA_CLASS_SUGGESTIONS.map( ( suggestion ) => {
							if ( typeof suggestion === 'string' ) {
								return new OO.ui.MenuSectionOptionWidget( {
									label: suggestion
								} );
							} else {
								const [ label, wdid ] = suggestion;
								const menuOptionWidget = new OO.ui.MenuOptionWidget( {
									data: wdid,
									label: label
								} );
								menuOptionWidget.$label.append( ` <small>(${wdid})</small>` );
								menuOptionWidget.$element.on( 'click', ( /** @type {MouseEvent} */ e ) => {
									if ( e.shiftKey ) {
										e.preventDefault();
										window.open( `https://www.wikidata.org/wiki/Special:EntityPage/${wdid}`, '_blank' );
									}
								} );
								return menuOptionWidget;
							}
						} )
					},
					title: mw.msg( 'voy-voyagedata-wdclassblacklist-label' )
				} );

				that.#inputWdClassWhitelist = new OO.ui.MenuTagMultiselectWidget( {
					allowArbitrary: true,
					label: mw.msg( 'voy-voyagedata-wdclasswhitelist-label' ),
					menu: {
						items: VoyageData.WIKIDATA_CLASS_SUGGESTIONS.map( ( suggestion ) => {
							if ( typeof suggestion === 'string' ) {
								return new OO.ui.MenuSectionOptionWidget( {
									label: suggestion
								} );
							} else {
								const [ label, wdid ] = suggestion;
								const menuOptionWidget = new OO.ui.MenuOptionWidget( {
									data: wdid,
									label: label
								} );
								menuOptionWidget.$label.append( ` <small>(${wdid})</small>` );
								menuOptionWidget.$element.on( 'click', ( /** @type {MouseEvent} */ e ) => {
									if ( e.shiftKey ) {
										e.preventDefault();
										window.open( `https://www.wikidata.org/wiki/Special:EntityPage/${wdid}`, '_blank' );
									}
								} );
								return menuOptionWidget;
							}
						} )
					},
					title: mw.msg( 'voy-voyagedata-wdclasswhitelist-label' )
				} );

				const inputCornerLayout = new OO.ui.ToggleSwitchWidget( {
					value: that.#settingLayout === 'corner'
				} );
				inputCornerLayout.on( 'change', ( /** @type {boolean} */ value ) => {
					const element = document.querySelector( '.voy-voyagedata' );
					if ( value ) {
						element.classList.remove( 'voy-voyagedata--banner' );
						element.classList.add( 'voy-voyagedata--corner' );
						that.#settingLayout = 'corner';
					} else {
						element.classList.remove( 'voy-voyagedata--corner' );
						element.classList.add( 'voy-voyagedata--banner' );
						that.#settingLayout = 'banner';
					}

					// Save
					( new mw.Api() ).saveOption( 'userjs-voy-voyagedata-layout', that.#settingLayout );
					mw.user.options.set( 'userjs-voy-voyagedata-layout', that.#settingLayout );
				} );

				that.#initialRadius = new OO.ui.TextInputWidget();

				const fieldsetMain = new OO.ui.FieldsetLayout();
				fieldsetMain.addItems( [
					new OO.ui.FieldLayout( that.#inputWdClassWhitelist, {
						label: mw.msg( 'voy-voyagedata-wdclasswhitelist-label' )
					} ),
					new OO.ui.FieldLayout( that.#inputWdClassBlacklist, {
						label: mw.msg( 'voy-voyagedata-wdclassblacklist-label' )
					} ),
					new OO.ui.FieldLayout( that.#inputNameParameterChain, {
						label: mw.msg( 'voy-voyagedata-nameparameterchain-label' )
					} ),
					new OO.ui.FieldLayout( inputCornerLayout, {
						label: mw.msg( 'voy-voyagedata-cornerlayout-label' )
					} )
				] );
				this.content.$element.append( fieldsetMain.$element );

				const fieldsetAdvanced = new OO.ui.FieldsetLayout( {
					label: mw.msg( 'voy-voyagedata-advancedsettings' )
				} );
				fieldsetAdvanced.addItems( [
					new OO.ui.FieldLayout( that.#initialRadius, {
						label: mw.msg( 'voy-voyagedata-initialradius-label' )
					} )
				] );
				this.content.$element.append( fieldsetAdvanced.$element );

				this.$body.append( this.content.$element );
			};
			ConfigWindow.prototype.getActionProcess = function ( action ) {
				if ( action ) {
					return new OO.ui.Process( async () => {
						await that.#saveSettings();
						this.close( { action: action } );
					} );
				}
				return ConfigWindow.super.prototype.getActionProcess.call( this, action );
			};
			this.#configWindow = new ConfigWindow( {
				size: 'large'
			} );

			this.#windowManager = new OO.ui.WindowManager();
			$( document.body ).append( this.#windowManager.$element );
			this.#windowManager.addWindows( [ this.#configWindow ] );
		}
	};

	function addPortlet() {
		const portlet = mw.util.addPortletLink( 'p-tb', '#', mw.msg( 'voy-voyagedata-portlet-load' ) );
		portlet.addEventListener( 'click', ( e ) => {
			e.preventDefault();

			portlet.remove();

			const voyageData = new VoyageData();
			voyageData.setup();
		} );
	}

	/**
	 * @param {Object} coordinate
	 * @param {number} coordinate.lat
	 * @param {number} coordinate.long
	 * @param {number} [maxLength=COORDINATES_MAX_LENGTH]
	 * @return {string}
	 */
	function coordinateToGeoLiteral( { lat, long }, maxLength = VoyageData.COORDINATES_MAX_LENGTH ) {
		return `"Point(${String( long ).substr( 0, maxLength )} ${String( lat ).substr( 0, maxLength )})"^^geo:wktLiteral`;
	}

	function fireHook() {
		mw.hook( 'voy.voyagedata.loaded' ).fire( VoyageData );
	}

	function getLangLocal() {
		if ( cachedLangLocal === null ) {
			if ( document.querySelectorAll( '.vcard[data-lang]' ).length > 0 ) {
				cachedLangLocal = /** @type {HTMLElement} */ ( document.querySelector( '.vcard[data-lang]' ) ).dataset.lang;
			} else {
				cachedLangLocal = null;
			}
		}
		return cachedLangLocal;
	}

	/**
	 * @param {boolean} [filterNoWikidata=true]
	 * @return {Object.<string, {name: string, items: Feature[]}>}
	 */
	function getMarkersForVcardsInArticle( filterNoWikidata = true ) {
		/** @type {Object.<string, {name: string, items: Feature[]}>} */
		const markersInArticleByGroup = {};

		let container = document;
		if ( document.querySelector( '.ext-WikiEditor-realtimepreview-preview' ) !== null ) {
			container = document.querySelector( '.ext-WikiEditor-realtimepreview-preview' );
		}

		for ( /** @type {HTMLElement} */ const element of container.querySelectorAll( `.vcard${filterNoWikidata ? ':not([data-wikidata])' : ''} a[data-lat][data-lon]` ) ) {
			/** @type {HTMLElement} */
			const vcard = element.closest( '.vcard' );

			const color = vcard.dataset.color;
			const lat = parseFloat( element.dataset.lat );
			const long = parseFloat( element.dataset.lon );
			const group = vcard.dataset.group !== undefined ? `wikivoyage-${vcard.dataset.group}` : 'wikivoyage';
			const groupDisplayName = vcard.dataset.groupTranslated !== undefined ? `WV: ${vcard.dataset.groupTranslated}` : 'WV';
			const name = vcard.dataset.name ?? mw.msg( 'voy-voyagedata-no-name-placeholder' );

			if ( markersInArticleByGroup[ group ] === undefined ) {
				// Create group, if needed
				markersInArticleByGroup[ group ] = {
					name: groupDisplayName,
					items: []
				};
			}
			markersInArticleByGroup[ group ].items.push( {
				type: 'Feature',
				properties: {
					'marker-color': shadeColor( color, 50 ),
					'marker-size': 'medium',
					'marker-symbol': 'marker',
					title: name
				},
				geometry: {
					type: 'Point',
					coordinates: [
						long,
						lat
					]
				}
			} );
		}

		return markersInArticleByGroup;
	}

	/**
	 * @return {string[]}
	 */
	function getWikidataIdsArticle() {
		return Array.from( document.querySelectorAll( '.vcard[data-wikidata]' ) ).map( ( /** @type {HTMLElement} */ element ) => {
			return element.dataset.wikidata;
		} );
	}

	function main() {
		if ( mw.config.get( 'wgNamespaceNumber' ) !== 0 ) {
			return;
		}

		setupStrings();
		addPortlet();
		fireHook();
	}

	/**
	 * @param {string} sparqlQuery
	 */
	function openSparqlQuery( sparqlQuery ) {
		window.open( `https://query.wikidata.org/#${encodeURIComponent( sparqlQuery )}`, '_blank' );
	}

	function setupStrings() {
		const lang = mw.config.get( 'wgUserLanguage' );
		mw.messages.set( Object.fromEntries( Object.keys( strings ).map( ( stringKey ) => {
			return [ stringKey, strings[ stringKey ][ lang ] ?? strings[ stringKey ].en ];
		} ) ) );
	}

	/**
	 * Set lightness of color.
	 *
	 * @see https://stackoverflow.com/a/13532993
	 * @param {string} color
	 * @param {number} percent
	 * @return {string}
	 */
	function shadeColor( color, percent ) {
		let r = parseInt( color.substring( 1, 3 ), 16 );
		let g = parseInt( color.substring( 3, 5 ), 16 );
		let b = parseInt( color.substring( 5, 7 ), 16 );

		r = Math.ceil( r * ( 100 + percent ) / 100 );
		g = Math.ceil( g * ( 100 + percent ) / 100 );
		b = Math.ceil( b * ( 100 + percent ) / 100 );

		r = Math.min( r, 255 );
		g = Math.min( g, 255 );
		b = Math.min( b, 255 );

		const rr = ( r.toString( 16 ).length === 1 ? '0' : '' ) + r.toString( 16 );
		const gg = ( g.toString( 16 ).length === 1 ? '0' : '' ) + g.toString( 16 );
		const bb = ( b.toString( 16 ).length === 1 ? '0' : '' ) + b.toString( 16 );

		return `#${rr}${gg}${bb}`;
	}

	if ( window.VoyageData !== undefined ) {
		// Already active
		return;
	}
	window.VoyageData = VoyageData;

	main();
} );
// </nowiki>