Adds a clickable world map under "Country of Origin" on Simkl stats pages, showing watched vs. missing countries.
// ==UserScript== // @name Simkl Country Map // @namespace simkl-country-map // @version 1.1 // @description Adds a clickable world map under "Country of Origin" on Simkl stats pages, showing watched vs. missing countries. // @author you // @match https://simkl.com/*/stats* // @require https://cdn.jsdelivr.net/npm/[email protected]/dist/js/jsvectormap.min.js // @require https://cdn.jsdelivr.net/npm/[email protected]/dist/maps/world.js // @resource jvmCSS https://cdn.jsdelivr.net/npm/[email protected]/dist/css/jsvectormap.min.css // @grant GM_addStyle // @grant GM_getResourceText // @run-at document-idle // ==/UserScript== (function () { 'use strict'; const DEBUG = true; // set to false once things work, to quiet the console const log = (...args) => DEBUG && console.log('[SimklCountryMap]', ...args); // ---------- helpers ---------- function getUserId() { // URL looks like https://simkl.com/8280165/stats/ const m = location.pathname.match(/^\/([^/]+)\/stats/); return m ? m[1] : null; } function buildDiscoverUrl(userId, countryCode, watched) { const params = new URLSearchParams({ user: userId, user_not_watched: watched ? 'false' : 'true', countries: countryCode }); return `https://simkl.com/movies/discover/?${params.toString()}`; } // Simkl renders each watched country as: <a class="stats-cty-li" href=".../discover/?...&countries=US"> // We use those links directly — the country code comes straight from the href's // "countries" query param, which is far more reliable than guessing from CSS/images. function findCountryOriginContainer() { const items = document.querySelectorAll('a.stats-cty-li'); if (!items.length) return null; // Climb from the first item until we find the smallest ancestor holding ALL items, // then go one level up so the map lands after the whole block (heading included). let node = items[0]; while (node.parentElement) { node = node.parentElement; if (node.querySelectorAll('a.stats-cty-li').length === items.length) { return node.parentElement || node; } } return items[0].parentElement; } // Returns { codes: Set<string>, stats: { [code]: {name, percent, extra} } } function extractWatchedCountries() { const codes = new Set(); const stats = {}; document.querySelectorAll('a.stats-cty-li').forEach((link) => { let code = null; try { const url = new URL(link.getAttribute('href'), location.origin); code = url.searchParams.get('countries'); } catch (e) { /* ignore malformed href */ } if (!code) return; code = code.toUpperCase(); codes.add(code); const name = link.querySelector('i')?.textContent?.trim() || code; const percent = link.querySelector('b')?.textContent?.trim() || ''; const extra = link.querySelector('span')?.textContent?.trim() || ''; stats[code] = { name, percent, extra }; }); log('Detected watched country codes:', [...codes], 'stats:', stats); return { codes, stats }; } // ---------- map rendering ---------- const WATCHED_COLOR = '#2ecc71'; const MISSING_COLOR = '#3a3f44'; const HOVER_COLOR = '#f4b400'; function injectStyles() { if (document.getElementById('scm-styles')) return; // jsVectorMap needs its own stylesheet for the SVG/container to have any size at all. // Without this it renders in the DOM but stays invisible (0x0). try { GM_addStyle(GM_getResourceText('jvmCSS')); } catch (e) { log('Failed to load jsVectorMap CSS resource, falling back to minimal inline rules.', e); GM_addStyle(` .jvm-container { position: relative; overflow: hidden; } .jvm-container svg { width: 100%; height: 100%; } .jvm-tooltip { position: absolute; display: none; border-radius: 3px; background: #292929; color: #fff; font-family: sans-serif; font-size: 12px; padding: 4px 8px; z-index: 1000; } .jvm-zoom-btn { position: absolute; cursor: pointer; line-height: 10px; width: 15px; height: 15px; background: #292929; color: #fff; padding: 3px; box-sizing: content-box; border-radius: 3px; left: 10px; font-size: 12px; border: 1px solid #4a4a4a; } .jvm-zoomin { top: 10px; } .jvm-zoomout { top: 30px; } `); } const style = document.createElement('style'); style.id = 'scm-styles'; style.textContent = ` #scm-map-wrapper { margin-top: 16px; padding: 12px; border-radius: 8px; background: rgba(255,255,255,0.03); } #scm-map-wrapper h3 { margin: 0 0 8px 0; font-size: 14px; opacity: 0.8; } #scm-map { width: 100%; height: 340px; } #scm-legend { display: flex; gap: 16px; margin-top: 8px; font-size: 12px; opacity: 0.85; } #scm-legend span { display: inline-flex; align-items: center; gap: 6px; } #scm-legend i { width: 10px; height: 10px; border-radius: 2px; display: inline-block; } `; document.head.appendChild(style); } function renderMap(container, watchedCodes, stats, userId) { injectStyles(); let wrapper = document.getElementById('scm-map-wrapper'); if (wrapper) wrapper.remove(); wrapper = document.createElement('div'); wrapper.id = 'scm-map-wrapper'; wrapper.innerHTML = ` <h3>Country Map</h3> <div id="scm-map"></div> <div id="scm-legend"> <span><i style="background:${WATCHED_COLOR}"></i> Watched</span> <span><i style="background:${MISSING_COLOR}"></i> Not watched yet</span> </div> `; container.insertAdjacentElement('afterend', wrapper); if (typeof jsVectorMap === 'undefined') { log('jsVectorMap failed to load — check your network settings / ad blocker for jsdelivr.net'); wrapper.querySelector('#scm-map').textContent = 'Map library failed to load.'; return; } const values = {}; // jsVectorMap's "world" dataset keys regions by UPPERCASE ISO 3166-1 alpha-2 codes // (e.g. "US", "CA", "GB") — NOT lowercase. Using lowercase keys here means every // value silently fails to match a region, which is why nothing was turning green. // // Also: series.regions values must be NUMBERS, not color strings. The library // normalizes each value between min/max and interpolates a color from `scale`. // We give every watched country the numeric value 1, and explicitly pin min/max to // 0/1 so the normalize step always has a real range to work with, regardless of how // many countries are watched. (Letting the library auto-detect min/max from the data // would collapse to min === max whenever every present value is the same — exactly // our case, since every watched country gets the same "1" — producing an invalid color.) watchedCodes.forEach((code) => { values[code.toUpperCase()] = 1; }); const map = new jsVectorMap({ selector: '#scm-map', map: 'world', backgroundColor: 'transparent', zoomButtons: true, regionStyle: { initial: { fill: MISSING_COLOR, "fill-opacity": 1, stroke: '#111', 'stroke-width': 0.5 }, hover: { fill: HOVER_COLOR, cursor: 'pointer' } }, series: { regions: [ { values, attribute: 'fill', scale: [MISSING_COLOR, WATCHED_COLOR], normalizeFunction: 'linear', min: 0, max: 1 } ] }, onRegionTooltipShow(event, tooltip, code) { const upper = code.toUpperCase(); const isWatched = watchedCodes.has(upper); const s = stats[upper]; let extraText; if (isWatched && s) { extraText = `Watched — ${s.percent}${s.extra ? ' ' + s.extra : ''}`; } else { extraText = 'Not watched yet'; } tooltip.text(`${tooltip.text()} — ${extraText}`, false); }, onRegionClick(event, code) { const isWatched = watchedCodes.has(code.toUpperCase()); const url = buildDiscoverUrl(userId, code.toUpperCase(), isWatched); window.open(url, '_blank'); } }); log('Map rendered.', map); } // ---------- boot ---------- function tryInit(attemptsLeft) { const userId = getUserId(); if (!userId) { log('Could not determine user id from URL.'); return; } const container = findCountryOriginContainer(); if (!container) { if (attemptsLeft > 0) { setTimeout(() => tryInit(attemptsLeft - 1), 500); } else { log('Could not find any a.stats-cty-li links. If Simkl changed their markup, ' + 'open devtools console, look for this log, and share the updated HTML.'); } return; } const { codes, stats } = extractWatchedCountries(); if (codes.size === 0) { log('Found the container but detected 0 countries from a.stats-cty-li links. ' + 'Please double check the country entries still use that markup.'); } renderMap(container, codes, stats, userId); } // Simkl's stats page may render this section asynchronously, so we poll briefly. tryInit(20); })();