Simkl Country Map

Adds a clickable world map under "Country of Origin" on Simkl stats pages, showing watched vs. missing countries.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==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);
})();