MediaWiki:Gadget-MapPins.js
Appearance
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 );
}
);
}() );