Toggle menu
Toggle preferences menu
Toggle personal menu
Not logged in
Your IP address will be publicly visible if you make any edits.

Join the Playtest on Steam Now: SpiritVale

MediaWiki:Common.js: Difference between revisions

MediaWiki interface page
No edit summary
No edit summary
Line 1: Line 1:
/* Common.js — Spirit Vale Wiki
/* Common.js — Spirit Vale Wiki
   Standalone Module: Definitions (v1)
   Phase 4.1 rebuild (incremental)
  - Event-delegated tooltips for .sv-def
  - Desktop: hover + follow cursor
  - Mobile/touch: tap to pin near tap point
  - Blank definitions do nothing (and will close any open tooltip)
  - Future-ready: data-sv-def-link navigation support


   Markup contract (produced by Module:Definitions / Template:def):
   Currently included (standalone modules):
    <span class="sv-def"
  1) Definitions (v1)
          data-sv-def-domain="Stat"
  2) Level Selector / Descriptions (data-series)
          data-sv-def-key="Vit"
 
          data-sv-def-tip="Tooltip text (optional)"
  Not included yet:
          data-sv-def-link="Page Title (optional)">
  - Popups
      ...
  - Tabs
    </span>
*/
*/


(function () {
(function () {
  /* ================================================================== */
  /* MODULE: Definitions (v1)                                          */
  /* ================================================================== */
   // Namespace + per-module guard (no “41”).
   // Namespace + per-module guard (no “41”).
   var SV = (window.SV = window.SV || {});
   var SV = (window.SV = window.SV || {});
Line 78: Line 76:
     var t = defEl.getAttribute(TIP_ATTR);
     var t = defEl.getAttribute(TIP_ATTR);
     if (t == null) return "";
     if (t == null) return "";
    // Treat pure-whitespace as empty.
     t = String(t);
     t = String(t);
     if (!t.replace(/\s+/g, "")) return "";
     if (!t.replace(/\s+/g, "")) return "";
Line 100: Line 97:
     el.setAttribute("aria-hidden", "true");
     el.setAttribute("aria-hidden", "true");


     // Minimal inline safety styling (CSS can override via .sv-def-tip).
     // Conservative inline styling (CSS can override via .sv-def-tip).
    // This is intentionally conservative so it still works even if site CSS
    // hasn’t loaded yet.
     el.style.position = "fixed";
     el.style.position = "fixed";
     el.style.zIndex = "99999";
     el.style.zIndex = "99999";
Line 145: Line 140:
     if (!el) return;
     if (!el) return;


    // First, ensure it’s measurable.
    // (If we just turned it on, width/height might be 0 until next frame.)
     var vw = window.innerWidth || document.documentElement.clientWidth || 0;
     var vw = window.innerWidth || document.documentElement.clientWidth || 0;
     var vh = window.innerHeight || document.documentElement.clientHeight || 0;
     var vh = window.innerHeight || document.documentElement.clientHeight || 0;


    // Gap rules:
    // - hover: larger vertical gap so cursor doesn’t cover it
    // - pinned: tighter but still separated from the tap point
     var gapY = state.pinned ? 10 : 16;
     var gapY = state.pinned ? 10 : 16;
     var gapX = 12;
     var gapX = 12;


    // Two-pass: set an initial position, then refine once we have rect.
     el.style.left = "0px";
     el.style.left = "0px";
     el.style.top = "0px";
     el.style.top = "0px";


    // Defer measuring to allow layout to settle.
     requestAnimationFrame(function () {
     requestAnimationFrame(function () {
       if (!state.visible) return;
       if (!state.visible) return;
Line 168: Line 156:
       var h = r.height || 60;
       var h = r.height || 60;


      // Prefer above cursor/tap.
       var left = clientX - w / 2;
       var left = clientX - w / 2;
       var top = clientY - h - gapY;
       var top = clientY - h - gapY;


      // Clamp horizontally.
       left = clamp(left, gapX, Math.max(gapX, vw - w - gapX));
       left = clamp(left, gapX, Math.max(gapX, vw - w - gapX));


      // If not enough space above, flip below.
       if (top < gapY) top = clientY + gapY;
       if (top < gapY) top = clientY + gapY;


      // Clamp vertically.
       top = clamp(top, gapY, Math.max(gapY, vh - h - gapY));
       top = clamp(top, gapY, Math.max(gapY, vh - h - gapY));


Line 196: Line 180:
     state.lastY = clientY || 0;
     state.lastY = clientY || 0;


    // Text-only (safe).
     el.textContent = text;
     el.textContent = text;


    // Pointer events only when pinned (so pinned tooltip can be interacted with later if needed).
     el.style.pointerEvents = state.pinned ? "auto" : "none";
     el.style.pointerEvents = state.pinned ? "auto" : "none";
     el.classList.toggle("sv-def-tip--pinned", state.pinned);
     el.classList.toggle("sv-def-tip--pinned", state.pinned);
Line 220: Line 202:
     if (!title) return;
     if (!title) return;


    // MediaWiki-friendly navigation if available.
     if (window.mw && mw.util && typeof mw.util.getUrl === "function") {
     if (window.mw && mw.util && typeof mw.util.getUrl === "function") {
       window.location.href = mw.util.getUrl(title);
       window.location.href = mw.util.getUrl(title);
Line 226: Line 207:
     }
     }


    // Fallback: treat as relative.
     window.location.href = String(title);
     window.location.href = String(title);
   }
   }
Line 239: Line 219:


   /* ------------------------------------------------------------------ */
   /* ------------------------------------------------------------------ */
   /* Desktop hover behavior (only when hover-capable)                    */
   /* Desktop hover                                                       */
   /* ------------------------------------------------------------------ */
   /* ------------------------------------------------------------------ */


Line 246: Line 226:
       "mouseover",
       "mouseover",
       function (e) {
       function (e) {
         if (state.pinned) return; // pinned wins
         if (state.pinned) return;


         var defEl = closest(e.target, DEF_SEL);
         var defEl = closest(e.target, DEF_SEL);
         if (!defEl) {
         if (!defEl) {
          // If moving over non-def content, close hover tooltip (if any).
           if (state.visible && !state.pinned) hideTip();
           if (state.visible && !state.pinned) hideTip();
           return;
           return;
         }
         }


        // Avoid re-triggering within the same element.
         var rel = e.relatedTarget;
         var rel = e.relatedTarget;
         if (rel && defEl.contains(rel)) return;
         if (rel && defEl.contains(rel)) return;
Line 261: Line 239:
         var text = getTipText(defEl);
         var text = getTipText(defEl);
         if (!text) {
         if (!text) {
          // Blank tip closes any active hover tooltip.
           if (state.visible && !state.pinned) hideTip();
           if (state.visible && !state.pinned) hideTip();
           return;
           return;
Line 275: Line 252:
       function (e) {
       function (e) {
         if (!state.visible || state.pinned) return;
         if (!state.visible || state.pinned) return;
        // Only track if we’re still “on” the same anchor.
 
         var defEl = closest(e.target, DEF_SEL);
         var defEl = closest(e.target, DEF_SEL);
         if (!defEl || !sameAnchor(defEl)) return;
         if (!defEl || !sameAnchor(defEl)) return;
Line 295: Line 272:


         var rel = e.relatedTarget;
         var rel = e.relatedTarget;
         if (rel && defEl.contains(rel)) return; // still within the definition element
         if (rel && defEl.contains(rel)) return;


         hideTip();
         hideTip();
Line 304: Line 281:


   /* ------------------------------------------------------------------ */
   /* ------------------------------------------------------------------ */
   /* Touch / click behavior                                              */
   /* Touch + click behavior                                              */
   /* ------------------------------------------------------------------ */
   /* ------------------------------------------------------------------ */


  // Pointerdown is the best universal “tap” detector.
   document.addEventListener(
   document.addEventListener(
     "pointerdown",
     "pointerdown",
Line 316: Line 292:
       var text = getTipText(defEl);
       var text = getTipText(defEl);
       if (!text) {
       if (!text) {
        // Blank def: close if open.
         if (state.visible) hideTip();
         if (state.visible) hideTip();
         return;
         return;
       }
       }


      // Touch/pen: pin tooltip near tap.
      // Mouse: we don’t pin on pointerdown (let hover handle it); clicking can toggle pin.
       var isTouchLike = e.pointerType && e.pointerType !== "mouse";
       var isTouchLike = e.pointerType && e.pointerType !== "mouse";
       if (!isTouchLike) return;
       if (!isTouchLike) return;


      // Prevent synthetic click immediately after a touch pin (avoids double-toggling).
       state.suppressClickUntil = Date.now() + 450;
       state.suppressClickUntil = Date.now() + 450;


      // If already pinned on same anchor and it has a link, a second tap navigates.
       var link = getLinkTitle(defEl);
       var link = getLinkTitle(defEl);
       if (state.pinned && sameAnchor(defEl) && link) {
       if (state.pinned && sameAnchor(defEl) && link) {
Line 343: Line 314:
   );
   );


  // Click: desktop toggle pin (no link) OR navigate (has link).
   document.addEventListener(
   document.addEventListener(
     "click",
     "click",
Line 360: Line 330:
       var link = getLinkTitle(defEl);
       var link = getLinkTitle(defEl);


      // If link exists: navigate.
       if (link) {
       if (link) {
        // If tooltip is pinned on this item, allow click to navigate (expected “future” behavior).
         e.preventDefault();
         e.preventDefault();
         navigateToTitle(link);
         navigateToTitle(link);
Line 368: Line 336:
       }
       }


      // No link: toggle pinned state on desktop.
       e.preventDefault();
       e.preventDefault();


       if (state.pinned && sameAnchor(defEl)) {
       if (state.pinned && sameAnchor(defEl)) hideTip();
        hideTip();
       else showTip(defEl, text, e.clientX, e.clientY, true);
       } else {
        showTip(defEl, text, e.clientX, e.clientY, true);
      }
     },
     },
     true
     true
Line 381: Line 345:


   /* ------------------------------------------------------------------ */
   /* ------------------------------------------------------------------ */
   /* Dismissal behavior                                                  */
   /* Dismissal                                                           */
   /* ------------------------------------------------------------------ */
   /* ------------------------------------------------------------------ */


  // Click outside closes pinned tooltip.
   document.addEventListener(
   document.addEventListener(
     "click",
     "click",
Line 390: Line 353:
       if (!state.visible || !state.pinned) return;
       if (!state.visible || !state.pinned) return;


      // Click inside tooltip should not close it.
       if (tipEl && (e.target === tipEl || (tipEl.contains && tipEl.contains(e.target)))) return;
       if (tipEl && (e.target === tipEl || (tipEl.contains && tipEl.contains(e.target)))) return;
      // Click on a definition is handled by the click handler above.
       if (closest(e.target, DEF_SEL)) return;
       if (closest(e.target, DEF_SEL)) return;


Line 401: Line 361:
   );
   );


  // Escape closes any tooltip (pinned or hover).
   document.addEventListener(
   document.addEventListener(
     "keydown",
     "keydown",
Line 411: Line 370:
   );
   );


  // If the page scrolls while pinned, keep it near the last anchor/tap point.
   window.addEventListener(
   window.addEventListener(
     "scroll",
     "scroll",
Line 429: Line 387:
     true
     true
   );
   );
  // Fully delegated; hook kept for consistent pattern.
  function initAllDefs(root) {
    void root;
  }
  if (window.mw && mw.hook) {
    mw.hook("wikipage.content").add(function ($content) {
      initAllDefs($content && $content[0]);
    });
  }
  if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", function () {
      initAllDefs(document);
    });
  } else {
    initAllDefs(document);
  }
})();
(function () {
  /* ================================================================== */
  /* MODULE: Level Selector / Descriptions (data-series)                */
  /* ================================================================== */
  // Namespace + per-module guard (standalone).
  var SV = (window.SV = window.SV || {});
  var COMMON = (SV.common = SV.common || {});
  if (COMMON.levelsInit) return;
  COMMON.levelsInit = 1;
  /* ------------------------------------------------------------------ */
  /* Selectors                                                          */
  /* ------------------------------------------------------------------ */
  var CARD_SEL = ".sv-gi-card, .sv-skill-card, .sv-passive-card";
  // Legacy range input + custom slider
  var LEVEL_RANGE_SEL =
    "input.sv-level-range[type='range'], .sv-level-range--custom[data-sv-slider='1']";
  var LEVEL_BOUNDARY_SEL = ".sv-skill-level, .sv-gi-level";
  var LEVEL_SCOPE_SEL = ".sv-gi-bottom, .sv-skill-bottom";
  var SERIES_SEL = "[data-series]";
  /* ------------------------------------------------------------------ */
  /* Internals                                                          */
  /* ------------------------------------------------------------------ */
  var _seriesCache = typeof WeakMap !== "undefined" ? new WeakMap() : null;
  var _drag = null;
  /* ------------------------------------------------------------------ */
  /* Utilities                                                          */
  /* ------------------------------------------------------------------ */
  function clampInt(value, min, max, fallback) {
    var n = parseInt(value, 10);
    if (isNaN(n)) n = fallback != null ? fallback : min;
    if (n < min) return min;
    if (n > max) return max;
    return n;
  }
  function closest(el, sel) {
    if (!el || el.nodeType !== 1) return null;
    if (el.closest) return el.closest(sel);
    while (el && el.nodeType === 1) {
      if (el.matches && el.matches(sel)) return el;
      el = el.parentElement;
    }
    return null;
  }
  function parseSeries(el) {
    if (!el) return null;
    if (_seriesCache) {
      if (_seriesCache.has(el)) return _seriesCache.get(el);
    } else if (el._svSeries !== undefined) {
      return el._svSeries;
    }
    var raw = el.getAttribute("data-series");
    var parsed = null;
    if (raw != null && raw !== "") {
      try {
        parsed = JSON.parse(raw);
        if (!Array.isArray(parsed)) parsed = null;
      } catch (e) {
        parsed = null;
      }
    }
    if (_seriesCache) _seriesCache.set(el, parsed);
    else el._svSeries = parsed;
    return parsed;
  }
  function isAfter(boundaryNode, el) {
    if (!boundaryNode || !el) return true;
    if (boundaryNode === el) return false;
    if (!boundaryNode.compareDocumentPosition) return true;
    return (boundaryNode.compareDocumentPosition(el) & 4) !== 0; // following
  }
  function isRangeInput(el) {
    return !!(el && el.tagName === "INPUT" && el.type === "range");
  }
  function isCustomSlider(el) {
    return !!(el && el.getAttribute && el.getAttribute("data-sv-slider") === "1");
  }


   /* ------------------------------------------------------------------ */
   /* ------------------------------------------------------------------ */
   /* Init hook                                                          */
   /* Core                                                                */
   /* ------------------------------------------------------------------ */
   /* ------------------------------------------------------------------ */


   // This module is fully event-delegated; no per-node init required.
   function getCardMaxLevel(card) {
   // Still, we keep the MediaWiki hook so this file matches your site pattern.
    return clampInt(card.getAttribute("data-max-level"), 1, 999, 1);
   function initAll(root) {
  }
     // No-op on purpose (delegated listeners already installed once).
 
     // Placeholder for future expansions (e.g., prewarming tooltip element).
  function getCardLevel(card, maxLevel) {
     void root;
    return clampInt(card.getAttribute("data-level"), 1, maxLevel, 1);
  }
 
  function getLevelSlider(card) {
    return card.querySelector(LEVEL_RANGE_SEL);
  }
 
  function getLevelBoundary(slider) {
    // boundary only matters when a slider exists
    if (!slider) return null;
    return closest(slider, LEVEL_BOUNDARY_SEL) || slider;
  }
 
  function getLevelScopeContainer(card, boundary) {
    var scope = card.querySelector(LEVEL_SCOPE_SEL) || card;
    if (boundary && scope !== card && !scope.contains(boundary)) return card;
    return scope;
  }
 
  function setLevelNumber(card, level) {
    var span = card.querySelector(".sv-level-num");
    if (span) span.textContent = String(level);
  }
 
  function applySeriesToScope(scope, boundary, level) {
    if (!scope) return;
 
    var index = level - 1;
    var nodes = scope.querySelectorAll(SERIES_SEL);
 
    for (var i = 0; i < nodes.length; i++) {
      var el = nodes[i];
 
      // Only update fields after boundary when boundary exists.
      if (boundary && !isAfter(boundary, el)) continue;
 
      var series = parseSeries(el);
      if (!series || series.length === 0) continue;
 
      var safeIndex = index;
      if (safeIndex >= series.length) safeIndex = series.length - 1;
      if (safeIndex < 0) safeIndex = 0;
 
      var value = series[safeIndex];
      if (value == null) value = "";
 
      el.textContent = String(value);
    }
  }
 
  /* ---------- Custom slider helpers --------------------------------- */
 
  function getCustomMin(slider) {
    return clampInt(
      slider.getAttribute("aria-valuemin") || slider.getAttribute("data-min"),
      1,
      999,
      1
    );
  }
 
  function getCustomMax(slider) {
    return clampInt(
      slider.getAttribute("aria-valuemax") || slider.getAttribute("data-max"),
      1,
      999,
      1
    );
  }
 
  function getCustomValue(slider, fallback) {
    return clampInt(
      slider.getAttribute("aria-valuenow") || slider.getAttribute("data-value"),
      1,
      999,
      fallback != null ? fallback : 1
    );
  }
 
  function setCustomBounds(slider, min, max) {
    slider.setAttribute("aria-valuemin", String(min));
    slider.setAttribute("aria-valuemax", String(max));
    slider.setAttribute("data-min", String(min));
    slider.setAttribute("data-max", String(max));
  }
 
  function updateCustomVisual(slider, value) {
    var min = getCustomMin(slider);
    var max = getCustomMax(slider);
    if (max < min) max = min;
 
    var pct = max === min ? 0 : (value - min) / (max - min);
    if (pct < 0) pct = 0;
    if (pct > 1) pct = 1;
 
    var left = pct * 100;
 
    var thumb = slider.querySelector(".sv-level-thumb");
    var fill = slider.querySelector(".sv-level-fill");
 
    if (thumb && thumb.style) thumb.style.left = left + "%";
    if (fill && fill.style) fill.style.width = left + "%";
  }
 
  function setCustomValue(slider, value) {
    slider.setAttribute("aria-valuenow", String(value));
    slider.setAttribute("data-value", String(value));
    updateCustomVisual(slider, value);
  }
 
  function valueFromClientX(slider, clientX) {
    var rect = slider.getBoundingClientRect();
    var min = getCustomMin(slider);
    var max = getCustomMax(slider);
    if (max < min) max = min;
 
    var w = rect.width || 1;
    var x = (clientX - rect.left) / w;
    if (x < 0) x = 0;
    if (x > 1) x = 1;
 
    var raw = min + x * (max - min);
    var rounded = Math.round(raw);
    return clampInt(rounded, min, max, min);
  }
 
   function applyLevelToCard(card, rawLevel) {
    if (!card) return;
 
    var maxLevel = getCardMaxLevel(card);
    var slider = getLevelSlider(card);
 
    var level = clampInt(rawLevel, 1, maxLevel, getCardLevel(card, maxLevel));
 
    // Store level on card for other systems / debugging.
    card.setAttribute("data-level", String(level));
    setLevelNumber(card, level);
 
    // Keep slider in sync if one exists.
    if (slider && maxLevel > 1) {
      if (isRangeInput(slider)) {
        slider.setAttribute("min", "1");
        slider.setAttribute("max", String(maxLevel));
        if (String(slider.value) !== String(level)) slider.value = String(level);
      } else if (isCustomSlider(slider)) {
        setCustomBounds(slider, 1, maxLevel);
        setCustomValue(slider, level);
      }
    }
 
    // Apply series updates:
    // - If slider exists: honor boundary rule (only below boundary)
    // - If no slider: update all series in scope (boundary=null)
    var boundary = getLevelBoundary(slider);
    var scope = getLevelScopeContainer(card, boundary);
    applySeriesToScope(scope, boundary, level);
  }
 
   function initLevels(root) {
     var container = root || document;
    var cards = container.querySelectorAll(CARD_SEL);
 
    for (var i = 0; i < cards.length; i++) {
      var card = cards[i];
      if (card.getAttribute("data-gi-level-init") === "1") continue;
 
      var maxLevel = getCardMaxLevel(card);
      var slider = getLevelSlider(card);
 
      var start = getCardLevel(card, maxLevel);
 
      if (slider && maxLevel > 1) {
        if (isRangeInput(slider)) start = slider.value;
        else if (isCustomSlider(slider)) start = getCustomValue(slider, start);
      }
 
      applyLevelToCard(card, start);
      card.setAttribute("data-gi-level-init", "1");
    }
  }
 
  /* ------------------------------------------------------------------ */
  /* Events                                                              */
  /* ------------------------------------------------------------------ */
 
  // Range input slider (delegated)
  document.addEventListener(
    "input",
    function (e) {
      var t = e.target;
      if (!t || t.nodeType !== 1) return;
      if (!t.matches || !t.matches("input.sv-level-range[type='range']")) return;
 
      var card = closest(t, CARD_SEL);
      if (!card) return;
 
      applyLevelToCard(card, t.value);
    },
    true
  );
 
  // Custom slider pointer drag (delegated)
  document.addEventListener(
    "pointerdown",
    function (e) {
      var slider =
        e.target && e.target.closest
          ? e.target.closest(".sv-level-range--custom[data-sv-slider='1']")
          : null;
      if (!slider) return;
 
      var card = closest(slider, CARD_SEL);
      if (!card) return;
 
      e.preventDefault();
 
      if (slider.focus) slider.focus();
 
      _drag = { slider: slider, card: card, pointerId: e.pointerId };
 
      if (slider.setPointerCapture) {
        try {
          slider.setPointerCapture(e.pointerId);
        } catch (err) {}
      }
 
      applyLevelToCard(card, valueFromClientX(slider, e.clientX));
    },
    true
  );
 
  document.addEventListener(
    "pointermove",
    function (e) {
      if (!_drag) return;
      if (e.pointerId !== _drag.pointerId) return;
      e.preventDefault();
      applyLevelToCard(_drag.card, valueFromClientX(_drag.slider, e.clientX));
    },
    true
  );
 
  function endDrag(e) {
    if (!_drag) return;
    if (e && e.pointerId != null && e.pointerId !== _drag.pointerId) return;
 
    if (_drag.slider && _drag.slider.releasePointerCapture) {
      try {
        _drag.slider.releasePointerCapture(_drag.pointerId);
      } catch (err) {}
    }
     _drag = null;
  }
 
  document.addEventListener("pointerup", endDrag, true);
  document.addEventListener("pointercancel", endDrag, true);
 
  // Custom slider keyboard support
  document.addEventListener(
    "keydown",
    function (e) {
      var el = document.activeElement;
      if (!el || !el.closest) return;
 
      var slider = el.closest(".sv-level-range--custom[data-sv-slider='1']");
      if (!slider) return;
 
      var card = closest(slider, CARD_SEL);
      if (!card) return;
 
      var min = getCustomMin(slider);
      var max = getCustomMax(slider);
      if (max < min) max = min;
 
      var cur = getCustomValue(slider, getCardLevel(card, max));
      var next = cur;
 
      if (e.key === "ArrowLeft" || e.key === "Left" || e.key === "ArrowDown") {
        next = cur - 1;
      } else if (
        e.key === "ArrowRight" ||
        e.key === "Right" ||
        e.key === "ArrowUp"
      ) {
        next = cur + 1;
      } else if (e.key === "Home") {
        next = min;
      } else if (e.key === "End") {
        next = max;
      } else if (e.key === "PageDown") {
        next = cur - 5;
      } else if (e.key === "PageUp") {
        next = cur + 5;
      } else {
        return;
      }
 
      e.preventDefault();
      next = clampInt(next, min, max, cur);
      applyLevelToCard(card, next);
    },
    true
  );
 
  /* ------------------------------------------------------------------ */
  /* Init                                                                */
  /* ------------------------------------------------------------------ */
 
  function initAllLevels(root) {
     initLevels(root);
   }
   }


   if (window.mw && mw.hook) {
   if (window.mw && mw.hook) {
     mw.hook("wikipage.content").add(function ($content) {
     mw.hook("wikipage.content").add(function ($content) {
       initAll($content && $content[0]);
       initAllLevels($content && $content[0]);
     });
     });
   }
   }
Line 450: Line 833:
   if (document.readyState === "loading") {
   if (document.readyState === "loading") {
     document.addEventListener("DOMContentLoaded", function () {
     document.addEventListener("DOMContentLoaded", function () {
       initAll(document);
       initAllLevels(document);
     });
     });
   } else {
   } else {
     initAll(document);
     initAllLevels(document);
   }
   }
})();
})();

Revision as of 19:17, 19 February 2026

/* Common.js — Spirit Vale Wiki
   Phase 4.1 rebuild (incremental)

   Currently included (standalone modules):
   1) Definitions (v1)
   2) Level Selector / Descriptions (data-series)

   Not included yet:
   - Popups
   - Tabs
*/

(function () {
  /* ================================================================== */
  /* MODULE: Definitions (v1)                                           */
  /* ================================================================== */

  // Namespace + per-module guard (no “41”).
  var SV = (window.SV = window.SV || {});
  var COMMON = (SV.common = SV.common || {});
  if (COMMON.definitionsInit) return;
  COMMON.definitionsInit = 1;

  /* ------------------------------------------------------------------ */
  /* Selectors / Attributes                                              */
  /* ------------------------------------------------------------------ */

  var DEF_SEL = ".sv-def";
  var TIP_ATTR = "data-sv-def-tip";
  var LINK_ATTR = "data-sv-def-link";

  /* ------------------------------------------------------------------ */
  /* Capability detection                                                 */
  /* ------------------------------------------------------------------ */

  var HOVER_CAPABLE = false;
  try {
    HOVER_CAPABLE =
      !!window.matchMedia &&
      window.matchMedia("(hover:hover) and (pointer:fine)").matches;
  } catch (e) {
    HOVER_CAPABLE = false;
  }

  /* ------------------------------------------------------------------ */
  /* State                                                               */
  /* ------------------------------------------------------------------ */

  var tipEl = null;

  var state = {
    visible: false,
    pinned: false,
    anchor: null, // current .sv-def element
    lastX: 0,
    lastY: 0,
    suppressClickUntil: 0, // prevents synthetic click after touch pin
  };

  /* ------------------------------------------------------------------ */
  /* Utilities                                                           */
  /* ------------------------------------------------------------------ */

  function closest(el, sel) {
    if (!el || el.nodeType !== 1) return null;
    if (el.closest) return el.closest(sel);
    while (el && el.nodeType === 1) {
      if (el.matches && el.matches(sel)) return el;
      el = el.parentElement;
    }
    return null;
  }

  function getTipText(defEl) {
    if (!defEl || !defEl.getAttribute) return "";
    var t = defEl.getAttribute(TIP_ATTR);
    if (t == null) return "";
    t = String(t);
    if (!t.replace(/\s+/g, "")) return "";
    return t;
  }

  function getLinkTitle(defEl) {
    if (!defEl || !defEl.getAttribute) return "";
    var t = defEl.getAttribute(LINK_ATTR);
    if (t == null) return "";
    t = String(t).trim();
    return t;
  }

  function ensureTipEl() {
    if (tipEl) return tipEl;

    var el = document.createElement("div");
    el.className = "sv-def-tip sv-hidden";
    el.setAttribute("role", "tooltip");
    el.setAttribute("aria-hidden", "true");

    // Conservative inline styling (CSS can override via .sv-def-tip).
    el.style.position = "fixed";
    el.style.zIndex = "99999";
    el.style.maxWidth = "360px";
    el.style.boxSizing = "border-box";
    el.style.padding = "8px 10px";
    el.style.borderRadius = "10px";
    el.style.background = "rgba(10, 14, 22, 0.92)";
    el.style.color = "#fff";
    el.style.fontSize = "14px";
    el.style.lineHeight = "1.25";
    el.style.whiteSpace = "pre-line";
    el.style.pointerEvents = "none"; // enabled only when pinned
    el.style.display = "none";

    document.body.appendChild(el);
    tipEl = el;
    return tipEl;
  }

  function setHidden(el, hidden) {
    if (!el) return;
    if (hidden) {
      el.classList.add("sv-hidden");
      el.setAttribute("aria-hidden", "true");
      el.style.display = "none";
    } else {
      el.classList.remove("sv-hidden");
      el.setAttribute("aria-hidden", "false");
      el.style.display = "block";
    }
  }

  function clamp(n, min, max) {
    if (n < min) return min;
    if (n > max) return max;
    return n;
  }

  function positionTipAt(clientX, clientY) {
    var el = ensureTipEl();
    if (!el) return;

    var vw = window.innerWidth || document.documentElement.clientWidth || 0;
    var vh = window.innerHeight || document.documentElement.clientHeight || 0;

    var gapY = state.pinned ? 10 : 16;
    var gapX = 12;

    el.style.left = "0px";
    el.style.top = "0px";

    requestAnimationFrame(function () {
      if (!state.visible) return;

      var r = el.getBoundingClientRect();
      var w = r.width || 240;
      var h = r.height || 60;

      var left = clientX - w / 2;
      var top = clientY - h - gapY;

      left = clamp(left, gapX, Math.max(gapX, vw - w - gapX));

      if (top < gapY) top = clientY + gapY;

      top = clamp(top, gapY, Math.max(gapY, vh - h - gapY));

      el.style.left = Math.round(left) + "px";
      el.style.top = Math.round(top) + "px";
    });
  }

  function showTip(defEl, text, clientX, clientY, pinned) {
    var el = ensureTipEl();
    if (!el) return;

    state.visible = true;
    state.pinned = !!pinned;
    state.anchor = defEl || null;
    state.lastX = clientX || 0;
    state.lastY = clientY || 0;

    el.textContent = text;

    el.style.pointerEvents = state.pinned ? "auto" : "none";
    el.classList.toggle("sv-def-tip--pinned", state.pinned);

    setHidden(el, false);
    positionTipAt(state.lastX, state.lastY);
  }

  function hideTip() {
    if (!tipEl) return;

    state.visible = false;
    state.pinned = false;
    state.anchor = null;

    setHidden(tipEl, true);
  }

  function navigateToTitle(title) {
    if (!title) return;

    if (window.mw && mw.util && typeof mw.util.getUrl === "function") {
      window.location.href = mw.util.getUrl(title);
      return;
    }

    window.location.href = String(title);
  }

  function sameAnchor(el) {
    return !!(el && state.anchor && el === state.anchor);
  }

  function shouldSuppressClick() {
    return Date.now() < state.suppressClickUntil;
  }

  /* ------------------------------------------------------------------ */
  /* Desktop hover                                                       */
  /* ------------------------------------------------------------------ */

  if (HOVER_CAPABLE) {
    document.addEventListener(
      "mouseover",
      function (e) {
        if (state.pinned) return;

        var defEl = closest(e.target, DEF_SEL);
        if (!defEl) {
          if (state.visible && !state.pinned) hideTip();
          return;
        }

        var rel = e.relatedTarget;
        if (rel && defEl.contains(rel)) return;

        var text = getTipText(defEl);
        if (!text) {
          if (state.visible && !state.pinned) hideTip();
          return;
        }

        showTip(defEl, text, e.clientX, e.clientY, false);
      },
      true
    );

    document.addEventListener(
      "mousemove",
      function (e) {
        if (!state.visible || state.pinned) return;

        var defEl = closest(e.target, DEF_SEL);
        if (!defEl || !sameAnchor(defEl)) return;

        state.lastX = e.clientX;
        state.lastY = e.clientY;
        positionTipAt(state.lastX, state.lastY);
      },
      true
    );

    document.addEventListener(
      "mouseout",
      function (e) {
        if (!state.visible || state.pinned) return;

        var defEl = closest(e.target, DEF_SEL);
        if (!defEl || !sameAnchor(defEl)) return;

        var rel = e.relatedTarget;
        if (rel && defEl.contains(rel)) return;

        hideTip();
      },
      true
    );
  }

  /* ------------------------------------------------------------------ */
  /* Touch + click behavior                                              */
  /* ------------------------------------------------------------------ */

  document.addEventListener(
    "pointerdown",
    function (e) {
      var defEl = closest(e.target, DEF_SEL);
      if (!defEl) return;

      var text = getTipText(defEl);
      if (!text) {
        if (state.visible) hideTip();
        return;
      }

      var isTouchLike = e.pointerType && e.pointerType !== "mouse";
      if (!isTouchLike) return;

      state.suppressClickUntil = Date.now() + 450;

      var link = getLinkTitle(defEl);
      if (state.pinned && sameAnchor(defEl) && link) {
        e.preventDefault();
        navigateToTitle(link);
        return;
      }

      e.preventDefault();
      showTip(defEl, text, e.clientX, e.clientY, true);
    },
    true
  );

  document.addEventListener(
    "click",
    function (e) {
      if (shouldSuppressClick()) return;

      var defEl = closest(e.target, DEF_SEL);
      if (!defEl) return;

      var text = getTipText(defEl);
      if (!text) {
        if (state.visible) hideTip();
        return;
      }

      var link = getLinkTitle(defEl);

      if (link) {
        e.preventDefault();
        navigateToTitle(link);
        return;
      }

      e.preventDefault();

      if (state.pinned && sameAnchor(defEl)) hideTip();
      else showTip(defEl, text, e.clientX, e.clientY, true);
    },
    true
  );

  /* ------------------------------------------------------------------ */
  /* Dismissal                                                           */
  /* ------------------------------------------------------------------ */

  document.addEventListener(
    "click",
    function (e) {
      if (!state.visible || !state.pinned) return;

      if (tipEl && (e.target === tipEl || (tipEl.contains && tipEl.contains(e.target)))) return;
      if (closest(e.target, DEF_SEL)) return;

      hideTip();
    },
    true
  );

  document.addEventListener(
    "keydown",
    function (e) {
      if (e.key !== "Escape") return;
      if (state.visible) hideTip();
    },
    true
  );

  window.addEventListener(
    "scroll",
    function () {
      if (!state.visible) return;
      positionTipAt(state.lastX, state.lastY);
    },
    true
  );

  window.addEventListener(
    "resize",
    function () {
      if (!state.visible) return;
      positionTipAt(state.lastX, state.lastY);
    },
    true
  );

  // Fully delegated; hook kept for consistent pattern.
  function initAllDefs(root) {
    void root;
  }

  if (window.mw && mw.hook) {
    mw.hook("wikipage.content").add(function ($content) {
      initAllDefs($content && $content[0]);
    });
  }

  if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", function () {
      initAllDefs(document);
    });
  } else {
    initAllDefs(document);
  }
})();

(function () {
  /* ================================================================== */
  /* MODULE: Level Selector / Descriptions (data-series)                 */
  /* ================================================================== */

  // Namespace + per-module guard (standalone).
  var SV = (window.SV = window.SV || {});
  var COMMON = (SV.common = SV.common || {});
  if (COMMON.levelsInit) return;
  COMMON.levelsInit = 1;

  /* ------------------------------------------------------------------ */
  /* Selectors                                                           */
  /* ------------------------------------------------------------------ */

  var CARD_SEL = ".sv-gi-card, .sv-skill-card, .sv-passive-card";

  // Legacy range input + custom slider
  var LEVEL_RANGE_SEL =
    "input.sv-level-range[type='range'], .sv-level-range--custom[data-sv-slider='1']";

  var LEVEL_BOUNDARY_SEL = ".sv-skill-level, .sv-gi-level";
  var LEVEL_SCOPE_SEL = ".sv-gi-bottom, .sv-skill-bottom";
  var SERIES_SEL = "[data-series]";

  /* ------------------------------------------------------------------ */
  /* Internals                                                           */
  /* ------------------------------------------------------------------ */

  var _seriesCache = typeof WeakMap !== "undefined" ? new WeakMap() : null;
  var _drag = null;

  /* ------------------------------------------------------------------ */
  /* Utilities                                                           */
  /* ------------------------------------------------------------------ */

  function clampInt(value, min, max, fallback) {
    var n = parseInt(value, 10);
    if (isNaN(n)) n = fallback != null ? fallback : min;
    if (n < min) return min;
    if (n > max) return max;
    return n;
  }

  function closest(el, sel) {
    if (!el || el.nodeType !== 1) return null;
    if (el.closest) return el.closest(sel);
    while (el && el.nodeType === 1) {
      if (el.matches && el.matches(sel)) return el;
      el = el.parentElement;
    }
    return null;
  }

  function parseSeries(el) {
    if (!el) return null;

    if (_seriesCache) {
      if (_seriesCache.has(el)) return _seriesCache.get(el);
    } else if (el._svSeries !== undefined) {
      return el._svSeries;
    }

    var raw = el.getAttribute("data-series");
    var parsed = null;

    if (raw != null && raw !== "") {
      try {
        parsed = JSON.parse(raw);
        if (!Array.isArray(parsed)) parsed = null;
      } catch (e) {
        parsed = null;
      }
    }

    if (_seriesCache) _seriesCache.set(el, parsed);
    else el._svSeries = parsed;

    return parsed;
  }

  function isAfter(boundaryNode, el) {
    if (!boundaryNode || !el) return true;
    if (boundaryNode === el) return false;
    if (!boundaryNode.compareDocumentPosition) return true;
    return (boundaryNode.compareDocumentPosition(el) & 4) !== 0; // following
  }

  function isRangeInput(el) {
    return !!(el && el.tagName === "INPUT" && el.type === "range");
  }

  function isCustomSlider(el) {
    return !!(el && el.getAttribute && el.getAttribute("data-sv-slider") === "1");
  }

  /* ------------------------------------------------------------------ */
  /* Core                                                                */
  /* ------------------------------------------------------------------ */

  function getCardMaxLevel(card) {
    return clampInt(card.getAttribute("data-max-level"), 1, 999, 1);
  }

  function getCardLevel(card, maxLevel) {
    return clampInt(card.getAttribute("data-level"), 1, maxLevel, 1);
  }

  function getLevelSlider(card) {
    return card.querySelector(LEVEL_RANGE_SEL);
  }

  function getLevelBoundary(slider) {
    // boundary only matters when a slider exists
    if (!slider) return null;
    return closest(slider, LEVEL_BOUNDARY_SEL) || slider;
  }

  function getLevelScopeContainer(card, boundary) {
    var scope = card.querySelector(LEVEL_SCOPE_SEL) || card;
    if (boundary && scope !== card && !scope.contains(boundary)) return card;
    return scope;
  }

  function setLevelNumber(card, level) {
    var span = card.querySelector(".sv-level-num");
    if (span) span.textContent = String(level);
  }

  function applySeriesToScope(scope, boundary, level) {
    if (!scope) return;

    var index = level - 1;
    var nodes = scope.querySelectorAll(SERIES_SEL);

    for (var i = 0; i < nodes.length; i++) {
      var el = nodes[i];

      // Only update fields after boundary when boundary exists.
      if (boundary && !isAfter(boundary, el)) continue;

      var series = parseSeries(el);
      if (!series || series.length === 0) continue;

      var safeIndex = index;
      if (safeIndex >= series.length) safeIndex = series.length - 1;
      if (safeIndex < 0) safeIndex = 0;

      var value = series[safeIndex];
      if (value == null) value = "";

      el.textContent = String(value);
    }
  }

  /* ---------- Custom slider helpers --------------------------------- */

  function getCustomMin(slider) {
    return clampInt(
      slider.getAttribute("aria-valuemin") || slider.getAttribute("data-min"),
      1,
      999,
      1
    );
  }

  function getCustomMax(slider) {
    return clampInt(
      slider.getAttribute("aria-valuemax") || slider.getAttribute("data-max"),
      1,
      999,
      1
    );
  }

  function getCustomValue(slider, fallback) {
    return clampInt(
      slider.getAttribute("aria-valuenow") || slider.getAttribute("data-value"),
      1,
      999,
      fallback != null ? fallback : 1
    );
  }

  function setCustomBounds(slider, min, max) {
    slider.setAttribute("aria-valuemin", String(min));
    slider.setAttribute("aria-valuemax", String(max));
    slider.setAttribute("data-min", String(min));
    slider.setAttribute("data-max", String(max));
  }

  function updateCustomVisual(slider, value) {
    var min = getCustomMin(slider);
    var max = getCustomMax(slider);
    if (max < min) max = min;

    var pct = max === min ? 0 : (value - min) / (max - min);
    if (pct < 0) pct = 0;
    if (pct > 1) pct = 1;

    var left = pct * 100;

    var thumb = slider.querySelector(".sv-level-thumb");
    var fill = slider.querySelector(".sv-level-fill");

    if (thumb && thumb.style) thumb.style.left = left + "%";
    if (fill && fill.style) fill.style.width = left + "%";
  }

  function setCustomValue(slider, value) {
    slider.setAttribute("aria-valuenow", String(value));
    slider.setAttribute("data-value", String(value));
    updateCustomVisual(slider, value);
  }

  function valueFromClientX(slider, clientX) {
    var rect = slider.getBoundingClientRect();
    var min = getCustomMin(slider);
    var max = getCustomMax(slider);
    if (max < min) max = min;

    var w = rect.width || 1;
    var x = (clientX - rect.left) / w;
    if (x < 0) x = 0;
    if (x > 1) x = 1;

    var raw = min + x * (max - min);
    var rounded = Math.round(raw);
    return clampInt(rounded, min, max, min);
  }

  function applyLevelToCard(card, rawLevel) {
    if (!card) return;

    var maxLevel = getCardMaxLevel(card);
    var slider = getLevelSlider(card);

    var level = clampInt(rawLevel, 1, maxLevel, getCardLevel(card, maxLevel));

    // Store level on card for other systems / debugging.
    card.setAttribute("data-level", String(level));
    setLevelNumber(card, level);

    // Keep slider in sync if one exists.
    if (slider && maxLevel > 1) {
      if (isRangeInput(slider)) {
        slider.setAttribute("min", "1");
        slider.setAttribute("max", String(maxLevel));
        if (String(slider.value) !== String(level)) slider.value = String(level);
      } else if (isCustomSlider(slider)) {
        setCustomBounds(slider, 1, maxLevel);
        setCustomValue(slider, level);
      }
    }

    // Apply series updates:
    // - If slider exists: honor boundary rule (only below boundary)
    // - If no slider: update all series in scope (boundary=null)
    var boundary = getLevelBoundary(slider);
    var scope = getLevelScopeContainer(card, boundary);
    applySeriesToScope(scope, boundary, level);
  }

  function initLevels(root) {
    var container = root || document;
    var cards = container.querySelectorAll(CARD_SEL);

    for (var i = 0; i < cards.length; i++) {
      var card = cards[i];
      if (card.getAttribute("data-gi-level-init") === "1") continue;

      var maxLevel = getCardMaxLevel(card);
      var slider = getLevelSlider(card);

      var start = getCardLevel(card, maxLevel);

      if (slider && maxLevel > 1) {
        if (isRangeInput(slider)) start = slider.value;
        else if (isCustomSlider(slider)) start = getCustomValue(slider, start);
      }

      applyLevelToCard(card, start);
      card.setAttribute("data-gi-level-init", "1");
    }
  }

  /* ------------------------------------------------------------------ */
  /* Events                                                              */
  /* ------------------------------------------------------------------ */

  // Range input slider (delegated)
  document.addEventListener(
    "input",
    function (e) {
      var t = e.target;
      if (!t || t.nodeType !== 1) return;
      if (!t.matches || !t.matches("input.sv-level-range[type='range']")) return;

      var card = closest(t, CARD_SEL);
      if (!card) return;

      applyLevelToCard(card, t.value);
    },
    true
  );

  // Custom slider pointer drag (delegated)
  document.addEventListener(
    "pointerdown",
    function (e) {
      var slider =
        e.target && e.target.closest
          ? e.target.closest(".sv-level-range--custom[data-sv-slider='1']")
          : null;
      if (!slider) return;

      var card = closest(slider, CARD_SEL);
      if (!card) return;

      e.preventDefault();

      if (slider.focus) slider.focus();

      _drag = { slider: slider, card: card, pointerId: e.pointerId };

      if (slider.setPointerCapture) {
        try {
          slider.setPointerCapture(e.pointerId);
        } catch (err) {}
      }

      applyLevelToCard(card, valueFromClientX(slider, e.clientX));
    },
    true
  );

  document.addEventListener(
    "pointermove",
    function (e) {
      if (!_drag) return;
      if (e.pointerId !== _drag.pointerId) return;
      e.preventDefault();
      applyLevelToCard(_drag.card, valueFromClientX(_drag.slider, e.clientX));
    },
    true
  );

  function endDrag(e) {
    if (!_drag) return;
    if (e && e.pointerId != null && e.pointerId !== _drag.pointerId) return;

    if (_drag.slider && _drag.slider.releasePointerCapture) {
      try {
        _drag.slider.releasePointerCapture(_drag.pointerId);
      } catch (err) {}
    }
    _drag = null;
  }

  document.addEventListener("pointerup", endDrag, true);
  document.addEventListener("pointercancel", endDrag, true);

  // Custom slider keyboard support
  document.addEventListener(
    "keydown",
    function (e) {
      var el = document.activeElement;
      if (!el || !el.closest) return;

      var slider = el.closest(".sv-level-range--custom[data-sv-slider='1']");
      if (!slider) return;

      var card = closest(slider, CARD_SEL);
      if (!card) return;

      var min = getCustomMin(slider);
      var max = getCustomMax(slider);
      if (max < min) max = min;

      var cur = getCustomValue(slider, getCardLevel(card, max));
      var next = cur;

      if (e.key === "ArrowLeft" || e.key === "Left" || e.key === "ArrowDown") {
        next = cur - 1;
      } else if (
        e.key === "ArrowRight" ||
        e.key === "Right" ||
        e.key === "ArrowUp"
      ) {
        next = cur + 1;
      } else if (e.key === "Home") {
        next = min;
      } else if (e.key === "End") {
        next = max;
      } else if (e.key === "PageDown") {
        next = cur - 5;
      } else if (e.key === "PageUp") {
        next = cur + 5;
      } else {
        return;
      }

      e.preventDefault();
      next = clampInt(next, min, max, cur);
      applyLevelToCard(card, next);
    },
    true
  );

  /* ------------------------------------------------------------------ */
  /* Init                                                                */
  /* ------------------------------------------------------------------ */

  function initAllLevels(root) {
    initLevels(root);
  }

  if (window.mw && mw.hook) {
    mw.hook("wikipage.content").add(function ($content) {
      initAllLevels($content && $content[0]);
    });
  }

  if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", function () {
      initAllLevels(document);
    });
  } else {
    initAllLevels(document);
  }
})();