Jump to content

MediaWiki:Common.js: Difference between revisions

From Adaris
No edit summary
No edit summary
 
(4 intermediate revisions by the same user not shown)
Line 172: Line 172:
     }
     }


     var miles = (pixels / 1000) * 50;
     var miles = (pixels / 1000) * 75;


     output.textContent = Math.round(miles) + ' mi';
     output.textContent = Math.round(miles) + ' mi';
Line 178: Line 178:
});
});
mw.loader.load('/index.php?title=MediaWiki:Familytree.js&action=raw&ctype=text/javascript');
mw.loader.load('/index.php?title=MediaWiki:Familytree.js&action=raw&ctype=text/javascript');
/* ============================================================
  Interactive deep-zoom map for MediaWiki (OpenSeadragon)
  ------------------------------------------------------------
  Paste this whole block at the bottom of  MediaWiki:Common.js
  Supports clickable pins, text labels, per-marker zoom ranges,
  and category layers with an on/off legend.
  ============================================================ */
( function () {
'use strict';
// Same major version is used for the script and its button images.
var OSD_BASE = 'https://cdn.jsdelivr.net/npm/openseadragon@6/build/openseadragon/';
function truthy( v ) {
return v === 'true' || v === '1' || v === 'yes';
}
function loadOSD() {
if ( window.OpenSeadragon ) {
return Promise.resolve();
}
return new Promise( function ( resolve, reject ) {
var s = document.createElement( 'script' );
s.src = OSD_BASE + 'openseadragon.min.js';
s.onload = resolve;
s.onerror = function () {
reject( new Error( 'InteractiveMap: failed to load OpenSeadragon' ) );
};
document.head.appendChild( s );
} );
}
// Build a wiki article URL without depending on extra RL modules.
function articleUrl( page ) {
var path = mw.config.get( 'wgArticlePath' ); // e.g. "/wiki/$1"
return path.replace( '$1', encodeURIComponent( String( page ).replace( / /g, '_' ) ) );
}
// Forgiving JSON parse: tolerates a page wrapped in <pre> or
// <syntaxhighlight> by grabbing the first {...} / [...] block.
function parseMarkers( txt ) {
try {
return normalize( JSON.parse( txt ) );
} catch ( e ) { /* fall through */ }
var m = txt.match( /[\[{][\s\S]*[\]}]/ );
if ( m ) {
try {
return normalize( JSON.parse( m[ 0 ] ) );
} catch ( e2 ) { /* fall through */ }
}
return { pins: [], categories: {} };
}
function normalize( data ) {
if ( Array.isArray( data ) ) {
return { pins: data, categories: {} };
}
if ( data && Array.isArray( data.pins ) ) {
return { pins: data.pins, categories: data.categories || {} };
}
return { pins: [], categories: {} };
}
function fetchMarkers( src ) {
if ( !src ) {
return Promise.resolve( { pins: [], categories: {} } );
}
if ( /^https?:\/\//.test( src ) ) {
return fetch( src ).then( function ( r ) {
return r.text();
} ).then( parseMarkers );
}
var scriptPath = mw.config.get( 'wgScriptPath' ) || '';
var url = scriptPath + '/api.php?action=parse&prop=wikitext&formatversion=2&format=json&page=' +
encodeURIComponent( src );
return fetch( url ).then( function ( r ) {
return r.json();
} ).then( function ( d ) {
return parseMarkers( ( d && d.parse && d.parse.wikitext ) || '[]' );
} );
}
var PIN_SVG = '<svg viewBox="0 0 24 34" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">' +
'<path d="M12 0C5.7 0 .8 4.9.8 11.2.8 19.7 12 34 12 34s11.2-14.3 11.2-22.8C23.2 4.9 18.3 0 12 0z" fill="currentColor"/>' +
'<circle cx="12" cy="11.2" r="4.2" fill="#fff" fill-opacity=".92"/></svg>';
// A marker's colour: its own "color" wins, else its category's colour.
function markerColor( p, categories ) {
var cat = ( categories && p.category ) ? categories[ p.category ] : null;
return p.color || ( cat && cat.color );
}
function buildPin( p, categories ) {
var color = markerColor( p, categories );
var pin = document.createElement( p.page ? 'a' : 'span' );
pin.className = 'imap-pin';
if ( p.page ) {
pin.href = articleUrl( p.page );
}
if ( color ) {
pin.style.setProperty( '--pin-color', color );
}
pin.setAttribute( 'aria-label', p.title || p.page || 'map location' );
pin.innerHTML = PIN_SVG;
if ( p.title ) {
var hover = document.createElement( 'span' );
hover.className = 'imap-pin-label';
hover.textContent = p.title;
pin.appendChild( hover );
}
return { element: pin, placement: OpenSeadragon.Placement.BOTTOM };
}
function buildLabel( p, categories ) {
var color = markerColor( p, categories );
var el = document.createElement( p.page ? 'a' : 'span' );
el.className = 'imap-label';
el.textContent = p.title || '';
if ( p.page ) {
el.href = articleUrl( p.page );
}
if ( color ) {
el.style.color = color;
}
if ( p.size ) {
el.style.fontSize = p.size + 'px';
}
return { element: el, placement: OpenSeadragon.Placement.CENTER };
}
// Add every marker; return a list we can show / hide by zoom + category.
function addMarkers( viewer, markers, categories ) {
var ti = viewer.world.getItemAt( 0 );
var tracked = [];
if ( !ti ) {
return tracked;
}
markers.forEach( function ( p ) {
if ( typeof p.x !== 'number' || typeof p.y !== 'number' ) {
return;
}
var built = ( p.kind === 'label' ) ? buildLabel( p, categories ) : buildPin( p, categories );
viewer.addOverlay( {
element: built.element,
location: ti.imageToViewportCoordinates( new OpenSeadragon.Point( p.x, p.y ) ),
placement: built.placement
} );
tracked.push( {
el: built.element,
cat: p.category || null,
min: typeof p.minZoom === 'number' ? p.minZoom : 0,
max: typeof p.maxZoom === 'number' ? p.maxZoom : Infinity,
shown: true
} );
} );
return tracked;
}
// Relative zoom: 1 = whole map fits the frame, higher = more zoomed in.
// Independent of screen size, so thresholds mean the same everywhere.
function relativeZoom( viewer ) {
return viewer.viewport.getZoom( true ) / viewer.viewport.getHomeZoom();
}
// A marker is visible when its category is enabled AND the current zoom
// is inside its range. `enabled` is shared with the legend checkboxes.
function makeVisibilityUpdater( viewer, tracked, enabled ) {
return function () {
if ( !tracked.length ) {
return;
}
var z = relativeZoom( viewer );
tracked.forEach( function ( t ) {
var catOn = !t.cat || enabled[ t.cat ] !== false;
var visible = catOn && z >= t.min && z <= t.max;
if ( visible !== t.shown ) {
t.shown = visible;
t.el.classList.toggle( 'imap-hidden', !visible ); // CSS fades it
}
} );
};
}
// Build the on/off legend from the categories actually used by pins.
// Populates `enabled` with each category's starting state and wires the
// checkboxes to re-run `onChange`.
function buildLegend( el, pins, categories, enabled, onChange ) {
var used = {};
pins.forEach( function ( p ) {
if ( p.category ) {
used[ p.category ] = true;
}
} );
// Config order first, then any used categories not in the config.
var keys = [];
Object.keys( categories ).forEach( function ( k ) {
if ( used[ k ] ) {
keys.push( k );
}
} );
Object.keys( used ).forEach( function ( k ) {
if ( keys.indexOf( k ) === -1 ) {
keys.push( k );
}
} );
// Starting state: on, unless the category sets "default": false.
keys.forEach( function ( k ) {
var cfg = categories[ k ] || {};
enabled[ k ] = ( cfg.default !== false );
} );
if ( !keys.length ) {
return;
}
var panel = document.createElement( 'div' );
panel.className = 'imap-legend';
var title = document.createElement( 'div' );
title.className = 'imap-legend-title';
title.textContent = 'Layers';
panel.appendChild( title );
var boxes = []; // child checkboxes, parallel to `keys`
// Master "All" checkbox: checked when every layer is on, unchecked
// when none are, and a dash (indeterminate) when it's a mix.
var master = document.createElement( 'input' );
master.type = 'checkbox';
function syncMaster() {
var on = 0;
boxes.forEach( function ( b ) {
if ( b.checked ) {
on++;
}
} );
master.checked = ( boxes.length > 0 && on === boxes.length );
master.indeterminate = ( on > 0 && on < boxes.length );
}
master.addEventListener( 'change', function () {
boxes.forEach( function ( b, i ) {
b.checked = master.checked;
enabled[ keys[ i ] ] = master.checked;
} );
master.indeterminate = false;
onChange();
} );
var masterRow = document.createElement( 'label' );
masterRow.className = 'imap-legend-all';
var masterText = document.createElement( 'span' );
masterText.textContent = 'All';
masterRow.appendChild( master );
masterRow.appendChild( masterText );
panel.appendChild( masterRow );
keys.forEach( function ( k ) {
var cfg = categories[ k ] || {};
var row = document.createElement( 'label' );
var cb = document.createElement( 'input' );
cb.type = 'checkbox';
cb.checked = enabled[ k ];
cb.addEventListener( 'change', function () {
enabled[ k ] = cb.checked;
syncMaster();
onChange();
} );
boxes.push( cb );
var swatch = document.createElement( 'span' );
swatch.className = 'imap-legend-swatch';
swatch.style.background = ( cfg.color || '#2f6fed' );
var text = document.createElement( 'span' );
text.textContent = cfg.label || k;
row.appendChild( cb );
row.appendChild( swatch );
row.appendChild( text );
panel.appendChild( row );
} );
syncMaster(); // reflect the starting state (handles "default": false)
el.appendChild( panel );
}
// Optional helper: shows the live zoom level and copies the pixel
// coordinates of a clicked point. Enable with  | coordhelper = 1
function enableCoordHelper( viewer, el ) {
var box = document.createElement( 'div' );
box.className = 'imap-coords';
var zoomLine = document.createElement( 'div' );
var ptLine = document.createElement( 'div' );
ptLine.textContent = 'Click the map to copy a pin\u2019s coordinates';
box.appendChild( zoomLine );
box.appendChild( ptLine );
el.appendChild( box );
function showZoom() {
zoomLine.textContent = 'zoom \u00d7' + relativeZoom( viewer ).toFixed( 2 );
}
showZoom();
viewer.addHandler( 'animation', showZoom );
viewer.addHandler( 'zoom', showZoom );
viewer.addHandler( 'resize', showZoom );
viewer.addHandler( 'canvas-click', function ( e ) {
if ( !e.quick ) {
return; // ignore the click that ends a drag
}
var img = viewer.world.getItemAt( 0 )
.viewportToImageCoordinates( viewer.viewport.pointFromPixel( e.position ) );
var snippet = '{ "x": ' + Math.round( img.x ) +
', "y": ' + Math.round( img.y ) + ', "title": "", "page": "" }';
ptLine.textContent = snippet;
if ( navigator.clipboard ) {
navigator.clipboard.writeText( snippet ).catch( function () {} );
}
} );
}
function initMap( el ) {
var dzi = el.getAttribute( 'data-dzi' );
if ( !dzi ) {
return;
}
var markersSrc = el.getAttribute( 'data-markers' );
var height = el.getAttribute( 'data-height' );
if ( height ) {
el.style.height = /^\d+$/.test( height ) ? height + 'px' : height;
}
var opts = {
element: el,
tileSources: dzi,
prefixUrl: OSD_BASE + 'images/',
showNavigator: true,
navigatorPosition: 'BOTTOM_RIGHT',
showRotationControl: false,
gestureSettingsMouse: { clickToZoom: false }, // single click is for pins
maxZoomPixelRatio: 2,
visibilityRatio: 1,
constrainDuringPan: true,
animationTime: 0.6,
springStiffness: 7
};
if ( truthy( el.getAttribute( 'data-cors' ) ) ) {
opts.crossOriginPolicy = 'Anonymous';
}
var viewer = OpenSeadragon( opts );
viewer.addHandler( 'open', function () {
if ( truthy( el.getAttribute( 'data-coord-helper' ) ) ) {
enableCoordHelper( viewer, el );
}
fetchMarkers( markersSrc ).then( function ( markerData ) {
var pins = markerData.pins || [];
var categories = markerData.categories || {};
var enabled = {};
var tracked = addMarkers( viewer, pins, categories );
var update = makeVisibilityUpdater( viewer, tracked, enabled );
buildLegend( el, pins, categories, enabled, update ); // sets defaults in `enabled`
update(); // set the correct state for the starting zoom
viewer.addHandler( 'animation', update );
viewer.addHandler( 'animation-finish', update );
viewer.addHandler( 'zoom', update );
viewer.addHandler( 'resize', update );
} ).catch( function ( err ) {
if ( window.console ) {
console.warn( 'InteractiveMap: could not load markers \u2014', err );
}
} );
} );
}
// wikipage.content fires on first load and again after live previews.
mw.hook( 'wikipage.content' ).add( function ( $content ) {
var root = ( $content && $content[ 0 ] ) ? $content[ 0 ] : document;
var nodes = root.querySelectorAll( '.interactive-map:not([data-imap-ready])' );
if ( !nodes.length ) {
return;
}
loadOSD().then( function () {
nodes.forEach( function ( el ) {
el.setAttribute( 'data-imap-ready', '1' );
initMap( el );
} );
} ).catch( function ( err ) {
if ( window.console ) {
console.error( err );
}
} );
} );
}() );

Latest revision as of 19:26, 20 June 2026

// -------------------------
// Infobox cleanup
// -------------------------
$(document).ready(function() {
    $('.infoboxTable th').each(function() {
        if ($(this).text().trim() === "Image") {
            $(this).text("");
        }
    });
});

// -------------------------
// Remove unwanted Introduction headers
// -------------------------
$(document).ready(function() {
    $('h2').each(function() {
        if ($(this).text().trim() === "Introduction") {
            $(this).remove();
        }
    });
});

// -------------------------
// Remove first empty paragraph
// -------------------------
$(document).ready(function() {
    $('p').first().each(function() {
        if ($(this).html().trim() === "<br>") {
            $(this).remove();
        }
    });
});


mw.loader.load('https://cdn.jsdelivr.net/gh/openlayers/openlayers.github.io@main/dist/en/v7.0.0/legacy/ol.js');
mw.loader.load('/maps/elemara/elemara-map.js');

mw.loader.using([], function () {
  $(function () {
    var container = document.getElementById("pixel-area-calculator");

    if (!container) {
      return;
    }

    container.innerHTML =
      '<div class="pixel-area-tool">' +
        '<h3>Pixel to Square Miles Calculator</h3>' +

        '<label>Width in pixels:' +
          '<input id="pixel-width" type="number" min="0" step="any" placeholder="Width">' +
        '</label>' +

        '<label>Height in pixels:' +
          '<input id="pixel-height" type="number" min="0" step="any" placeholder="Height">' +
        '</label>' +

        '<button id="pixel-area-button" type="button">Calculate</button>' +

        '<div id="pixel-area-result"></div>' +
      '</div>';

    document.getElementById("pixel-area-button").addEventListener("click", function () {
      var widthPixels = parseFloat(document.getElementById("pixel-width").value);
      var heightPixels = parseFloat(document.getElementById("pixel-height").value);
      var result = document.getElementById("pixel-area-result");

      if (isNaN(widthPixels) || isNaN(heightPixels)) {
        result.textContent = "Please enter both pixel values.";
        return;
      }

      var widthMiles = (widthPixels / 1000) * 75;
      var heightMiles = (heightPixels / 1000) * 75;
      var squareMiles = widthMiles * heightMiles;

      result.innerHTML =
        "<strong>" + widthMiles.toLocaleString(undefined, { maximumFractionDigits: 0 }) + " miles</strong>" +
        " × " +
        "<strong>" + heightMiles.toLocaleString(undefined, { maximumFractionDigits: 0 }) + " miles</strong>" +
        " = " +
        "<strong>" + squareMiles.toLocaleString(undefined, { maximumFractionDigits: 0 }) + " mi²</strong>";
    });
  });
});

(function () {
    function stripHeaderLines(text) {
        return text
            // Remove standalone short lines that look like section headers
            .replace(/(?:^|\n)\s*[A-Z][A-Za-z0-9'’\- ,()]{0,50}\s*(?=\n)/g, '\n')
            // Clean up excess blank lines
            .replace(/\n{2,}/g, '\n')
            .trim();
    }

    function cleanAdarisPopups() {
        document.querySelectorAll(
            '.mwe-popups-extract, .mwe-popups-extract p, .mwe-popups .extract, .mwe-popups .mwe-popups-extract'
        ).forEach(function (el) {
            if (!el || el.dataset.adarisCleaned === '1') {
                return;
            }

            el.textContent = stripHeaderLines(el.textContent);
            el.dataset.adarisCleaned = '1';
        });
    }

    const observer = new MutationObserver(function () {
        cleanAdarisPopups();
        setTimeout(cleanAdarisPopups, 100);
        setTimeout(cleanAdarisPopups, 300);
    });

    observer.observe(document.body, {
        childList: true,
        subtree: true,
        characterData: true
    });

    cleanAdarisPopups();
})();

mw.loader.using('mediawiki.util', function () {
  $(function () {
    var input = document.getElementById('pixelToMileInput');
    var output = document.getElementById('pixelToMileOutput');

    if (!input || !output) {
      return;
    }

    input.addEventListener('input', function () {
      var pixels = parseFloat(input.value);

      if (isNaN(pixels)) {
        output.textContent = '0 mi';
        return;
      }

      var miles = (pixels / 1000) * 75;

      output.textContent = Math.round(miles) + ' mi';
    });
  });
});

$(function () {
  var container = document.getElementById('pixel-to-mile-calculator');

  if (!container) {
    return;
  }

  container.className = 'adaris-tool-box';

  container.innerHTML =
    '<label for="pixelToMileInput"><strong>Pixels:</strong></label><br>' +
    '<input id="pixelToMileInput" type="number" placeholder="Enter pixels">' +
    '<p><strong>Distance:</strong> <span id="pixelToMileOutput">0 mi</span></p>';

  var input = document.getElementById('pixelToMileInput');
  var output = document.getElementById('pixelToMileOutput');

  input.addEventListener('input', function () {
    var pixels = parseFloat(input.value);

    if (isNaN(pixels)) {
      output.textContent = '0 mi';
      return;
    }

    var miles = (pixels / 1000) * 75;

    output.textContent = Math.round(miles) + ' mi';
  });
});
mw.loader.load('/index.php?title=MediaWiki:Familytree.js&action=raw&ctype=text/javascript');

/* ============================================================
   Interactive deep-zoom map for MediaWiki (OpenSeadragon)
   ------------------------------------------------------------
   Paste this whole block at the bottom of  MediaWiki:Common.js
   Supports clickable pins, text labels, per-marker zoom ranges,
   and category layers with an on/off legend.
   ============================================================ */
( function () {
	'use strict';

	// Same major version is used for the script and its button images.
	var OSD_BASE = 'https://cdn.jsdelivr.net/npm/openseadragon@6/build/openseadragon/';

	function truthy( v ) {
		return v === 'true' || v === '1' || v === 'yes';
	}

	function loadOSD() {
		if ( window.OpenSeadragon ) {
			return Promise.resolve();
		}
		return new Promise( function ( resolve, reject ) {
			var s = document.createElement( 'script' );
			s.src = OSD_BASE + 'openseadragon.min.js';
			s.onload = resolve;
			s.onerror = function () {
				reject( new Error( 'InteractiveMap: failed to load OpenSeadragon' ) );
			};
			document.head.appendChild( s );
		} );
	}

	// Build a wiki article URL without depending on extra RL modules.
	function articleUrl( page ) {
		var path = mw.config.get( 'wgArticlePath' ); // e.g. "/wiki/$1"
		return path.replace( '$1', encodeURIComponent( String( page ).replace( / /g, '_' ) ) );
	}

	// Forgiving JSON parse: tolerates a page wrapped in <pre> or
	// <syntaxhighlight> by grabbing the first {...} / [...] block.
	function parseMarkers( txt ) {
		try {
			return normalize( JSON.parse( txt ) );
		} catch ( e ) { /* fall through */ }
		var m = txt.match( /[\[{][\s\S]*[\]}]/ );
		if ( m ) {
			try {
				return normalize( JSON.parse( m[ 0 ] ) );
			} catch ( e2 ) { /* fall through */ }
		}
		return { pins: [], categories: {} };
	}

	function normalize( data ) {
		if ( Array.isArray( data ) ) {
			return { pins: data, categories: {} };
		}
		if ( data && Array.isArray( data.pins ) ) {
			return { pins: data.pins, categories: data.categories || {} };
		}
		return { pins: [], categories: {} };
	}

	function fetchMarkers( src ) {
		if ( !src ) {
			return Promise.resolve( { pins: [], categories: {} } );
		}
		if ( /^https?:\/\//.test( src ) ) {
			return fetch( src ).then( function ( r ) {
				return r.text();
			} ).then( parseMarkers );
		}
		var scriptPath = mw.config.get( 'wgScriptPath' ) || '';
		var url = scriptPath + '/api.php?action=parse&prop=wikitext&formatversion=2&format=json&page=' +
			encodeURIComponent( src );
		return fetch( url ).then( function ( r ) {
			return r.json();
		} ).then( function ( d ) {
			return parseMarkers( ( d && d.parse && d.parse.wikitext ) || '[]' );
		} );
	}

	var PIN_SVG = '<svg viewBox="0 0 24 34" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">' +
		'<path d="M12 0C5.7 0 .8 4.9.8 11.2.8 19.7 12 34 12 34s11.2-14.3 11.2-22.8C23.2 4.9 18.3 0 12 0z" fill="currentColor"/>' +
		'<circle cx="12" cy="11.2" r="4.2" fill="#fff" fill-opacity=".92"/></svg>';

	// A marker's colour: its own "color" wins, else its category's colour.
	function markerColor( p, categories ) {
		var cat = ( categories && p.category ) ? categories[ p.category ] : null;
		return p.color || ( cat && cat.color );
	}

	function buildPin( p, categories ) {
		var color = markerColor( p, categories );
		var pin = document.createElement( p.page ? 'a' : 'span' );
		pin.className = 'imap-pin';
		if ( p.page ) {
			pin.href = articleUrl( p.page );
		}
		if ( color ) {
			pin.style.setProperty( '--pin-color', color );
		}
		pin.setAttribute( 'aria-label', p.title || p.page || 'map location' );
		pin.innerHTML = PIN_SVG;
		if ( p.title ) {
			var hover = document.createElement( 'span' );
			hover.className = 'imap-pin-label';
			hover.textContent = p.title;
			pin.appendChild( hover );
		}
		return { element: pin, placement: OpenSeadragon.Placement.BOTTOM };
	}

	function buildLabel( p, categories ) {
		var color = markerColor( p, categories );
		var el = document.createElement( p.page ? 'a' : 'span' );
		el.className = 'imap-label';
		el.textContent = p.title || '';
		if ( p.page ) {
			el.href = articleUrl( p.page );
		}
		if ( color ) {
			el.style.color = color;
		}
		if ( p.size ) {
			el.style.fontSize = p.size + 'px';
		}
		return { element: el, placement: OpenSeadragon.Placement.CENTER };
	}

	// Add every marker; return a list we can show / hide by zoom + category.
	function addMarkers( viewer, markers, categories ) {
		var ti = viewer.world.getItemAt( 0 );
		var tracked = [];
		if ( !ti ) {
			return tracked;
		}
		markers.forEach( function ( p ) {
			if ( typeof p.x !== 'number' || typeof p.y !== 'number' ) {
				return;
			}
			var built = ( p.kind === 'label' ) ? buildLabel( p, categories ) : buildPin( p, categories );
			viewer.addOverlay( {
				element: built.element,
				location: ti.imageToViewportCoordinates( new OpenSeadragon.Point( p.x, p.y ) ),
				placement: built.placement
			} );
			tracked.push( {
				el: built.element,
				cat: p.category || null,
				min: typeof p.minZoom === 'number' ? p.minZoom : 0,
				max: typeof p.maxZoom === 'number' ? p.maxZoom : Infinity,
				shown: true
			} );
		} );
		return tracked;
	}

	// Relative zoom: 1 = whole map fits the frame, higher = more zoomed in.
	// Independent of screen size, so thresholds mean the same everywhere.
	function relativeZoom( viewer ) {
		return viewer.viewport.getZoom( true ) / viewer.viewport.getHomeZoom();
	}

	// A marker is visible when its category is enabled AND the current zoom
	// is inside its range. `enabled` is shared with the legend checkboxes.
	function makeVisibilityUpdater( viewer, tracked, enabled ) {
		return function () {
			if ( !tracked.length ) {
				return;
			}
			var z = relativeZoom( viewer );
			tracked.forEach( function ( t ) {
				var catOn = !t.cat || enabled[ t.cat ] !== false;
				var visible = catOn && z >= t.min && z <= t.max;
				if ( visible !== t.shown ) {
					t.shown = visible;
					t.el.classList.toggle( 'imap-hidden', !visible ); // CSS fades it
				}
			} );
		};
	}

	// Build the on/off legend from the categories actually used by pins.
	// Populates `enabled` with each category's starting state and wires the
	// checkboxes to re-run `onChange`.
	function buildLegend( el, pins, categories, enabled, onChange ) {
		var used = {};
		pins.forEach( function ( p ) {
			if ( p.category ) {
				used[ p.category ] = true;
			}
		} );

		// Config order first, then any used categories not in the config.
		var keys = [];
		Object.keys( categories ).forEach( function ( k ) {
			if ( used[ k ] ) {
				keys.push( k );
			}
		} );
		Object.keys( used ).forEach( function ( k ) {
			if ( keys.indexOf( k ) === -1 ) {
				keys.push( k );
			}
		} );

		// Starting state: on, unless the category sets "default": false.
		keys.forEach( function ( k ) {
			var cfg = categories[ k ] || {};
			enabled[ k ] = ( cfg.default !== false );
		} );

		if ( !keys.length ) {
			return;
		}

		var panel = document.createElement( 'div' );
		panel.className = 'imap-legend';

		var title = document.createElement( 'div' );
		title.className = 'imap-legend-title';
		title.textContent = 'Layers';
		panel.appendChild( title );

		var boxes = []; // child checkboxes, parallel to `keys`

		// Master "All" checkbox: checked when every layer is on, unchecked
		// when none are, and a dash (indeterminate) when it's a mix.
		var master = document.createElement( 'input' );
		master.type = 'checkbox';

		function syncMaster() {
			var on = 0;
			boxes.forEach( function ( b ) {
				if ( b.checked ) {
					on++;
				}
			} );
			master.checked = ( boxes.length > 0 && on === boxes.length );
			master.indeterminate = ( on > 0 && on < boxes.length );
		}

		master.addEventListener( 'change', function () {
			boxes.forEach( function ( b, i ) {
				b.checked = master.checked;
				enabled[ keys[ i ] ] = master.checked;
			} );
			master.indeterminate = false;
			onChange();
		} );

		var masterRow = document.createElement( 'label' );
		masterRow.className = 'imap-legend-all';
		var masterText = document.createElement( 'span' );
		masterText.textContent = 'All';
		masterRow.appendChild( master );
		masterRow.appendChild( masterText );
		panel.appendChild( masterRow );

		keys.forEach( function ( k ) {
			var cfg = categories[ k ] || {};
			var row = document.createElement( 'label' );

			var cb = document.createElement( 'input' );
			cb.type = 'checkbox';
			cb.checked = enabled[ k ];
			cb.addEventListener( 'change', function () {
				enabled[ k ] = cb.checked;
				syncMaster();
				onChange();
			} );
			boxes.push( cb );

			var swatch = document.createElement( 'span' );
			swatch.className = 'imap-legend-swatch';
			swatch.style.background = ( cfg.color || '#2f6fed' );

			var text = document.createElement( 'span' );
			text.textContent = cfg.label || k;

			row.appendChild( cb );
			row.appendChild( swatch );
			row.appendChild( text );
			panel.appendChild( row );
		} );

		syncMaster(); // reflect the starting state (handles "default": false)
		el.appendChild( panel );
	}

	// Optional helper: shows the live zoom level and copies the pixel
	// coordinates of a clicked point. Enable with  | coordhelper = 1
	function enableCoordHelper( viewer, el ) {
		var box = document.createElement( 'div' );
		box.className = 'imap-coords';
		var zoomLine = document.createElement( 'div' );
		var ptLine = document.createElement( 'div' );
		ptLine.textContent = 'Click the map to copy a pin\u2019s coordinates';
		box.appendChild( zoomLine );
		box.appendChild( ptLine );
		el.appendChild( box );

		function showZoom() {
			zoomLine.textContent = 'zoom \u00d7' + relativeZoom( viewer ).toFixed( 2 );
		}
		showZoom();
		viewer.addHandler( 'animation', showZoom );
		viewer.addHandler( 'zoom', showZoom );
		viewer.addHandler( 'resize', showZoom );

		viewer.addHandler( 'canvas-click', function ( e ) {
			if ( !e.quick ) {
				return; // ignore the click that ends a drag
			}
			var img = viewer.world.getItemAt( 0 )
				.viewportToImageCoordinates( viewer.viewport.pointFromPixel( e.position ) );
			var snippet = '{ "x": ' + Math.round( img.x ) +
				', "y": ' + Math.round( img.y ) + ', "title": "", "page": "" }';
			ptLine.textContent = snippet;
			if ( navigator.clipboard ) {
				navigator.clipboard.writeText( snippet ).catch( function () {} );
			}
		} );
	}

	function initMap( el ) {
		var dzi = el.getAttribute( 'data-dzi' );
		if ( !dzi ) {
			return;
		}
		var markersSrc = el.getAttribute( 'data-markers' );
		var height = el.getAttribute( 'data-height' );
		if ( height ) {
			el.style.height = /^\d+$/.test( height ) ? height + 'px' : height;
		}

		var opts = {
			element: el,
			tileSources: dzi,
			prefixUrl: OSD_BASE + 'images/',
			showNavigator: true,
			navigatorPosition: 'BOTTOM_RIGHT',
			showRotationControl: false,
			gestureSettingsMouse: { clickToZoom: false }, // single click is for pins
			maxZoomPixelRatio: 2,
			visibilityRatio: 1,
			constrainDuringPan: true,
			animationTime: 0.6,
			springStiffness: 7
		};
		if ( truthy( el.getAttribute( 'data-cors' ) ) ) {
			opts.crossOriginPolicy = 'Anonymous';
		}

		var viewer = OpenSeadragon( opts );

		viewer.addHandler( 'open', function () {
			if ( truthy( el.getAttribute( 'data-coord-helper' ) ) ) {
				enableCoordHelper( viewer, el );
			}
			fetchMarkers( markersSrc ).then( function ( markerData ) {
				var pins = markerData.pins || [];
				var categories = markerData.categories || {};
				var enabled = {};
				var tracked = addMarkers( viewer, pins, categories );
				var update = makeVisibilityUpdater( viewer, tracked, enabled );
				buildLegend( el, pins, categories, enabled, update ); // sets defaults in `enabled`
				update(); // set the correct state for the starting zoom
				viewer.addHandler( 'animation', update );
				viewer.addHandler( 'animation-finish', update );
				viewer.addHandler( 'zoom', update );
				viewer.addHandler( 'resize', update );
			} ).catch( function ( err ) {
				if ( window.console ) {
					console.warn( 'InteractiveMap: could not load markers \u2014', err );
				}
			} );
		} );
	}

	// wikipage.content fires on first load and again after live previews.
	mw.hook( 'wikipage.content' ).add( function ( $content ) {
		var root = ( $content && $content[ 0 ] ) ? $content[ 0 ] : document;
		var nodes = root.querySelectorAll( '.interactive-map:not([data-imap-ready])' );
		if ( !nodes.length ) {
			return;
		}
		loadOSD().then( function () {
			nodes.forEach( function ( el ) {
				el.setAttribute( 'data-imap-ready', '1' );
				initMap( el );
			} );
		} ).catch( function ( err ) {
			if ( window.console ) {
				console.error( err );
			}
		} );
	} );
}() );