MediaWiki:Common.js: Difference between revisions

From No Way Out Wiki
No edit summary
Tag: Reverted
No edit summary
 
(11 intermediate revisions by the same user not shown)
Line 1: Line 1:
// ImageMap Highlighter - only load on pages that have the highlighter div
/* ImageMap Highlighter (content-box scaled + offset-aware)
if ($('.imageMapHighlighter').length > 0) {
  - Highlights mapped regions and syncs with legend
   /*
   - Handles devicePixelRatio, float wrappers, borders/padding on <img>
written by user:קיפודנחש on hewiki.
  Public domain; based on user:קיפודנחש (hewiki)
released as public domain. you may copy, modify and redistribute any way you want.
no guaranty or waranty of any kind, explicit or implied.
*/
*/
$(document).ready(function() {
(function ($, mw) {
  'use strict';


    var
  if (window.IMH_GRID_LOADED) return;
//add this class to all elements created by the script. the reason is that we call the script again on
  window.IMH_GRID_LOADED = true;
//window resize, and use the class to remove all the "artefacts" we created in the previous run.
myClassName = 'imageMapHighlighterArtefacts'
, liHighlightClass = 'liHighlighting'
, specialAreaMark = 'area_mark'
, specialLiClassesMark = 'list_classes'
// "2d context" attributes used for highlighting.
, areaHighLighting = {fillStyle: 'rgba(0,0,0,0.35)', strokeStyle: 'yellow', lineJoin: 'round', lineWidth: 2}
//every imagemap that wants highlighting, should reside in a div of this 'class':
, hilightDivMarker = '.imageMapHighlighter'
// specifically for wikis - redlinks tooltip adds this message
, he = mw && mw.config && mw.config.get('wgUserLanguage') == 'he'
, expandLegend = he ? 'הצגת מקרא' : 'ּShow Legend'
, collapseLegend = he ? 'הסתרת המקרא' : 'Hide Legend'
, liEvents = 'mouseover mouseout focus blur'
, mouseEvents = 'mouseover mouseout'
, highlightEvents = ['mouseover', 'focus']
;


  // ---- Config ----
  var ART = 'imageMapHighlighterArtefacts';
  var WRAP_CLASS = 'im-wrap';
  var liHighlightClass = 'liHighlighting';
  var specialAreaMark = 'area_mark';
  var specialLiClassesMark = 'list_classes';
  var hilightDivMarker = '.imageMapHighlighter';
  var areaHighLighting = { fillStyle: 'rgba(0,0,0,0.35)', strokeStyle: 'yellow', lineJoin: 'round', lineWidth: 2 };
  var he = mw && mw.config && mw.config.get('wgUserLanguage') === 'he';
  var expandLegend = he ? 'הצגת מקרא' : 'Show legend';
  var collapseLegend = he ? 'הסתרת המקרא' : 'Hide legend';
  var liEvents = 'mouseover mouseout focus blur';
  var mouseEvents = 'mouseover mouseout';
  var highlightEvents = ['mouseover', 'focus'];


function drawMarker(context, areas) { // mthis is where the magic is done.
  // ---- Drawing (scaled to content box, with offset) ----
  function drawMarker(context, areas) {
    var sx = context._imhSx || 1;
    var sy = context._imhSy || 1;
    var ox = context._imhOffX || 0; // left padding+border of <img>
    var oy = context._imhOffY || 0; // top padding+border of <img>


function drawPoly(coords) {
    function drawPolyScaled(coords) {
context.moveTo(coords.shift(), coords.shift());
      context.moveTo(coords[0] * sx + ox, coords[1] * sy + oy);
while (coords.length)
      for (var i = 2; i < coords.length; i += 2) {
context.lineTo(coords.shift(), coords.shift());
        context.lineTo(coords[i] * sx + ox, coords[i + 1] * sy + oy);
}
      }
    }
 
    for (var i = 0; i < areas.length; i++) {
      var a = areas[i];
      var coords = (a.coords || '').split(',').map(Number);
      if (!coords.length) continue;
 
      context.beginPath();
      switch (a.shape) {
        case 'circle': {
          var cx = coords[0] * sx + ox;
          var cy = coords[1] * sy + oy;
          var r  = coords[2];
          if (context.ellipse) {
            context.ellipse(cx, cy, r * sx, r * sy, 0, 0, Math.PI * 2);
          } else {
            var rMean = r * Math.sqrt((sx * sx + sy * sy) / 2);
            context.arc(cx, cy, rMean, 0, Math.PI * 2);
          }
          break;
        }
        case 'poly':
          drawPolyScaled(coords);
          break;
        case 'rect':
        case null:
        case '':
        default:
          drawPolyScaled([coords[0], coords[1], coords[0], coords[3], coords[2], coords[3], coords[2], coords[1]]);
      }
      context.closePath();
      context.stroke();
      context.fill();
    }
  }


for (var i in areas) {
  // ---- Legend hover handler ----
var coords = areas[i].coords.split(',');
  function mouseAction(e) {
context.beginPath();
    var $this = $(this);
switch (areas[i].shape) {
    var activate = highlightEvents.indexOf(e.type) >= 0;
case 'rect':
    var cap = ($this.find('.im-label a').text() || $this.find('.im-label').text() || $this.text()).trim();
case null:
    var $legend = $this.closest('.im-legend');
case '': // imagemap extension stopped setting shape="rect" for rectangles in mw version 1.45.0-wmf.7
    var context = $legend.data('context');
drawPoly([coords[0], coords[1], coords[0], coords[3], coords[2], coords[3], coords[2], coords[1]]);
    var special = $legend.data(specialAreaMark);
break;
case 'circle': context.arc(coords[0],coords[1],coords[2],0,Math.PI*2);  break;//x,y,r,startAngle,endAngle
case 'poly': drawPoly(coords); break;
}
context.closePath();
context.stroke();
context.fill();
}
}


function mouseAction(e, fromMap) {
    $this.toggleClass(liHighlightClass, activate);
var $this = $(this),
    if (!context) return;
activate = $.inArray(e.type, highlightEvents) >= 0,
caption = $this.text(),
ol = $this.parent(),
context = ol.data('context'),
special = ol.data(specialAreaMark);
$this.toggleClass(liHighlightClass, activate); // mark/unmark the list item.
context.clearRect(0, 0, context.canvas.width, context.canvas.height); // prepare for a new day.
ol.find('li').each(function() {
var $li = $(this);
var licap = $li.text();
var param;
if (activate && licap === caption) { // highlight!!!
param = special && special.hover  || areaHighLighting;
} else {
param = special && special.nover && (special.nover[licap] || special.nover.default);
}
if (param) {
$.extend(context, param);
drawMarker(context, $li.data('areas'));
}
});
}


function handleOneMap() {
    context.clearRect(0, 0, context.canvas.width, context.canvas.height);
var img = $(this),
w = this.width,
h = this.height,
map = img.parent().siblings('map:first'),
dims = {position: 'absolute', width: w + 'px', height: h + 'px', border: 0, top:0, left:0},
specialHighlight = img.closest(hilightDivMarker).data(specialAreaMark),
specialLiClasses = img.closest(hilightDivMarker).data(specialLiClassesMark);
if (!($('area', map).length))
return; //not an imagemap, or map with 0 areas.


var jcanvas = $('<canvas>', {'class': myClassName})
    $legend.find('.im-row').each(function () {
.css(dims)
      var $row = $(this);
.attr({width: w, height: h});
      var rowCap = ($row.find('.im-label a').text() || $row.find('.im-label').text() || $row.text()).trim();
var bgimg = $('<img>', {'class': myClassName, src: img.attr('src')})
      var param = (activate && rowCap === cap)
.css(dims);//completely inert image. this is what we see.
        ? (special && special.hover) || areaHighLighting
var context = $.extend(jcanvas[0].getContext("2d"), areaHighLighting);
        : (special && special.nover && (special.nover[rowCap] || special.nover.default));
      if (param) {
// this is where the magic is done: prepare a sandwich of the inert bgimg at the bottom,
        $.extend(context, param);
// the canvas above it, and the original image on top,
        drawMarker(context, $row.data('areas') || []);
// so canvas won't steal the mouse events.
      }
// pack them all TIGHTLY in a newly minted "relative" div, so when page chnage
    });
// (other scripts adding elements, window resize etc.), canvas and imagese remain aligned.
  }
var div = $('<div>').css({position: 'relative', width: w + 'px', height: h + 'px'});
img.before(div); // put the div just above the image, and ...
div.append(bgimg) // place the background image in the div
.append(jcanvas)// and the canvas. both are "absolute", so they don't occupy space in the div
.append(img); // now yank the original image from the window and place it on the div.
img.fadeTo(1, 0); // make the image transparent - we see canvas and bgimg through it.
// the original, now transparent image is creating our mouse events
var ol = $('<ol>', {'class': myClassName})
.css({clear: 'both', margin: 0, listStyle: 'none', maxWidth: w + 'px', float: 'left', position: 'relative'})
.attr({'data-expandtext' : expandLegend, 'data-collapsetext': collapseLegend})
.data(specialAreaMark, specialHighlight)
.data('context', context);


// ol below image, hr below ol. original caption pushed below hr.
  // Utility to parse pixel strings safely
div.after($('<hr>', {'class': myClassName}).css('clear', 'both'))
  function px(v) { var n = parseFloat(v); return isNaN(n) ? 0 : n; }
.after(ol);
var lis = {}; //collapse areas with same caption to one list item
var someli; // select arbitrary one
$('area', map).each(function() {
var html = mw.html.escape(this.title);
var li = lis[html]; // title already met? use the same li
if (!li) { //no? create a new one.
var href = this.href;
lis[html] = li = $('<li>', {'class': myClassName})
.append($('<a>', {href: href}).html(html)) 
.on(liEvents, mouseAction)
.data('areas', [])
.addClass(specialLiClasses && (specialLiClasses[html] || specialLiClasses['default']))
.appendTo(ol);
}
li.data('areas').push(this); //add the area to the li
someli = li; // whichever - we just want one...
$(this).on(mouseEvents, function(e) {
li.trigger(e.type);
});
});
if (someli) someli.trigger('mouseout');
mw.loader.using('jquery.makeCollapsible').then( function () {
ol.addClass('mw-collapsed')
.makeCollapsible();
});
}


function init() {
  // ---- Build one imagemap ----
mw.util.addCSS('li.' + myClassName + '{white-space:nowrap;border:solid 1px transparent;border-radius:6px;}\n' + //css for li element
  function handleOneMap() {
'li.' +  myClassName + '.' + liHighlightClass + '{background-color:yellow;border-color:green;}\n' + //css for highlighted li element.
    var $img = $(this);
'.rtl li.' + myClassName + '{float: right; margin-left: 3em;}\n' +
    if ($img.data('imhProcessed')) return;
'.ltr li.' + myClassName + '{float: left; margin-right: 3em;}');
$(hilightDivMarker+ ' img').each(handleOneMap);
}


//has at least one "imagehighlight" div, and canvas-capable browser:
    var el = $img[0];
if ( $(hilightDivMarker).length && $('<canvas>')[0].getContext )
    if (!el.complete || !el.naturalWidth) { $img.one('load', handleOneMap); return; }
mw.loader.using( ['jquery.makeCollapsible', 'mediawiki.util'] ).done( init );
});
}


/* TOC JS */
    // Find <map>
    var $map = (function () {
      var m = $img.parent().siblings('map:first');
      if (m.length) return m;
      var usemap = $img.attr('usemap');
      if (usemap) {
        var name = usemap.replace(/^#/, '');
        m = $('map[name="' + name + '"]');
        if (m.length) return m.first();
      }
      return $img.closest(hilightDivMarker).find('map:first');
    })();
    if (!$map.length || !$map.find('area').length) return;


    // Natural & rendered sizes
    var natW = el.naturalWidth, natH = el.naturalHeight;
    var rect = el.getBoundingClientRect();
    var cssW = rect.width, cssH = rect.height;


/* Move #toc into Timeless' right rail and make it behave responsively */
    // Compute content-box size & offset (exclude img borders/padding)
mw.hook('wikipage.content').add(function ($c) {
    var cs = window.getComputedStyle(el);
  var $toc = $c.find('#toc');
    var offX = px(cs.borderLeftWidth) + px(cs.paddingLeft);
  if (!$toc.length) return;
    var offY = px(cs.borderTopWidth) + px(cs.paddingTop);
    var innerW = cssW - offX - (px(cs.borderRightWidth) + px(cs.paddingRight));
    var innerH = cssH - offY - (px(cs.borderBottomWidth) + px(cs.paddingBottom));
    if (innerW <= 0 || innerH <= 0) { innerW = cssW; innerH = cssH; offX = 0; offY = 0; }


  var $rail = $('#mw-related-navigation');
    // Scale from imagemap coords (natural) → rendered content pixels
  if (!$rail.length) return;
    var sx = innerW / natW;
    var sy = innerH / natH;


  // Create a sidebar section for the TOC if it doesn't exist
    // Prepare wrapper (inherit float & margins so layout doesn’t shift)
  var $chunk = $rail.find('.sidebar-chunk.toc-chunk');
    var $wrap = $img.closest('.' + WRAP_CLASS);
   if (!$chunk.length) {
    if (!$wrap.length) {
     $chunk = $('<div class="sidebar-chunk toc-chunk"></div>').prependTo($rail);
      $wrap = $('<div>', { 'class': WRAP_CLASS }).css({ position: 'relative' });
  }
      // copy float & margins from img
  $chunk.prepend($toc);       // move TOC into the rail
      var fl = (cs.float || cs.cssFloat || 'none');
      if (fl && fl !== 'none') $wrap.css('float', fl);
      ['marginTop','marginRight','marginBottom','marginLeft'].forEach(function (p) { $wrap.css(p, cs[p]); });
      $img.before($wrap);
      $wrap.append($img);
      $img.css({ margin: 0, display: 'block' }); // remove baseline gap
    }
 
    // Size wrapper to the img’s border box
    $wrap.css({ width: cssW + 'px', height: cssH + 'px' });
 
    // Clean prior artifacts
    $wrap.find('canvas.' + ART + ', img.' + ART).remove();
    $wrap.nextAll('div.im-legend, ol.' + ART + ', hr.' + ART + ', .mw-collapsible-toggle.im-toggle').remove();
 
    // Canvas (DPR-aware) + inert bg image
    var dpr = window.devicePixelRatio || 1;
    var layerCSS = { position: 'absolute', top: 0, left: 0, width: cssW + 'px', height: cssH + 'px', border: 0 };
    var $bg = $('<img>', { 'class': ART, src: $img.attr('src') }).css($.extend({ zIndex: 0, display: 'block' }, layerCSS));
    var $cv = $('<canvas>', { 'class': ART })
      .css($.extend({ zIndex: 1 }, layerCSS))
      .attr({ width: Math.round(cssW * dpr), height: Math.round(cssH * dpr) });
 
    var ctx = $cv[0].getContext('2d');
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);   // draw in CSS pixels
    $.extend(ctx, areaHighLighting);
    ctx._imhSx = sx; ctx._imhSy = sy;        // scale factors to content
    ctx._imhOffX = offX; ctx._imhOffY = offY; // left/top offsets of content
 
    // Original image stays on top (transparent) to keep mouse events
    $img.css($.extend({ zIndex: 2, opacity: 0, display: 'block' }, layerCSS));
    $wrap.append($bg).append($cv);
    $img.data('imhProcessed', true);
 
    // ---- Legend (grid; styled via site CSS) ----
    var $legend = $('<div>', {
      'class': ART + ' im-legend mw-collapsible mw-collapsed'
    }).attr({ 'data-expandtext': expandLegend, 'data-collapsetext': collapseLegend })
      .data(specialAreaMark, $img.closest(hilightDivMarker).data(specialAreaMark))
      .data('context', ctx);
 
     var $grid = $('<div class="im-grid" role="list"></div>');
    $legend.append($grid);
 
    // Insert below image
    $wrap.after($('<hr>', { 'class': ART }).css('clear', 'both')).after($legend);
 
    // Build rows (collapse duplicate titles)
    var rowsByTitle = Object.create(null);
    var idx = 0, $someRow;
    var specialLiClasses = $img.closest(hilightDivMarker).data(specialLiClassesMark);


  // Keep the "hide" toggle in sync with a compact class
     $map.find('area').each(function () {
  function syncCollapsed() {
      var title = this.title || ('Area ' + (idx + 1));
     var collapsed = $toc.children('ul:first').is(':hidden');
      var href = this.href || '#';
    $toc.toggleClass('toc--collapsed', collapsed)
      var key = title;
        .toggleClass('toc--trim', collapsed);  // hide sublevels when collapsed
      var $row = rowsByTitle[key];
  }
  syncCollapsed();
  $toc.on('click', '.toctoggle a, .toctogglelabel, .toctogglelink', function () {
    setTimeout(syncCollapsed, 0);
  });


  // Optional: on small screens, move the TOC back into the article flow
      if (!$row) {
  function place() {
        idx++;
    if (window.matchMedia('(max-width:980px)').matches) {
        $row = $('<div>', { 'class': ART + ' im-row', 'role': 'listitem', 'tabindex': '0' })
      if (!$toc.closest('.mw-parser-output').length) {
          .append($('<span class="im-num">').text(idx))
         $('.mw-parser-output').first().prepend($toc);
          .append($('<span class="im-label">').append($('<a>', { href: href }).text(title)))
          .on(liEvents, mouseAction)
          .data('areas', []);
         if (specialLiClasses) {
          var extra = specialLiClasses[title] || specialLiClasses['default'];
          if (extra) $row.addClass(extra);
        }
        rowsByTitle[key] = $row;
        $grid.append($row);
       }
       }
    } else {
      if (!$toc.closest('#mw-related-navigation').length) {
        $chunk.prepend($toc);
      }
    }
  }
  place();
  $(window).on('resize', place);
});


/* Timeless: Move/merge "Page tools" + "More" into the LEFT rail */
      $row.data('areas').push(this);
mw.hook('wikipage.content').add(function () {
      $someRow = $row;
  if (mw.config.get('skin') !== 'timeless') return;
      $(this).on(mouseEvents, function (e) { $row.trigger(e.type); });
    });


  var $left  = $('#mw-site-navigation');
    if ($someRow) $someRow.trigger('mouseout');
  var $right = $('#mw-related-navigation');
  if (!$left.length) return;


  // Ensure a single "Page tools" portlet in the left rail
    // Collapsible + move toggle inside the legend box
  function ensurePageTools() {
     mw.loader.using('jquery.makeCollapsible').then(function () {
     var $pt = $('#p-tb,#p-tools').first();
      $legend.makeCollapsible();
    if (!$pt.length) {
       var $toggle = $legend.prev('.mw-collapsible-toggle');
       $pt = $(
       if (!$toggle.length) $toggle = $legend.next('.mw-collapsible-toggle');
        '<div id="p-tb" class="sidebar-chunk">' +
       if ($toggle.length) { $toggle.addClass('im-toggle'); $legend.prepend($toggle); }
          '<h2>Page tools</h2>' +
     });
          '<div class="body"><ul></ul></div>' +
        '</div>'
      ).prependTo($left);
    } else {
       if (!$pt.closest('#mw-site-navigation').length) $pt.prependTo($left);
      $pt.addClass('sidebar-chunk');
       if (!$pt.find('.body').length) $pt.wrapInner('<div class="body"></div>');
      if (!$pt.find('ul').length) $pt.find('.body').append('<ul></ul>');
     }
    return $pt;
   }
   }
  var $tools = ensurePageTools();


   // Move all <li> items from a container into Page tools, then remove the container
   // ---- Init ----
   function drain(container) {
   function init($scope) {
     var $c = $(container);
     var $root = ($scope && $scope.length) ? $scope : $(document);
    if (!$c.length) return;
     var $targets = $root.find(hilightDivMarker + ' img');
     var $ul = $c.find('ul').first();
     if (!$targets.length) return;
     if ($ul.length) $ul.children('li').appendTo($tools.find('ul'));
     $targets.each(handleOneMap);
     $c.remove();
   }
   }


  // Known IDs that can hold "More"/actions/tool items
   $(function () { init(); });
  ['#p-cactions', '#p-actions', '#p-more', '#p-page-tools', '#p-tb-sidebar'].forEach(drain);
  mw.hook('wikipage.content').add(function ($c) { init($c); });
 
  // Right-rail chunks by heading text
   $right.find('.sidebar-chunk').each(function () {
    var title = $(this).children('h2,h3,.sidebar-title').first().text().trim().toLowerCase();
    if (title === 'more' || title === 'page tools') drain(this);
  });


  // Some builds use vector menus inside the sidebars
})(jQuery, mw);
  $right.find('.vector-menu').each(function () {
    var txt = $(this).find('.vector-menu-heading, .menu .menu-label').first().text().trim().toLowerCase();
    if (txt === 'more' || txt === 'page tools') drain(this);
  });
});

Latest revision as of 12:19, 29 September 2025

/* ImageMap Highlighter (content-box scaled + offset-aware)
   - Highlights mapped regions and syncs with legend
   - Handles devicePixelRatio, float wrappers, borders/padding on <img>
   Public domain; based on user:קיפודנחש (hewiki)
*/
(function ($, mw) {
  'use strict';

  if (window.IMH_GRID_LOADED) return;
  window.IMH_GRID_LOADED = true;

  // ---- Config ----
  var ART = 'imageMapHighlighterArtefacts';
  var WRAP_CLASS = 'im-wrap';
  var liHighlightClass = 'liHighlighting';
  var specialAreaMark = 'area_mark';
  var specialLiClassesMark = 'list_classes';
  var hilightDivMarker = '.imageMapHighlighter';
  var areaHighLighting = { fillStyle: 'rgba(0,0,0,0.35)', strokeStyle: 'yellow', lineJoin: 'round', lineWidth: 2 };
  var he = mw && mw.config && mw.config.get('wgUserLanguage') === 'he';
  var expandLegend = he ? 'הצגת מקרא' : 'Show legend';
  var collapseLegend = he ? 'הסתרת המקרא' : 'Hide legend';
  var liEvents = 'mouseover mouseout focus blur';
  var mouseEvents = 'mouseover mouseout';
  var highlightEvents = ['mouseover', 'focus'];

  // ---- Drawing (scaled to content box, with offset) ----
  function drawMarker(context, areas) {
    var sx = context._imhSx || 1;
    var sy = context._imhSy || 1;
    var ox = context._imhOffX || 0; // left padding+border of <img>
    var oy = context._imhOffY || 0; // top padding+border of <img>

    function drawPolyScaled(coords) {
      context.moveTo(coords[0] * sx + ox, coords[1] * sy + oy);
      for (var i = 2; i < coords.length; i += 2) {
        context.lineTo(coords[i] * sx + ox, coords[i + 1] * sy + oy);
      }
    }

    for (var i = 0; i < areas.length; i++) {
      var a = areas[i];
      var coords = (a.coords || '').split(',').map(Number);
      if (!coords.length) continue;

      context.beginPath();
      switch (a.shape) {
        case 'circle': {
          var cx = coords[0] * sx + ox;
          var cy = coords[1] * sy + oy;
          var r  = coords[2];
          if (context.ellipse) {
            context.ellipse(cx, cy, r * sx, r * sy, 0, 0, Math.PI * 2);
          } else {
            var rMean = r * Math.sqrt((sx * sx + sy * sy) / 2);
            context.arc(cx, cy, rMean, 0, Math.PI * 2);
          }
          break;
        }
        case 'poly':
          drawPolyScaled(coords);
          break;
        case 'rect':
        case null:
        case '':
        default:
          drawPolyScaled([coords[0], coords[1], coords[0], coords[3], coords[2], coords[3], coords[2], coords[1]]);
      }
      context.closePath();
      context.stroke();
      context.fill();
    }
  }

  // ---- Legend hover handler ----
  function mouseAction(e) {
    var $this = $(this);
    var activate = highlightEvents.indexOf(e.type) >= 0;
    var cap = ($this.find('.im-label a').text() || $this.find('.im-label').text() || $this.text()).trim();
    var $legend = $this.closest('.im-legend');
    var context = $legend.data('context');
    var special = $legend.data(specialAreaMark);

    $this.toggleClass(liHighlightClass, activate);
    if (!context) return;

    context.clearRect(0, 0, context.canvas.width, context.canvas.height);

    $legend.find('.im-row').each(function () {
      var $row = $(this);
      var rowCap = ($row.find('.im-label a').text() || $row.find('.im-label').text() || $row.text()).trim();
      var param = (activate && rowCap === cap)
        ? (special && special.hover) || areaHighLighting
        : (special && special.nover && (special.nover[rowCap] || special.nover.default));
      if (param) {
        $.extend(context, param);
        drawMarker(context, $row.data('areas') || []);
      }
    });
  }

  // Utility to parse pixel strings safely
  function px(v) { var n = parseFloat(v); return isNaN(n) ? 0 : n; }

  // ---- Build one imagemap ----
  function handleOneMap() {
    var $img = $(this);
    if ($img.data('imhProcessed')) return;

    var el = $img[0];
    if (!el.complete || !el.naturalWidth) { $img.one('load', handleOneMap); return; }

    // Find <map>
    var $map = (function () {
      var m = $img.parent().siblings('map:first');
      if (m.length) return m;
      var usemap = $img.attr('usemap');
      if (usemap) {
        var name = usemap.replace(/^#/, '');
        m = $('map[name="' + name + '"]');
        if (m.length) return m.first();
      }
      return $img.closest(hilightDivMarker).find('map:first');
    })();
    if (!$map.length || !$map.find('area').length) return;

    // Natural & rendered sizes
    var natW = el.naturalWidth, natH = el.naturalHeight;
    var rect = el.getBoundingClientRect();
    var cssW = rect.width, cssH = rect.height;

    // Compute content-box size & offset (exclude img borders/padding)
    var cs = window.getComputedStyle(el);
    var offX = px(cs.borderLeftWidth) + px(cs.paddingLeft);
    var offY = px(cs.borderTopWidth)  + px(cs.paddingTop);
    var innerW = cssW - offX - (px(cs.borderRightWidth) + px(cs.paddingRight));
    var innerH = cssH - offY - (px(cs.borderBottomWidth) + px(cs.paddingBottom));
    if (innerW <= 0 || innerH <= 0) { innerW = cssW; innerH = cssH; offX = 0; offY = 0; }

    // Scale from imagemap coords (natural) → rendered content pixels
    var sx = innerW / natW;
    var sy = innerH / natH;

    // Prepare wrapper (inherit float & margins so layout doesn’t shift)
    var $wrap = $img.closest('.' + WRAP_CLASS);
    if (!$wrap.length) {
      $wrap = $('<div>', { 'class': WRAP_CLASS }).css({ position: 'relative' });
      // copy float & margins from img
      var fl = (cs.float || cs.cssFloat || 'none');
      if (fl && fl !== 'none') $wrap.css('float', fl);
      ['marginTop','marginRight','marginBottom','marginLeft'].forEach(function (p) { $wrap.css(p, cs[p]); });
      $img.before($wrap);
      $wrap.append($img);
      $img.css({ margin: 0, display: 'block' }); // remove baseline gap
    }

    // Size wrapper to the img’s border box
    $wrap.css({ width: cssW + 'px', height: cssH + 'px' });

    // Clean prior artifacts
    $wrap.find('canvas.' + ART + ', img.' + ART).remove();
    $wrap.nextAll('div.im-legend, ol.' + ART + ', hr.' + ART + ', .mw-collapsible-toggle.im-toggle').remove();

    // Canvas (DPR-aware) + inert bg image
    var dpr = window.devicePixelRatio || 1;
    var layerCSS = { position: 'absolute', top: 0, left: 0, width: cssW + 'px', height: cssH + 'px', border: 0 };
    var $bg = $('<img>', { 'class': ART, src: $img.attr('src') }).css($.extend({ zIndex: 0, display: 'block' }, layerCSS));
    var $cv = $('<canvas>', { 'class': ART })
      .css($.extend({ zIndex: 1 }, layerCSS))
      .attr({ width: Math.round(cssW * dpr), height: Math.round(cssH * dpr) });

    var ctx = $cv[0].getContext('2d');
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);   // draw in CSS pixels
    $.extend(ctx, areaHighLighting);
    ctx._imhSx = sx; ctx._imhSy = sy;         // scale factors to content
    ctx._imhOffX = offX; ctx._imhOffY = offY; // left/top offsets of content

    // Original image stays on top (transparent) to keep mouse events
    $img.css($.extend({ zIndex: 2, opacity: 0, display: 'block' }, layerCSS));
    $wrap.append($bg).append($cv);
    $img.data('imhProcessed', true);

    // ---- Legend (grid; styled via site CSS) ----
    var $legend = $('<div>', {
      'class': ART + ' im-legend mw-collapsible mw-collapsed'
    }).attr({ 'data-expandtext': expandLegend, 'data-collapsetext': collapseLegend })
      .data(specialAreaMark, $img.closest(hilightDivMarker).data(specialAreaMark))
      .data('context', ctx);

    var $grid = $('<div class="im-grid" role="list"></div>');
    $legend.append($grid);

    // Insert below image
    $wrap.after($('<hr>', { 'class': ART }).css('clear', 'both')).after($legend);

    // Build rows (collapse duplicate titles)
    var rowsByTitle = Object.create(null);
    var idx = 0, $someRow;
    var specialLiClasses = $img.closest(hilightDivMarker).data(specialLiClassesMark);

    $map.find('area').each(function () {
      var title = this.title || ('Area ' + (idx + 1));
      var href = this.href || '#';
      var key = title;
      var $row = rowsByTitle[key];

      if (!$row) {
        idx++;
        $row = $('<div>', { 'class': ART + ' im-row', 'role': 'listitem', 'tabindex': '0' })
          .append($('<span class="im-num">').text(idx))
          .append($('<span class="im-label">').append($('<a>', { href: href }).text(title)))
          .on(liEvents, mouseAction)
          .data('areas', []);
        if (specialLiClasses) {
          var extra = specialLiClasses[title] || specialLiClasses['default'];
          if (extra) $row.addClass(extra);
        }
        rowsByTitle[key] = $row;
        $grid.append($row);
      }

      $row.data('areas').push(this);
      $someRow = $row;
      $(this).on(mouseEvents, function (e) { $row.trigger(e.type); });
    });

    if ($someRow) $someRow.trigger('mouseout');

    // Collapsible + move toggle inside the legend box
    mw.loader.using('jquery.makeCollapsible').then(function () {
      $legend.makeCollapsible();
      var $toggle = $legend.prev('.mw-collapsible-toggle');
      if (!$toggle.length) $toggle = $legend.next('.mw-collapsible-toggle');
      if ($toggle.length) { $toggle.addClass('im-toggle'); $legend.prepend($toggle); }
    });
  }

  // ---- Init ----
  function init($scope) {
    var $root = ($scope && $scope.length) ? $scope : $(document);
    var $targets = $root.find(hilightDivMarker + ' img');
    if (!$targets.length) return;
    $targets.each(handleOneMap);
  }

  $(function () { init(); });
  mw.hook('wikipage.content').add(function ($c) { init($c); });

})(jQuery, mw);