Just Cause 2 Checklist

Just Cause

// ==UserScript==
// @name         Just Cause 2 Checklist
// @namespace    http://tampermonkey.net/
// @version      2025-08-27-dupfix
// @description  Just Cause
// @author       Walter Woshid
// @match        https://jc2map.info/
// @run-at       document-end
// @icon         https://www.google.com/s2/favicons?sz=64&domain=jc2map.info
// @grant        unsafeWindow
// @license      MIT
// ==/UserScript==

(function () {
  'use strict';

  const LS_KEYS = {
    COLLECTED: 'jc2_collected_sg_v2' // "sgid@@lat,lng##index"
  };

  // ---------- storage ----------
  function loadJSON(key, fallback) {
    try {
      const raw = localStorage.getItem(key);
      return raw ? JSON.parse(raw) : fallback;
    } catch { return fallback; }
  }
  function saveJSON(key, obj) {
    try { localStorage.setItem(key, JSON.stringify(obj)); } catch {}
  }
  const collected = loadJSON(LS_KEYS.COLLECTED, {}); // key -> 1

  // Expand old keys (no ##index) to the new ##1 format so old progress still counts
  function migrateCollectedKeysToIndexed() {
    let changed = false;
    for (const oldKey of Object.keys(collected)) {
      if (!/##\d+$/.test(oldKey)) {
        collected[oldKey + '##1'] = 1;
        delete collected[oldKey];
        changed = true;
      }
    }
    if (changed) saveJSON(LS_KEYS.COLLECTED, collected);
  }

  // ---------- state maps ----------
  // sgid and gid are the XML ids, e.g., gid: "Chaos Item", sgid: "Baby Panay Statues"
  const totalsBySg = new Map(); // sgid -> total markers from xml
  const collectedBySg = new Map(); // sgid -> collected count (from storage)
  const gidOfSg = new Map(); // sgid -> gid (derived from DOM buttons)
  const topNameOfGid = new Map(); // gid -> top tab name (e.g., "Chaos Item" -> "Destructables")
  const buttonsBySg = new Map(); // sgid -> <input> button element
  const buttonBaseLabel = new Map(); // sgid -> original label (trimmed)
  const topLinksByName = new Map(); // top tab name -> <a> element
  const topBaseLabel = new Map(); // top tab name -> original text

  function normalizeSpaces(s) { return (s || '').replace(/\s+/g, ' ').trim(); }
  function inc(map, key, d = 1) { map.set(key, (map.get(key) || 0) + d); }
  function setIfMissing(map, key, val) { if (!map.has(key)) map.set(key, val); }

  // ---------- UI scanning ----------
  function scanUI() {
    const container = document.querySelector('#inputUpload');
    if (!container) return false;

    container.querySelectorAll('.tabbertab').forEach(tab => {
      const h2 = tab.querySelector('h2');
      if (!h2) return;
      const topName = normalizeSpaces(h2.textContent);

      // nav link
      const link = Array.from(container.querySelectorAll('.tabbernav a'))
        .find(a => normalizeSpaces(a.textContent) === topName || a.title === topName);
      if (link) {
        topLinksByName.set(topName, link);
        setIfMissing(topBaseLabel, topName, normalizeSpaces(link.textContent));
      }

      // subcategory buttons carry "gid~sgid"
      tab.querySelectorAll('input.input_button[id*="~"]').forEach(btn => {
        const id = btn.id || '';
        const [gidPart, sgPart] = id.split('~');
        if (!gidPart || !sgPart) return;
        const gid = normalizeSpaces(gidPart.replaceAll('_', ' '));
        const sgid = normalizeSpaces(sgPart.replaceAll('_', ' '));

        buttonsBySg.set(sgid, btn);
        gidOfSg.set(sgid, gid);

        const label = normalizeSpaces(btn.value || btn.getAttribute('value') || sgid);
        setIfMissing(buttonBaseLabel, sgid, label);

        topNameOfGid.set(gid, topName);
      });
    });

    return buttonsBySg.size > 0;
  }

  // ---------- totals from XML (exact) ----------
  function buildTotalsFromXml(xmlDoc) {
    try {
      const groups = xmlDoc.getElementsByTagName('g');
      for (let gi = 0; gi < groups.length; gi++) {
        const gEl = groups[gi];
        const gid = gEl.getAttribute('id');
        const sgs = gEl.getElementsByTagName('sg');
        for (let si = 0; si < sgs.length; si++) {
          const sgEl = sgs[si];
          const sgid = sgEl.getAttribute('id');
          const items = sgEl.getElementsByTagName('i');
          totalsBySg.set(sgid, items.length);
          // init collected counter (from storage)
          let c = 0;
          const prefix = sgid + '@@'; // works for both indexed and (migrated) old keys
          for (const key in collected) {
            if (key.startsWith(prefix)) c++;
          }
          collectedBySg.set(sgid, c);
        }
      }
    } catch { /* ignore */ }
  }

  // ---------- UI updates ----------
  function updateSubButton(sgid) {
    const btn = buttonsBySg.get(sgid);
    if (!btn) return;
    const base = buttonBaseLabel.get(sgid) || sgid;
    const total = totalsBySg.get(sgid) || 0;
    const col = Math.min(collectedBySg.get(sgid) || 0, total);
    btn.value = ` ${base} (${col}/${total}) `;
  }

  function ensureTopLinks() {
    const nav = document.querySelector('#inputUpload .tabbernav');
    if (!nav) return false;
    const links = nav.querySelectorAll('a');
    links.forEach(a => {
      const name = normalizeSpaces(a.getAttribute('title') || a.textContent || '');
      if (!name) return;
      if (!topLinksByName.has(name)) {
        topLinksByName.set(name, a);
        if (!topBaseLabel.has(name)) {
          topBaseLabel.set(name, normalizeSpaces(a.textContent || name));
        }
      }
    });
    return topLinksByName.size > 0;
  }

  function updateTopTabByGid(gid) {
    const topName = topNameOfGid.get(gid);
    if (!topName) return;

    let total = 0, col = 0;
    for (const [sgid, g] of gidOfSg.entries()) {
      if (g !== gid) continue;
      total += (totalsBySg.get(sgid) || 0);
      col += (collectedBySg.get(sgid) || 0);
    }

    let link = topLinksByName.get(topName);
    if (!link) {
      ensureTopLinks();
      link = topLinksByName.get(topName) ||
        document.querySelector(`#inputUpload .tabbernav a[title="${topName}"]`);
      if (link && !topBaseLabel.has(topName)) {
        topBaseLabel.set(topName, normalizeSpaces(link.textContent || topName));
      }
    }

    const base = topBaseLabel.get(topName) || topName;
    if (link) link.textContent = `${base} (${col}/${total})`;
  }

  function updateAllUI() {
    for (const sgid of buttonsBySg.keys()) updateSubButton(sgid);
    const gids = new Set(topNameOfGid.keys());
    for (const gid of gids) updateTopTabByGid(gid);
  }

  // ---------- keys for duplicates ----------
  function latlngStr(latlng) {
    const lat = +latlng.lat.toFixed(6);
    const lng = +latlng.lng.toFixed(6);
    return `${lat},${lng}`;
  }
  function keyForIndexed(sgid, latlng, idx) {
    return `${sgid}@@${latlngStr(latlng)}##${idx}`;
  }
  // track how many markers we've assigned at each (sgid,lat,lng) during a build
  const seenIndexBySg = new Map(); // sgid -> Map(latlngStr -> nextIndex)
  function assignIndex(sgid, latlng) {
    let perSg = seenIndexBySg.get(sgid);
    if (!perSg) { perSg = new Map(); seenIndexBySg.set(sgid, perSg); }
    const ll = latlngStr(latlng);
    const next = (perSg.get(ll) || 0) + 1;
    perSg.set(ll, next);
    return next;
  }

  // ---------- marker handling ----------
  function applyCollectedStyle(iconEl, isCollected) {
    iconEl.style.opacity = isCollected ? '0.28' : '';
    iconEl.style.filter = isCollected ? 'grayscale(0.6) contrast(0.9)' : '';
  }

  function getClusterRadiusPx() {
    const oms = unsafeWindow.__oms;
    return (oms && oms.options && oms.options.nearbyDistance) || 8;
  }

  // Count how many other markers are within N pixels of this marker (visible-only)
  function countNearbyMarkers(marker, px = 8) {
    const map = unsafeWindow.map;
    if (!map || !marker || typeof marker.getLatLng !== 'function') return 0;
    const p0 = map.latLngToLayerPoint(marker.getLatLng());
    let n = 0;
    const layers = map._layers || {};
    for (const id in layers) {
      const m = layers[id];
      if (!m || m === marker) continue;
      if (typeof m.getLatLng !== 'function') continue;
      if (!m._icon) continue; // not rendered/visible
      const p = map.latLngToLayerPoint(m.getLatLng());
      const dx = p.x - p0.x, dy = p.y - p0.y;
      if ((dx * dx + dy * dy) <= (px * px)) { n++; if (n > 0) break; }
    }
    return n;
  }

  function attachToggleToMarker(marker, sgid) {
    function registerWithOMS() {
      if (!marker.__jc2OmsAdded && unsafeWindow.__oms && typeof unsafeWindow.__jc2_addToOms === 'function') {
        unsafeWindow.__jc2_addToOms(marker);
        marker.__jc2OmsAdded = true;
      }
    }

    const ensureIcon = () => {
      const el = marker._icon;
      if (!el) return false;
      if (el.dataset.jc2Bound) { registerWithOMS(); return true; }
      el.dataset.jc2Bound = '1';
      el.dataset.jc2Sgid = sgid;

      const ll = marker.getLatLng();
      // assign unique index based on (sgid,lat,lng) for this build
      const idx = assignIndex(sgid, ll);
      const k = keyForIndexed(sgid, ll, idx);
      el.dataset.jc2Key = k;

      const isC = !!collected[k];
      applyCollectedStyle(el, isC);

      // If this point is a cluster, spiderfy on mousedown and skip that click
      el.addEventListener('mousedown', () => {
        const px = getClusterRadiusPx();
        if (countNearbyMarkers(marker, px) > 0) {
          if (unsafeWindow.__oms) unsafeWindow.__oms.spiderfy(marker);
          el.dataset.jc2SkipNextClick = '1';
        }
      }, { passive: true });

      // Toggle only when not skipping and not clustered
      el.addEventListener('click', () => {
        if (el.dataset.jc2SkipNextClick === '1') { el.dataset.jc2SkipNextClick = ''; return; }
        const px = getClusterRadiusPx();
        if (countNearbyMarkers(marker, px) > 0) {
          if (unsafeWindow.__oms) unsafeWindow.__oms.spiderfy(marker);
          return;
        }

        const key = el.dataset.jc2Key;
        const group = el.dataset.jc2Sgid;
        const was = !!collected[key];

        if (was) {
          delete collected[key];
          inc(collectedBySg, group, -1);
        } else {
          collected[key] = 1;
          inc(collectedBySg, group, +1);
        }
        saveJSON(LS_KEYS.COLLECTED, collected);
        applyCollectedStyle(el, !was);
        updateSubButton(group);
        const gid = gidOfSg.get(group);
        if (gid) updateTopTabByGid(gid);
      }, { passive: true });

      registerWithOMS();
      return true;
    };

    if (!ensureIcon()) {
      marker.on('add', () => {
        registerWithOMS();
        ensureIcon();
      });
    }
  }

  // After createMarkers runs, window.markerLayers[sgid] is a LayerGroup.
  function postProcessNewMarkersForSg(sgid) {
    try {
      const lg = unsafeWindow.markerLayers?.[sgid];
      if (!lg || typeof lg.getLayers !== 'function') return;

      // Reset indexer so attachments for this subgroup follow XML order on every build
      seenIndexBySg.set(sgid, new Map());

      const layers = lg.getLayers();
      for (const layer of layers) {
        if (layer && typeof layer.getLatLng === 'function') {
          attachToggleToMarker(layer, sgid);
        }
      }
    } catch { /* noop */ }
  }

  // ---------- bootstrap / monkey-patch ----------
  function start() {
    const okUI = scanUI();
    const xmlDoc = unsafeWindow.xmlDoc;
    const L = unsafeWindow.L;
    if (!okUI || !xmlDoc || !L) {
      requestAnimationFrame(start);
      return;
    }

    ensureTopLinks();

    const mo = new MutationObserver(() => {
      const before = topLinksByName.size;
      const ok = ensureTopLinks();
      if (ok && topLinksByName.size !== before) updateAllUI();
    });
    mo.observe(document.body, { childList: true, subtree: true });

    // Migrate old (non-indexed) keys once
    migrateCollectedKeysToIndexed();

    // Build totals; initialize collectedBySg from storage
    buildTotalsFromXml(xmlDoc);

    // Patch createMarkers to hook new markers as they are created
    const origCreateMarkers = unsafeWindow.createMarkers;
    if (typeof origCreateMarkers === 'function' && !origCreateMarkers.__wrapped) {
      unsafeWindow.createMarkers = function patchedCreateMarkers(xml, itemid) {
        const ret = origCreateMarkers.apply(this, arguments);

        // itemid looks like "Chaos Item~Baby Panay Statues"
        const parts = (itemid || '').split('~');
        const sgid = normalizeSpaces(parts[1] || '');

        if (sgid) postProcessNewMarkersForSg(sgid);

        if (sgid) {
          updateSubButton(sgid);
          const gid = gidOfSg.get(sgid);
          if (gid) updateTopTabByGid(gid);
        } else {
          updateAllUI();
        }
        return ret;
      };
      unsafeWindow.createMarkers.__wrapped = true;
    }

    // First-pass UI
    updateAllUI();

    // Safety net: bind any existing markers
    setTimeout(() => {
      for (const sgid of buttonsBySg.keys()) postProcessNewMarkersForSg(sgid);
      updateAllUI();
    }, 500);
  }

  // Kick off
  start();
})();

// ---- Overzoom without missing tiles ----
(function enableOverzoom() {
  const MAX_Z = 10; // how far you want to zoom in
  const NATIVE_MAX = 5; // highest zoom with real tiles on the site

  function whenReady(fn) {
    if (unsafeWindow.L && unsafeWindow.map && typeof unsafeWindow.map.getZoom === 'function') {
      fn();
    } else {
      setTimeout(() => whenReady(fn), 100);
    }
  }

  whenReady(() => {
    const L = unsafeWindow.L;
    const map = unsafeWindow.map;

    if (typeof map.setMaxZoom === 'function') map.setMaxZoom(MAX_Z);
    map.options.maxZoom = MAX_Z;

    function patchTileLayer(layer) {
      if (!layer || !layer.options) return;
      layer.options.maxZoom = MAX_Z;
      layer.options.maxNativeZoom = NATIVE_MAX;
      if (typeof layer.setMaxZoom === 'function') layer.setMaxZoom(MAX_Z);
      if (typeof layer.setMaxNativeZoom === 'function') layer.setMaxNativeZoom(NATIVE_MAX);
      if (typeof layer.redraw === 'function') layer.redraw();
    }

    Object.values(map._layers || {}).forEach(l => {
      try { if (l instanceof L.TileLayer) patchTileLayer(l); } catch {}
    });

    const origAddLayer = map.addLayer;
    map.addLayer = function wrappedAddLayer(layer) {
      const res = origAddLayer.apply(this, arguments);
      try { if (layer instanceof L.TileLayer) patchTileLayer(layer); } catch {}
      return res;
    };

    if (!L.TileLayer.prototype.__overzoomPatched) {
      const origGet = L.TileLayer.prototype._getZoomForUrl;
      L.TileLayer.prototype._getZoomForUrl = function () {
        const z = origGet ? origGet.call(this) : (this._map ? this._map.getZoom() : NATIVE_MAX);
        const maxNat = (this.options && this.options.maxNativeZoom != null) ? this.options.maxNativeZoom : NATIVE_MAX;
        return (z > maxNat) ? maxNat : z;
      };
      L.TileLayer.prototype.__overzoomPatched = true;
    }

    if (typeof unsafeWindow.zoomFine === 'function') {
      const orig = unsafeWindow.zoomFine;
      unsafeWindow.zoomFine = function (e) {
        return orig.apply(this, arguments);
      };
    }

    map.on('zoomend', () => map.panInsideBounds(map.options.maxBounds || map.getBounds(), { animate: false }));
  });
})();

// --- Overlapping markers: spiderfy on hover ---
(function addSpiderfy() {
  const OMS_URLS = [
    'https://unpkg.com/overlapping-marker-spiderfier-leaflet/dist/oms.min.js',
    'https://cdn.jsdelivr.net/npm/overlapping-marker-spiderfier-leaflet/dist/oms.min.js'
  ];

  function inject(urls, done) {
    if (unsafeWindow.OverlappingMarkerSpiderfier) return done();
    const url = urls.shift();
    if (!url) return console.warn('[OMS] failed to load');
    const s = document.createElement('script');
    s.src = url; s.async = true;
    s.onload = () => done();
    s.onerror = () => inject(urls, done);
    document.head.appendChild(s);
  }

  function initOMS() {
    const map = unsafeWindow.map;
    if (!map || !unsafeWindow.OverlappingMarkerSpiderfier) return;

    const oms = new unsafeWindow.OverlappingMarkerSpiderfier(map, {
      keepSpiderfied: true,
      nearbyDistance: 8,
      circleSpiralSwitchover: 9,
      spiralFootSeparation: 20,
      spiralLengthStart: 11,
      spiralLengthFactor: 5,
      legWeight: 1.2
      // legColors: { usual: '#aaa', highlighted: '#fff' },
    });
    unsafeWindow.__oms = oms;

    let hoverTimer = null;
    function scheduleUnspiderfy() {
      clearTimeout(hoverTimer);
      hoverTimer = setTimeout(() => oms.unspiderfy(), 250);
    }

    unsafeWindow.__jc2_addToOms = function (marker) {
      try {
        oms.addMarker(marker);
        marker.on('mouseover', () => {
          clearTimeout(hoverTimer);
          oms.spiderfy(marker);
        });
        marker.on('mouseout', scheduleUnspiderfy);
      } catch {}
    };

    // Best-effort register already-present markers
    try {
      Object.values(map._layers || {}).forEach(l => {
        if (l && typeof l.getLatLng === 'function') {
          unsafeWindow.__jc2_addToOms(l);
        }
      });
    } catch {}
  }

  (function waitForMap() {
    if (unsafeWindow.L && unsafeWindow.map && typeof unsafeWindow.map.getZoom === 'function') {
      inject(OMS_URLS.slice(), initOMS);
    } else {
      setTimeout(waitForMap, 100);
    }
  })();
})();