MediaWiki:Gadget-MarkerTooltip.js

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>
/***************************************************************************
 * MarkerTooltip.js v1.5, 2023-10-13
 * Displays an extended marker tooltip on mouse over on desktops
 * or on click on smartphones
 * Displays tooltip for abbreviations on smartphones
 * Original author: Roland Unger
 * Support of both desktop and mobile views
 * Documentation: https://de.wikivoyage.org/wiki/Wikivoyage:Gadget-MarkerTooltip.js
 * License: GPL-2.0+, CC-by-sa 3.0
 ***************************************************************************/
/* eslint-disable mediawiki/class-doc */

( function ( $ ) {
	'use strict';

	var mkTooltip = function() {
		const strings = {
			de: {
				hint:          'Click auf den Marker öffnet die Karte direkt.',
				hint2:         'Benutzen Sie zur Anzeige die Kartendienste.',

				ch1903:        'CH1903',
				ch1903Title:   'Es folgt die Koordinate in der Form der Schweizer Landeskoordinaten.',
				dec:           'Dezimal',
				decTitle:      'Es folgt die Koordinate in Dezimalform. Über den nebenstehenden Geo-URI-Link kann eine Karten-Anwendung gestartet werden.',
				geoUriTitle:   'Über diesen Link startet der Browser eine Karten-Anwendung, z.&#x202F;B. Google Maps. Auf vielen Smartphones bereits eingerichtet.',
				hex:           'GMS',
				hexTitle:      'Es folgt die Koordinate in der Form Grad-Minuten-Sekunden.',
				plus:          'Plus Code',
				plusTitle:     'Es folgt die Koordinate als Plus Code.',

				anchor:        'Anker',
				anchorTitle:   'Zeigt den/die Namen des/der Vorlagen-Anker(s) an.',
				anchorText:    'Der/die Name(n) des/der Vorlagen-Anker(s) lauten:\n\n',
				clipboard:     'Ablage',
				clipboardTitle:'Kopiert die nebenstehende Angabe in die Zwischenablage. Insbesondere ältere Browser unterstützten diese Funktion leider nicht.',
				mapSources:    'Kartendienste',
				mapSourcesTitle: 'Es folgen verschiedene Listen mit Kartenquellen und -diensten',
				tools:         'Werkzeuge',
				toolsTitle:    'Es folgen verschiedene Vorlagen-Werkzeuge',
				voy:           'Wikivoyage',
				voyTitle:      'Öffnet eine Wikivoyage-eigene Internetseite, die zahlreiche Kartenquellen und -dienste auflistet.',
				voyURL:        '/w/index.php?title=Special%3AMapsources',
				wmflabs:       'WMF-Labs',
				wmflabsTitle:  'Öffnet eine Internetseite von WMF Labs, die zahlreiche Kartenquellen und -dienste auflistet.',
				wmflabsURL:    'https://tools.wmflabs.org/geohack/geohack.php?',

				EW:            'OW', // international: 'EW'
				NS:            'NS'
			},
			en: {
				hint:          'Clicking on the marker directly opens the map.',
				hint2:         'Use the map tools for display.',

				ch1903:        'CH1903',
				ch1903Title:   'Coordinates are shown in the form of the Swiss national coordinates.',
				dec:           'Decimal',
				decTitle:      'Coordinates are shown as decimal values. A map application can be started via the adjacent Geo-URI link.',
				geoUriTitle:   'The browser starts a map application using this link, e.&#x202F;g. Google Maps. Already set up on many smartphones.',
				hex:           'DMS',
				hexTitle:      'Coordinates are shown as degree-minutes-seconds.',
				plus:          'Plus Code',
				plusTitle:     'Coordinates are shown as Plus Code.',

				anchor:        'Anchor',
				anchorTitle:   'Shows the name(s) of the template anchor(s).',
				anchorText:    'The name(s) of the template anchor(s) are:\n\n',
				clipboard:     'Clipboard',
				clipboardTitle:'Copies the adjacent information to the clipboard. Unfortunately, older browsers do not support this feature.',
				mapSources:    'Map tools',
				mapSourcesTitle: 'The following lists with map sources and services are available',
				tools:         'Tools',
				toolsTitle:    'The following tools are available',
				voy:           'Wikivoyage',
				voyTitle:      'Opens Wikivoyage’s own webpage which lists numerous map sources and services.',
				voyURL:        '/w/index.php?title=Special%3AMapsources',
				wmflabs:       'WMF Labs',
				wmflabsTitle:  'Opens a WMF Labs webpage which lists numerous map sources and services.',
				wmflabsURL:    'https://tools.wmflabs.org/geohack/geohack.php?',

				EW:            'EW',
				NS:            'NS'
			}
		};

		const fallbackLang = 'en',
			maxZoomLevel = 19; // see also getScaleFromZoom

		const options = {
			plusCode: false,
			ch1903:   true
		};

		const classes = {
			copyMarker:     'voy-copy-marker',
			listingTooltip: 'listing-tooltip',
			listingTooltipMobile: 'listing-tooltip-mobile'
		};

		const selectors = {
			kartographerLink: '.mw-kartographer-maplink',
			latitude:         '.p-latitude',
			longitude:        '.p-longitude',
			lCoordinates:     '.listing-coordinates',
			lEditButton:      '.listing-edit-button button',
			lInfoButton:      '.listing-info-button button',
			lMap:             '.listing-map, .listing-without-marker',
			lName:            '.listing-name',
			vcard:            '.vcard'
		};

		const data = {
			color:      'data-color',
			id:         'data-id',
			lat:        'data-lat',
			lon:        'data-lon',
			mAttribute: 'data-copy-marker-attribute',
			mContent:   'data-copy-marker-content',
			name:       'data-name',
			region:     'data-region',
			wikilang:   'data-wikilang',
			zoom:       'data-zoom'
		};

		// internal use
		const pageLang = mw.config.get( 'wgPageContentLanguage' ),
			userLang = mw.config.get( 'wgUserLanguage' ),
		//	isMobile = window.matchMedia( '(any-pointer: coarse)' ).matches &&  // has touch screen or similar
		//		!window.matchMedia( '(any-pointer: fine)' ).matches,  // has mouse
			isMobile = ( /android|webos|iphone|ipad|ipod|blackberry|iemobile|opera mini/i.test( navigator.userAgent.toLowerCase() ) ),
			timeouts = [];
		var messages = {};

		function addMessages( str, chain ) {
			for ( var i = chain.length - 1; i >= 0; i-- ) {
				if ( str.hasOwnProperty( chain[ i ] ) ) {
					$.extend( messages, strings[ chain[ i ] ] );
				}
			}
		}

		function setupMessages() {
			const chain = ( userLang == pageLang ) ? [ pageLang, fallbackLang ] :
				[ userLang, pageLang, fallbackLang ];
			addMessages( strings, chain );
		}

		// Only n digits
		function round( coord, n ) {
			const m = Math.pow( 10, n );
			return Math.round( coord * m ) / m;
		}

		// Converting decimal to DMS coordinates
		function toDMS( dec, letters ) {
			const letter = letters.charAt( ( dec >= 0 ) ? 0 : 1 );
			const angle = Math.abs( dec );

			var deg = Math.floor( angle );
			var min = ( angle - deg ) * 60;
			var sec = Math.round( ( min - Math.floor( min ) ) * 60 );
			min = Math.floor( min );
			if ( sec >= 60 ) {
				sec -= 60;
				min += 1;
			}
			if ( min >= 60 ) {
				min -= 60;
				deg += 1;
			}
			return deg + '° ' + min + '′ ' + sec + '″ ' + letter;
		}

		// Converting decimal to CH1903 coordinates
		// see: https://de.wikipedia.org/wiki/Schweizer_Landeskoordinaten
		function toCH1903( lat, lon ) {
			const ch1903 = {
				easting: 0,
				northing: 0,
				error: true
			};
			if ( lat < 45.5 || lat > 48 || lon < 5.0 || lon > 11 ) {
				return ch1903;
			}

			const phi = ( lat * 3600 - 169028.66 ) / 10000;
			const phi2 = phi * phi;
			const lambda = ( lon * 3600 - 26782.5 ) / 10000;
			const lambda2 = lambda * lambda;

			ch1903.northing = Math.round( 200147.07 + 308807.95 * phi + 3745.25 * lambda2 +
				76.63 * phi2 - 194.56 * lambda2 * phi + 119.79 * phi2 * phi );

			ch1903.easting = Math.round( 600072.37 + 211455.93 * lambda - 10938.51 * lambda * phi -
				0.36 * lambda * phi2 - 44.54 * lambda2 * lambda );

			ch1903.error = false;
			return ch1903;
		}

		// Converting decimal to Open Location Code (Plus Code)
		// see: https://en.wikipedia.org/wiki/Open_Location_Code
		function toPlusCode( lat, lon ) {
			const codeChars = '23456789CFGHJMPQRVWX';
			const resolutions = [ 20.0, 1.0, 0.05, 0.0025, 0.000125 ];
			var code = '';

			var modLat = lat;
			modLat = Math.max( -90, modLat );
			modLat = Math.min( modLat, 90 - 0.000025 );
			// 0.000025 = resolutions[ 4 ] / 5 [rows]
			modLat += 90; // starting from 0
			
			var modLon = lon;
			while ( modLon < -180 ) {
				modLon += 360;
			}
			while ( modLon >= 180 ) {
				modLon -= 360;
			}
			modLon += 180; // starting from 0

			// first 10 + 1 digits
			for ( var i = 0; i < 5; i++ ) {
				const res = resolutions[ i ];

				var digit = Math.floor( modLat / res );
				modLat -= digit * res;
				code += codeChars.charAt( digit );

				digit = Math.floor( modLon / res );
				modLon -= digit * res;
				code += codeChars.charAt( digit );

				if ( i === 3 ) {
					code += '+';
				}
			}

			// last digit
			const row = Math.floor( 5 * modLat / resolutions[ 4 ] );
			const col = Math.floor( 4 * modLon / resolutions[ 4 ] );
			code += codeChars.charAt( 4 * row + col );

			return code;
		}
		
		// zoom level 19 -> 1:1000, 0 -> 500000000
		function getScaleFromZoom( zoom ) {
			const scales = [ 1000, 2000, 4000, 8000, 15000, 35000, 70000, 150000, 250000,
				500000, 1000000, 2000000, 4000000, 10000000, 15000000, 35000000, 70000000,
				150000000, 250000000, 500000000 ];
			if ( zoom >= maxZoomLevel ) {
				return scales[ 0 ];
			} else if ( zoom <= 0 ) {
				return scales[ scales.length - 1 ];
			}
			return scales[ maxZoomLevel - Math.round( zoom ) ];
		}

		function copyToClipboard( selector, container ) {
			const clipboard = $( '<textarea id="mkClipboard"></textarea>' )
				.css( { 'width': 1, 'border': 'none', 'opacity': 0 } );
			$( 'body' ).append( clipboard );
			const text = ( selector !== '.mkClip5' ) ? $( selector, container ).text()
				: $( '#anchorId', container ).text();
			clipboard.val( text ).select();
			document.execCommand( 'copy' );
			clipboard.remove();
		}

		function clipboardLink( aClass ) {
			return mw.format( '[ <a href="javascript:" class="$1" title="$2">$3</a> ]',
				aClass, messages.clipboardTitle, messages.clipboard );
		}

		function makeTableRow( label, title, clipClass, buttonClass, text ) {
			return mw.format( '<tr><td><span title="$1">$2:</span> <span class="$3">$4</span></td><td>$5</td></tr>',
				title, label, clipClass, text, clipboardLink( buttonClass ) );
		}

		function makeContent( $origin ) {
			var link = $( selectors.kartographerLink, $origin ).first();
			var lat, lon, withMarker, zoom;
			if ( link.length ) {
				lat = round( link.attr( data.lat ), 6 );
				lon = round( link.attr( data.lon ), 6 );
				zoom = link.attr( data.zoom );
				withMarker = true;
			} else {
				link = $origin.closest( selectors.vcard ).find( selectors.lCoordinates );
				lat = round( $( selectors.latitude, link ).text(), 6 );
				lon = round( $( selectors.longitude, link ).text(), 6 );
				zoom = 16;
				withMarker = false;
			}
			const latStr = toDMS( lat, messages.NS );
			const lonStr = toDMS( lon, messages.EW );

			const wrapper = $origin.closest( selectors.vcard );
			const color = wrapper.attr( data.color );
			const lang = wrapper.attr( data.wikilang );
			var region = wrapper.attr( data.region );
			if ( !region ) {
				region = '';
			}

			var name = wrapper.attr( data.name );
			if ( !name ) {
				name = $( selectors.lName, wrapper ).first();
				var wikiLink = $( 'a', name ).first();
				name = ( wikiLink.length ) ? wikiLink.text() : name = name.text();
			}
			name = encodeURI( name.replace( /\s/g, '+' ) ).replace( /&/g, '%26' );
			const id = $( selectors.lName, wrapper ).attr( 'id' );
			const id2 = wrapper.attr( 'id' );

			var params = '&params=';
			params += Math.abs( lat ) + ( ( lat < 0 ) ? '_S_' : '_N_' );
			params += Math.abs( lon ) + ( ( lon < 0 ) ? '_W' : '_E' );
			params += '_scale%3A' + getScaleFromZoom( zoom ) +
				'_type%3Alandmark_globe%3Aearth';
			if ( region !== '' ) {
				params += '_region%3A' + region;
			}

			const ch1903 = toCH1903( lat, lon );
			const plusCode = toPlusCode( lat, lon );

			var table = '<table>' +
				makeTableRow( messages.hex, messages.hexTitle, 'mkClip1', 'mkButton1',
					latStr + ' ' + lonStr ) +
				makeTableRow( messages.dec, messages.decTitle, 'mkClip2', 'mkButton2',
					'<a href="geo:' + lat + ',' + lon + '" title="' +
					messages.geoUriTitle + '">' + lat + ', ' + lon + '</a>' );
			if ( options.plusCode ) {
				table += makeTableRow( messages.plus, messages.plusTitle, 'mkClip3', 'mkButton3',
					'<span class="voy-mkTooltipPlusCode">' + plusCode.substr( 0, 4 ) + '</span>' +
					plusCode.substr( 4 ) );
			}
			if ( options.ch1903 && !ch1903.error ) {
				table += makeTableRow( messages.ch1903, messages.ch1903Title,
					'mkClip4', 'mkButton4', '<span title="CH1903 easting">' +
					ch1903.easting + '</span> / <span title="CH1903 northing">' +
					ch1903.northing + '</span>' );
			}
			if ( id ) {
				const html = [];
				const infobutton = $( selectors.lInfoButton, wrapper ).prop( 'outerHTML' ) || '';
				if ( infobutton !== '' ) {
					html.push( '<span id="infobutton">' + infobutton + '</span>' );
				}
				const editbutton = $( selectors.lEditButton, wrapper ).prop( 'outerHTML' ) || '';
				if ( editbutton !== '' ) {
					html.push( '<span id="editbutton">' + editbutton + '</span>' );
				}
				var anchor = mw.format( '<a href="#" id="anchorIdLink" title="$1">$2</a>', messages.anchorTitle, messages.anchor ) +
					mw.format( '<span id="anchorId" style="display: none">$1</span>', id );
				if ( id2 ) {
					anchor += mw.format( '<span id="anchorId2" style="display: none">$1</span>', id2 );
				}
				html.push( anchor );
				table += makeTableRow( messages.tools, messages.toolsTitle, 'mkClip5', 'mkButton5',
					html.join( ' | ' ) );
			}
			table += '</table>';

			var mapSources = mw.format( '<div title="$1">$2: ', messages.mapSourcesTitle, messages.mapSources ) +
				mw.format( '<a href="$1&locname=$2" title="$3" target="_blank" rel="noopener">$4</a> | ',
					messages.voyURL + params, name, messages.voyTitle, messages.voy ) +
				mw.format( '<a href="$1pagename=$2&language=$3" title="$4" target="_blank" rel="noopener">$5</a>',
					messages.wmflabsURL, name, lang + params, messages.wmflabsTitle, messages.wmflabs ) +
				'</div>';

			return $( '<div class="voy-mkTooltipInner"></div>' )
				.css( 'border-left-color', color )
				.append( $( '<div class="voy-mkTooltipHint">' + (withMarker ? messages.hint : messages.hint2 ) + '</div>' )
					.css( { 'margin-bottom': '0.5em' } ) )
				.append( $( table ) )
				.append( $( mapSources ) )
				.append( $( '<div class="voy-mkTooltipTail"></div>' ) );
		}

		// setting tooltip position
		function setTooltipPosition( e, tooltip, $this ) {
			const tail = $( '.voy-mkTooltipTail', tooltip );
			const winWidth = $( window ).width();
			var left, offset, right, width;
			
			if ( e.clientY < $( window ).height() / 2 ) {
				tooltip.css( 'top', $this.innerHeight() - 4 )
					.addClass('voy-mkBelow');
			} else {
				tooltip.css( 'bottom', $this.innerHeight() - 4 )
					.addClass('voy-mkAbove');
			}
			if ( e.clientX < winWidth / 2 ) {
				tooltip.css( 'left', $this.innerWidth() / 2 - 16 )
					.addClass('voy-mkLeft');
				if ( isMobile ) {
					offset = tooltip.offset();
					right = offset.left + tooltip.outerWidth();
					if ( right > winWidth - 1 ) {
						left = offset.left - ( right - winWidth ) - 2;
						if ( left < 2 ) {
							left = 2;
						}
						width = tooltip.innerWidth();
						tooltip.offset( { top: offset.top, left: left } );
						tooltip.innerWidth( width );
						width = offset.left - left;
						offset = tail.offset();
						offset.left += width;
						tail.offset( offset );
					}
				}
			}
			else {
				tooltip.css( 'right', $this.innerWidth() / 2 - 13 )
					.addClass('voy-mkRight');
				if ( isMobile ) {
					offset = tooltip.offset();
					left = offset.left;
					if ( left < 2 ) {
						width = tooltip.innerWidth();
						tooltip.offset( { top: offset.top, left: 2 } );
						tooltip.innerWidth( width );
						offset = tail.offset();
						offset.left += left;
						tail.offset( offset );
					}
				}
			}
		}

		function showMarkerTooltip( e ) {
			const $this = $( e.target ).closest( '.' + classes.listingTooltip );
			const wrapper = $this.closest( selectors.vcard );
			e.stopPropagation();
			const id = $this.attr( data.id );
			var $origin = $this;
			if ( $this.hasClass( classes.copyMarker ) ) {
				// getting from original marker
				const attr = $this.attr( data.mAttribute );
				const content = $this.attr( data.mContent );
				$origin = $( '*[' + attr + '="' + content + '"]' ).first();
			}

			const tooltip = $( '<div class="voy-mkTooltip" role="tooltip"/>' )
				.append( makeContent( $origin ) );
			if ( isMobile ) {
				tooltip.addClass( 'voy-mkTooltipMobile' );
			} else {
				tooltip.hide(); // later fade-in;
			}
			$this.append( tooltip );
			setTooltipPosition( e, tooltip, $this );

			$( '.mkButton1', tooltip )
				.click( function() { copyToClipboard( '.mkClip1', tooltip ); } );
			$( '.mkButton2', tooltip )
				.click( function() { copyToClipboard( '.mkClip2', tooltip ); } );
			$( '.mkButton3', tooltip )
				.click( function() { copyToClipboard( '.mkClip3', tooltip ); } );
			$( '.mkButton4', tooltip )
				.click( function() { copyToClipboard( '.mkClip4', tooltip ); } );
			$( '.mkButton5', tooltip )
				.click( function() { copyToClipboard( '.mkClip5', tooltip ); } );
			$( '#anchorIdLink', tooltip )
				.click( function() {
					var alertText = messages.anchorText + $( '#anchorId', tooltip ).text();
					const anchor2 = $( '#anchorId2', tooltip ).text();
					if ( anchor2 && anchor2 != '' ) {
						alertText += ', ' + anchor2;
					}
					removeAllTooltips();
					alert( alertText );
				} );
			$( '#infobutton', tooltip )
				.click( function() {
					$( selectors.lInfoButton, wrapper ).trigger( 'click' );
					removeAllTooltips();
				} );
			$( '#editbutton', tooltip )
				.click( function() {
					$( selectors.lEditButton, wrapper ).trigger( 'click' );
					removeAllTooltips();
				} );

			if ( isMobile ) {
				// removing tooltip after 10 sec in mobile mode
				timeouts[ id ] =
					setTimeout( function() { removeTooltip( $this ); }, 10000 );
				$( 'body' ).click( handleOutsideClick );
			} else {
				// fading-in hidden tooltip in desktop mode
				setTimeout( function() { tooltip.fadeIn( 500 ); }, 300 );
			}

			return tooltip;
		}

		// Click event handler if clicked outside any tooltip
		function handleOutsideClick( event ) {
			if ( !$( event.target ).closest( '.voy-mkTooltip' ).length &&
				$( '.voy-mkTooltip' ).is( ':visible' ) ) {
				removeAllTooltips();
			}
		}

		function removeTooltip( marker ) {
			const id = marker.attr( data.id );
			if ( id ) {
				clearTimeout( timeouts[ id ] );
			}
			$( '.voy-mkTooltip', marker ).remove();
			$( '.voy-mkTooltipButton', marker ).text( '▼' );
		}

		function removeAllTooltips() {
			if ( isMobile ) {
				$( 'body' ).off( 'click', handleOutsideClick );
			}
			var markers = $( '.' + classes.listingTooltip ).add( $( 'abbr' ) );
			markers.each( function() {
				removeTooltip( $( this ) );
			});
		}

		function showMobileMarker( e ) {
			const $this = $( e.target ).closest( '.voy-mkTooltipButton' );
			const text = $this.text();
			removeAllTooltips();
			if ( text === '▼' ) {
				$this.text( '▲' );
				showMarkerTooltip( e );
			}
		}

		function initMarkerTooltip() {
			$( selectors.lMap ).addClass( classes.listingTooltip );
			if ( isMobile ) {
				$( selectors.lMap ).addClass( classes.listingTooltipMobile );
			}

			const markers = $( '.' + classes.listingTooltip )
				.attr( 'title', '' )
				.css( { 'position': 'relative', 'cursor': 'default' } );
			var id = 0;
			// setting id for timeout handler
			markers.each( function() {
				$( this ).attr( data.id, 'tt' + id );
				id += 1;
			} );
				
			if ( isMobile ) {
				const mobileMarker = $( '<span class="voy-mkTooltipButton">▼</span>' )
					.click( function( e ) { 
						showMobileMarker( e );
					});
				markers.append( mobileMarker );
			} else {
				markers.mouseenter( function( e ) {
					showMarkerTooltip( e );
				})
				.mouseleave( function( e ) {
					$( '.voy-mkTooltip' ).remove();
				});
			}
		}

		function initAbbrTooltip() {
			const abbr = $( 'abbr' )
				.css( { 'position': 'relative', 'cursor': 'pointer' } );

			var id = 0;
			// setting id for timeout handler
			abbr.each( function() {
				$( this ).attr( data.id, 'at' + id );
				id += 1;
			} );

			abbr.click( function( e ) {
				e.stopPropagation();
				const $this = $( e.target ).closest( 'abbr' );
				const id = $this.attr( data.id );
				var tooltip = $( '.voy-mkTooltip', $this );
				removeAllTooltips();
				if ( tooltip.length === 0 ) {
					const title = $this.attr( 'title' );
					if ( title ) {
						const div = $( '<div class="voy-mkTooltipInner voy-mkTooltipMaxWidth">' +
							title + '</div>' )
							.append( $( '<div class="voy-mkTooltipTail"></div>' ) );
						tooltip = $( '<div class="voy-mkTooltip voy-mkTooltipMobile" role="tooltip"></div>' )
							.append( div );
						$this.append( tooltip );
						setTooltipPosition( e, tooltip, $this );
						timeouts[ id ] =
							setTimeout( function() { removeTooltip( $this ); }, 10000 );
						$( 'body' ).click( handleOutsideClick );
					}
				}
			} );
		}

		function init() {
			setupMessages();
			initMarkerTooltip();
			if ( isMobile ) {
				initAbbrTooltip();
			}
		}

		return { init: init };
	} ();
	
	$( mkTooltip.init );

} ( jQuery ) );

//</nowiki>