Jump to content

MediaWiki:Gadget-MapPins.js

From Creopedia

Note: After publishing, you may have to bypass your browser's cache to see the changes.

  • Firefox / Safari: Hold Shift while clicking Reload, or press either Ctrl-F5 or Ctrl-R (⌘-R on a Mac)
  • Google Chrome: Press Ctrl-Shift-R (⌘-Shift-R on a Mac)
  • Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5.
/**
 * MapPins gadget - per-type checkboxes for location map marker overlays.
 *
 * Every EvoCreo 1 and 2 location page renders its map as an image with an
 * absolutely positioned overlay of circular pins, plus a single
 * "Toggle map markers" button that hides or shows the whole overlay at once.
 * This gadget adds a row of checkboxes below that button so a reader can turn
 * individual pin types on and off.
 *
 * It reads the pin colours already present in the rendered page, so no article
 * needs any markup change. That is deliberate: published overlays carry
 * hand-placed Fly markers and reworded legend rows, and rewriting them to add
 * type classes would risk moving a pin for no benefit.
 *
 * Published to MediaWiki:Gadget-MapPins.js. The source of truth is
 * creopedia-pages/MediaWiki-Gadget-MapPins.js in the creopedia-wiki repo; edit
 * there, run .claude/tools/mappins-test.py, and re-publish so the two do not
 * drift apart.
 */

( function () {
	'use strict';

	// Marker layers all reuse this one id, so multi-floor pages carry several
	// elements with the same id. An attribute selector returns every one of
	// them, which getElementById would not, and that is what scopes the
	// checkboxes to a single floor.
	var LAYER_SELECTOR = '[id="mw-customcollapsible-mapmarkers"]';

	// A pin is a div carrying only a title and an inline style. This must stay
	// a descendant query, not a child query: jquery.makeCollapsible wraps a
	// plain div collapsible's children in .mw-collapsible-content, and the
	// ordering between that module and this gadget is not guaranteed.
	var PIN_SELECTOR = 'div[title][style]';

	var TOGGLE_SELECTOR = '.mw-customtoggle-mapmarkers';
	var INITIALIZED_CLASS = 'site-mappins-initialized';
	var CONTROLS_CLASS = 'site-mappins-controls';

	/**
	 * Pin colour to legend Type.
	 *
	 * These are the colour constants the page generators emit:
	 * .claude/tools/mapdata.py for EvoCreo 2, legend_gen.py for EvoCreo 1.
	 * Ability is fed by two colours because Dig and Fly share one legend row,
	 * so this maps colour to group rather than listing one colour per group.
	 *
	 * Note the two browns are distinct and easy to confuse: #8a4b12 is Ability
	 * (Dig), #5a3410 is Cave.
	 *
	 * A colour missing from this table is not dropped - it lands in the Other
	 * group and is reported via mw.log.warn. If a future export introduces a
	 * new pin colour, add it here.
	 */
	var COLOUR_GROUPS = {
		'#1c66d6': 'Item',      // mapdata.BLUE
		'#8a4b12': 'Ability',   // mapdata.BROWN, Dig
		'#0f7fa8': 'Ability',   // mapdata.SKY, Fly
		'#1f7a3d': 'Exit',      // mapdata.GREEN
		'#4d4d4d': 'Building',  // mapdata.GREY
		'#5a3410': 'Cave',      // mapdata.CAVE
		'#a3005a': 'Creo',      // mapdata.PRIME
		// legend_gen.INFO_AMBER. EvoCreo 1 only, and it covers two legend types:
		// the generator writes Tablet for QAYEH keys and Sign for everything
		// else, both in this colour, so they share one checkbox.
		'#8a5a00': 'Sign'
	};

	// Legend order: items, abilities, signs, exits, entrances, creo.
	var GROUP_ORDER = [
		'Item', 'Ability', 'Sign', 'Exit', 'Building', 'Cave', 'Creo', 'Other'
	];

	var GROUP_LABELS = {
		Item: 'Items',
		Ability: 'Abilities',
		Exit: 'Exits',
		Building: 'Buildings',
		Cave: 'Caves',
		Creo: 'Creo',
		Sign: 'Signs and tablets',
		Other: 'Other'
	};

	var layerCounter = 0;

	/**
	 * Normalise a computed background colour to a lowercase hex string.
	 *
	 * Reads the computed value rather than string-matching the style attribute
	 * so that whitespace and formatting differences cannot break the lookup.
	 *
	 * @param {string} colour A computed rgb() or rgba() colour.
	 * @return {string|null} Hex such as '#1c66d6', or null if unparseable.
	 */
	function toHex( colour ) {
		var m = /^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/.exec( colour || '' );
		if ( !m ) {
			return null;
		}
		var hex = '#';
		for ( var i = 1; i <= 3; i++ ) {
			var part = Number( m[ i ] ).toString( 16 );
			hex += part.length === 1 ? '0' + part : part;
		}
		return hex;
	}

	/**
	 * Group a layer's pins by legend type.
	 *
	 * @param {NodeList} pins
	 * @return {Object} Map of group name to array of pin elements.
	 */
	function groupPins( pins ) {
		var groups = {};
		var unknown = {};

		Array.prototype.forEach.call( pins, function ( pin ) {
			var hex = toHex( window.getComputedStyle( pin ).backgroundColor );
			var group = COLOUR_GROUPS[ hex ];

			if ( !group ) {
				group = 'Other';
				if ( hex ) {
					unknown[ hex ] = true;
				}
			}

			if ( !groups[ group ] ) {
				groups[ group ] = [];
			}
			groups[ group ].push( pin );
		} );

		var unknownList = Object.keys( unknown );
		if ( unknownList.length ) {
			mw.log.warn(
				'[Gadget-MapPins] Unrecognised pin colour(s), grouped under ' +
				'"Other": ' + unknownList.join( ', ' ) +
				'. Add them to COLOUR_GROUPS.'
			);
		}

		return groups;
	}

	/**
	 * Build one checkbox plus label for a group of pins.
	 *
	 * @param {string} group
	 * @param {Array} pins
	 * @param {number} index Per-layer counter, keeps input ids unique.
	 * @return {HTMLElement}
	 */
	function buildControl( group, pins, index ) {
		var wrap = document.createElement( 'span' );
		wrap.style.cssText =
			'display:inline-flex; align-items:center; gap:4px; white-space:nowrap;';

		var input = document.createElement( 'input' );
		input.type = 'checkbox';
		input.checked = true;
		input.id = 'site-mappins-' + index + '-' + group.toLowerCase();
		input.style.cssText = 'margin:0; cursor:pointer;';

		var label = document.createElement( 'label' );
		label.htmlFor = input.id;
		label.textContent = GROUP_LABELS[ group ] + ' (' + pins.length + ')';
		label.style.cssText = 'cursor:pointer; user-select:none;';

		input.addEventListener( 'change', function () {
			var display = input.checked ? '' : 'none';
			pins.forEach( function ( pin ) {
				pin.style.display = display;
			} );
		} );

		wrap.appendChild( input );
		wrap.appendChild( label );
		return wrap;
	}

	/**
	 * Add the checkbox row for a single marker layer.
	 *
	 * @param {HTMLElement} layer
	 */
	function decorateLayer( layer ) {
		layer.classList.add( INITIALIZED_CLASS );

		var pins = layer.querySelectorAll( PIN_SELECTOR );
		if ( !pins.length ) {
			return;
		}

		// layer -> positioning container -> centring wrapper, which is also
		// where the existing toggle button lives.
		var container = layer.parentNode;
		var wrapper = container && container.parentNode;
		if ( !wrapper ) {
			return;
		}

		var groups = groupPins( pins );
		var index = layerCounter++;

		// inline-flex, not flex, so the row sits on the same line as the toggle
		// button rather than below it. Both are inline-level and both carry
		// margin-top:6px, so they rise and fall together; baseline alignment
		// lines the label text up with the button's label. max-width keeps it
		// inside the container, and when there is no room left on the line the
		// whole row drops below the button, which is the wanted narrow-screen
		// behaviour.
		var row = document.createElement( 'div' );
		row.className = CONTROLS_CLASS;
		row.style.cssText = 'display:inline-flex; flex-wrap:wrap; ' +
			'align-items:center; justify-content:center; gap:4px 14px; ' +
			'margin-top:6px; margin-left:14px; max-width:100%; ' +
			'font-size:0.875rem; line-height:1.4;';

		GROUP_ORDER.forEach( function ( group ) {
			if ( groups[ group ] && groups[ group ].length ) {
				row.appendChild( buildControl( group, groups[ group ], index ) );
			}
		} );

		if ( !row.childNodes.length ) {
			return;
		}

		// Keep the existing master toggle on top with the checkboxes beneath
		// it. Scoped to this wrapper so a multi-floor page puts each row under
		// its own floor's button.
		var toggle = wrapper.querySelector( TOGGLE_SELECTOR );
		if ( toggle && toggle.parentNode === wrapper ) {
			wrapper.insertBefore( row, toggle.nextSibling );
		} else {
			wrapper.appendChild( row );
		}
	}

	function init() {
		var layers = document.querySelectorAll(
			LAYER_SELECTOR + ':not(.' + INITIALIZED_CLASS + ')'
		);
		Array.prototype.forEach.call( layers, decorateLayer );
	}

	// Initialize on initial page load.
	mw.hook( 'wikipage.content' ).add( init );

	// Re-initialize after a VisualEditor edit.
	mw.loader.using( 'ext.visualEditor.desktopArticleTarget.init' ).then(
		function () {
			mw.hook( 'postEdit' ).add( init );
		}
	);
}() );