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 เพื่อติดตั้งสคริปต์นี้

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

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

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

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