您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
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); } })(); })();