Benutzer:Nw520/VoyageData.js
Erscheinungsbild
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 = 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>