MapyClimbs Mobile

Detect climbs on Mapy.com / Mapy.cz routes in mobile browsers with a floating analysis panel.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         MapyClimbs Mobile
// @namespace    https://mapy.com/
// @version      0.1.0
// @description  Detect climbs on Mapy.com / Mapy.cz routes in mobile browsers with a floating analysis panel.
// @match        https://mapy.com/*
// @match        https://*.mapy.com/*
// @match        https://mapy.cz/*
// @match        https://*.mapy.cz/*
// @run-at       document-start
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_addStyle
// ==/UserScript==

(function () {
  "use strict";

  const STORAGE = {
    pendingGPX: "pendingGPX",
    gpxCaptureTime: "gpxCaptureTime",
    lastClimbResult: "lastClimbResult",
    lastTotalDistance: "lastTotalDistance",
    scoringModel: "scoringModel",
    markersEnabled: "markersEnabled",
    panelOpen: "panelOpen",
  };

  const MODELS = {
    aso: {
      score(distanceMeters, avgGrade) {
        return (distanceMeters / 1000) * avgGrade * avgGrade;
      },
      minScore: 0,
      thresholds: [
        { category: "HC", min: 600 },
        { category: "1", min: 300 },
        { category: "2", min: 150 },
        { category: "3", min: 75 },
        { category: "4", min: 0 },
      ],
    },
    garmin: {
      score(distanceMeters, avgGrade) {
        return distanceMeters * avgGrade;
      },
      minScore: 1500,
      thresholds: [
        { category: "HC", min: 64000 },
        { category: "1", min: 48000 },
        { category: "2", min: 32000 },
        { category: "3", min: 16000 },
        { category: "4", min: 8000 },
      ],
    },
  };

  const TEXT = {
    title: "MapyClimbs",
    openPanel: "Open climb analysis",
    closePanel: "Close",
    analyze: "Analyze Route",
    analyzing: "Waiting for GPX export...",
    importGPX: "Import GPX",
    retry: "Retry Last GPX",
    noClimbs: "No climbs detected on this route.",
    routeOverview: "Route overview",
    distance: "Distance",
    elevation: "Elevation",
    avgGrade: "Avg grade",
    maxGrade: "Max grade",
    summit: "Summit",
    summitAt: "Summit at",
    estTime: "Est. time",
    vam: "VAM",
    fiets: "Fiets index",
    markers: "Markers",
    model: "Scoring",
    importHint:
      "If Mapy's mobile export flow changes, use Import GPX as a fallback.",
    routeNotDetected:
      "Route planner UI not detected. Open a route on Mapy and try again.",
    exportNotFound:
      "Could not find a GPX export control on this page. Use Import GPX as a fallback.",
    exportNoCapture:
      "No GPX was captured. If Mapy opened a dialog, complete it once and try again.",
    gpxCaptured: "GPX captured and analyzed.",
    gpxImported: "GPX imported and analyzed.",
    invalidGPX: "Invalid GPX file.",
    waitingForPage: "Waiting for the route planner to load...",
    climbsDetected(count) {
      return count === 1 ? "1 climb detected" : `${count} climbs detected`;
    },
    climbLabel(index) {
      return `Climb ${index + 1}`;
    },
    category(category) {
      return `Cat ${category}`;
    },
  };

  const CATEGORY_COLORS = {
    HC: "#800020",
    "1": "#D32F2F",
    "2": "#F57C00",
    "3": "#FBC02D",
    "4": "#4CAF50",
  };

  const state = {
    pendingGPX: "",
    gpxCaptureTime: 0,
    climbs: [],
    totalDistance: 0,
    scoringModel: readStorage(STORAGE.scoringModel, "aso"),
    markersEnabled: readStorage(STORAGE.markersEnabled, true),
    panelOpen: readStorage(STORAGE.panelOpen, false),
    status: "",
    statusKind: "idle",
    lastObservedHref: location.href,
    lastTriggerCaptureStart: 0,
    lastTriggerCaptureDeadline: 0,
    exportObserver: null,
    uiReady: false,
    root: null,
    fileInput: null,
  };

  injectBridgeScript();
  installStyles();

  window.addEventListener("message", handleWindowMessage);

  if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", bootstrap, { once: true });
  } else {
    bootstrap();
  }

  function bootstrap() {
    hydrateStoredState();
    ensureUI();
    render();

    window.setInterval(() => {
      if (state.lastObservedHref !== location.href) {
        state.lastObservedHref = location.href;
        render();
        syncMarkers();
      }

      if (!state.root || !document.body.contains(state.root)) {
        ensureUI();
        render();
      }
    }, 1000);

    if (state.pendingGPX && !state.climbs.length) {
      analyzeGPX(state.pendingGPX, "stored");
    } else {
      syncMarkers();
    }
  }

  function hydrateStoredState() {
    const pendingGPX = readStorage(STORAGE.pendingGPX, "");
    const captureTime = readStorage(STORAGE.gpxCaptureTime, 0);
    const climbs = readStorage(STORAGE.lastClimbResult, []);
    const totalDistance = readStorage(STORAGE.lastTotalDistance, 0);

    state.pendingGPX = typeof pendingGPX === "string" ? pendingGPX : "";
    state.gpxCaptureTime = typeof captureTime === "number" ? captureTime : 0;
    state.climbs = Array.isArray(climbs) ? climbs : [];
    state.totalDistance =
      typeof totalDistance === "number" ? totalDistance : 0;
  }

  function handleWindowMessage(event) {
    if (event.source !== window || event.origin !== location.origin) {
      return;
    }

    const data = event.data;
    if (!data || typeof data !== "object") {
      return;
    }

    if (data.type === "MAPYCLIMBS_GPX_FETCHED" && typeof data.gpxContent === "string") {
      state.pendingGPX = data.gpxContent;
      state.gpxCaptureTime =
        typeof data.timestamp === "number" ? data.timestamp : Date.now();
      writeStorage(STORAGE.pendingGPX, state.pendingGPX);
      writeStorage(STORAGE.gpxCaptureTime, state.gpxCaptureTime);
      analyzeGPX(state.pendingGPX, "capture");
    }
  }

  function analyzeGPX(gpxContent, source) {
    let points;
    try {
      points = parseGPX(gpxContent);
    } catch (error) {
      setStatus(error instanceof Error ? error.message : TEXT.invalidGPX, "error");
      return;
    }

    const climbs = detectClimbs(points, state.scoringModel);
    const totalDistance = points.length ? points[points.length - 1][0] : 0;

    state.pendingGPX = gpxContent;
    state.climbs = climbs;
    state.totalDistance = totalDistance;
    state.panelOpen = true;

    writeStorage(STORAGE.pendingGPX, state.pendingGPX);
    writeStorage(STORAGE.lastClimbResult, state.climbs);
    writeStorage(STORAGE.lastTotalDistance, state.totalDistance);
    writeStorage(STORAGE.panelOpen, state.panelOpen);

    if (source === "capture") {
      setStatus(TEXT.gpxCaptured, "ok");
    } else if (source === "import") {
      setStatus(TEXT.gpxImported, "ok");
    } else {
      setStatus("", "idle");
    }

    render();
    syncMarkers();
  }

  function ensureUI() {
    if (state.uiReady && state.root && document.body.contains(state.root)) {
      return;
    }

    if (!document.body) {
      return;
    }

    const root = document.createElement("div");
    root.id = "mapyclimbs-mobile-root";
    root.innerHTML = `
      <button id="mapyclimbs-fab" type="button" aria-label="${escapeHtml(TEXT.openPanel)}">
        <span class="mc-fab-label">${escapeHtml(TEXT.title)}</span>
        <span class="mc-fab-count" hidden></span>
      </button>
      <div id="mapyclimbs-overlay" hidden></div>
      <button id="mapyclimbs-close" type="button" class="mc-overlay-close" aria-label="${escapeHtml(TEXT.closePanel)}" hidden>×</button>
      <section id="mapyclimbs-sheet" aria-label="${escapeHtml(TEXT.title)}" hidden>
        <header class="mc-sheet-header">
          <div class="mc-title-wrap">
            <div class="mc-title">${escapeHtml(TEXT.title)}</div>
            <div id="mapyclimbs-status" class="mc-status"></div>
          </div>
        </header>
        <div class="mc-toolbar">
          <button id="mapyclimbs-analyze" type="button" class="mc-primary-btn">${escapeHtml(TEXT.analyze)}</button>
          <button id="mapyclimbs-import" type="button" class="mc-secondary-btn">${escapeHtml(TEXT.importGPX)}</button>
          <button id="mapyclimbs-retry" type="button" class="mc-secondary-btn">${escapeHtml(TEXT.retry)}</button>
        </div>
        <div class="mc-settings">
          <div class="mc-setting">
            <span>${escapeHtml(TEXT.model)}</span>
            <div class="mc-pill-group">
              <button type="button" class="mc-pill" data-model="aso">ASO</button>
              <button type="button" class="mc-pill" data-model="garmin">Garmin</button>
            </div>
          </div>
          <label class="mc-setting mc-toggle">
            <span>${escapeHtml(TEXT.markers)}</span>
            <input id="mapyclimbs-markers" type="checkbox">
          </label>
        </div>
        <div id="mapyclimbs-content" class="mc-content"></div>
        <p class="mc-footer-note">${escapeHtml(TEXT.importHint)}</p>
      </section>
    `;

    document.body.appendChild(root);

    const fileInput = document.createElement("input");
    fileInput.type = "file";
    fileInput.accept = ".gpx,application/gpx+xml,application/xml,text/xml";
    fileInput.style.display = "none";
    fileInput.addEventListener("change", handleImportFile);
    document.body.appendChild(fileInput);

    state.root = root;
    state.fileInput = fileInput;
    state.uiReady = true;

    const fab = byId("mapyclimbs-fab");
    const overlay = byId("mapyclimbs-overlay");
    const close = byId("mapyclimbs-close");
    const analyze = byId("mapyclimbs-analyze");
    const importButton = byId("mapyclimbs-import");
    const retry = byId("mapyclimbs-retry");
    const markers = byId("mapyclimbs-markers");

    fab.addEventListener("click", () => {
      state.panelOpen = true;
      writeStorage(STORAGE.panelOpen, state.panelOpen);
      render();
    });

    overlay.addEventListener("click", () => {
      state.panelOpen = false;
      writeStorage(STORAGE.panelOpen, state.panelOpen);
      render();
    });

    close.addEventListener("click", () => {
      state.panelOpen = false;
      writeStorage(STORAGE.panelOpen, state.panelOpen);
      render();
    });

    analyze.addEventListener("click", triggerRouteAnalysis);
    importButton.addEventListener("click", () => state.fileInput.click());
    retry.addEventListener("click", () => {
      if (!state.pendingGPX) {
        setStatus(TEXT.exportNoCapture, "warning");
        render();
        return;
      }
      analyzeGPX(state.pendingGPX, "retry");
    });

    markers.checked = state.markersEnabled;
    markers.addEventListener("change", () => {
      state.markersEnabled = markers.checked;
      writeStorage(STORAGE.markersEnabled, state.markersEnabled);
      syncMarkers();
      render();
    });

    root.querySelectorAll("[data-model]").forEach((button) => {
      button.addEventListener("click", () => {
        const nextModel = button.getAttribute("data-model");
        if (!nextModel || nextModel === state.scoringModel) {
          return;
        }
        state.scoringModel = nextModel;
        writeStorage(STORAGE.scoringModel, state.scoringModel);
        if (state.pendingGPX) {
          analyzeGPX(state.pendingGPX, "recompute");
        } else {
          render();
        }
      });
    });
  }

  function render() {
    if (!state.uiReady) {
      return;
    }

    const fab = byId("mapyclimbs-fab");
    const overlay = byId("mapyclimbs-overlay");
    const sheet = byId("mapyclimbs-sheet");
    const close = byId("mapyclimbs-close");
    const status = byId("mapyclimbs-status");
    const content = byId("mapyclimbs-content");
    const count = state.root.querySelector(".mc-fab-count");
    const retry = byId("mapyclimbs-retry");
    const analyze = byId("mapyclimbs-analyze");

    const shouldShowFab = isRoutePlannerLikely() || !!state.pendingGPX || !!state.climbs.length;
    fab.hidden = !shouldShowFab;

    overlay.hidden = !state.panelOpen;
    sheet.hidden = !state.panelOpen;
    close.hidden = !state.panelOpen;

    if (state.status) {
      status.textContent = state.status;
      status.dataset.kind = state.statusKind;
    } else {
      status.textContent = "";
      status.dataset.kind = "idle";
    }

    count.hidden = !state.climbs.length;
    count.textContent = state.climbs.length ? String(state.climbs.length) : "";

    retry.disabled = !state.pendingGPX;
    analyze.disabled = false;

    state.root
      .querySelectorAll("[data-model]")
      .forEach((button) => {
        button.classList.toggle(
          "is-active",
          button.getAttribute("data-model") === state.scoringModel,
        );
      });

    if (!state.pendingGPX && !isRoutePlannerLikely()) {
      content.innerHTML = `<div class="mc-empty">${escapeHtml(TEXT.routeNotDetected)}</div>`;
      return;
    }

    if (!state.climbs.length) {
      if (state.pendingGPX) {
        content.innerHTML = `<div class="mc-empty">${escapeHtml(TEXT.noClimbs)}</div>`;
      } else {
        content.innerHTML = `<div class="mc-empty">${escapeHtml(TEXT.waitingForPage)}</div>`;
      }
      return;
    }

    content.innerHTML = renderOverview(state.climbs, state.totalDistance) +
      state.climbs.map(renderClimbCard).join("");
  }

  function renderOverview(climbs, totalDistance) {
    const totalClimbElevation = climbs.reduce((sum, climb) => sum + climb.elevation, 0);
    const maxGrade = computeWindowMaxGrade(
      climbs.flatMap((climb) => climb.segments),
      200,
    );

    return `
      <section class="mc-card mc-overview">
        <div class="mc-section-title">${escapeHtml(TEXT.routeOverview)}</div>
        <div class="mc-overview-stats">
          ${renderStat(fmtKm(totalDistance, 1), "km total")}
          ${renderStat(`+${Math.round(totalClimbElevation)}`, "m climbing")}
          ${renderStat(fmtPct(maxGrade, 1), "max grade")}
          ${renderStat(String(climbs.length), "climbs")}
        </div>
        <div class="mc-overview-strip">
          ${renderRouteStrip(climbs, totalDistance)}
        </div>
        <div class="mc-overview-caption">${escapeHtml(TEXT.climbsDetected(climbs.length))}</div>
      </section>
    `;
  }

  function renderStat(value, label) {
    return `
      <div class="mc-stat">
        <div class="mc-stat-value">${escapeHtml(value)}</div>
        <div class="mc-stat-label">${escapeHtml(label)}</div>
      </div>
    `;
  }

  function renderRouteStrip(climbs, totalDistance) {
    if (!totalDistance) {
      return "";
    }

    return climbs
      .map((climb, index) => {
        const start = climb.segments[0]?.startDistance || 0;
        const end =
          climb.segments[climb.segments.length - 1]?.endDistance || start;
        const left = (start / totalDistance) * 100;
        const width = Math.max(((end - start) / totalDistance) * 100, 1.5);
        const color = CATEGORY_COLORS[climb.category] || CATEGORY_COLORS["4"];

        return `
          <span
            class="mc-strip-segment"
            style="left:${left.toFixed(2)}%;width:${width.toFixed(2)}%;background:${color}"
            title="${escapeHtml(TEXT.climbLabel(index))}"
          >${width > 8 ? escapeHtml(String(index + 1)) : ""}</span>
        `;
      })
      .join("");
  }

  function renderClimbCard(climb, index) {
    const maxGrade = computeWindowMaxGrade(climb.segments, 200);
    const summit = findSummit(climb.segments);
    const estMinutes = estimateClimbMinutes(climb);
    const color = CATEGORY_COLORS[climb.category] || CATEGORY_COLORS["4"];

    return `
      <article class="mc-card mc-climb" style="--mc-accent:${color}">
        <div class="mc-climb-head">
          <div class="mc-climb-title">${escapeHtml(TEXT.climbLabel(index))}</div>
          <div class="mc-climb-badge">${escapeHtml(TEXT.category(climb.category))}</div>
        </div>
        <div class="mc-grid">
          ${renderMetric(TEXT.distance, `${fmtKm(climb.distance, 2)} km`)}
          ${renderMetric(TEXT.elevation, `+${Math.round(climb.elevation)} m`)}
          ${renderMetric(TEXT.avgGrade, fmtPct(climb.avgGrade, 1))}
          ${renderMetric(TEXT.maxGrade, fmtPct(maxGrade, 1))}
          ${renderMetric(TEXT.summit, `${Math.round(summit.elev)} m`)}
          ${renderMetric(TEXT.summitAt, `${fmtKm(summit.dist, 1)} km`)}
        </div>
        <div class="mc-meta">
          ${renderMeta(TEXT.estTime, fmtDuration(estMinutes))}
          ${renderMeta(TEXT.vam, `${computeVAM(climb)} m/h`)}
          ${renderMeta(TEXT.fiets, computeFietsIndex(climb))}
        </div>
      </article>
    `;
  }

  function renderMetric(label, value) {
    return `
      <div class="mc-metric">
        <div class="mc-metric-label">${escapeHtml(label)}</div>
        <div class="mc-metric-value">${escapeHtml(value)}</div>
      </div>
    `;
  }

  function renderMeta(label, value) {
    return `
      <div class="mc-meta-item">
        <span>${escapeHtml(label)}</span>
        <strong>${escapeHtml(value)}</strong>
      </div>
    `;
  }

  async function handleImportFile(event) {
    const input = event.target;
    const file = input.files && input.files[0];
    if (!file) {
      return;
    }

    try {
      const text = await file.text();
      analyzeGPX(text, "import");
    } catch {
      setStatus(TEXT.invalidGPX, "error");
      render();
    } finally {
      input.value = "";
    }
  }

  function triggerRouteAnalysis() {
    if (!isRoutePlannerLikely()) {
      setStatus(TEXT.routeNotDetected, "warning");
      render();
      return;
    }

    const exportButton = findExportButton();
    if (!exportButton) {
      setStatus(TEXT.exportNotFound, "warning");
      render();
      return;
    }

    state.panelOpen = true;
    writeStorage(STORAGE.panelOpen, state.panelOpen);
    state.lastTriggerCaptureStart = Date.now();
    state.lastTriggerCaptureDeadline = state.lastTriggerCaptureStart + 7000;

    setStatus(TEXT.analyzing, "pending");
    observeExportDialog();
    suppressDownloadOnce();

    try {
      exportButton.click();
    } catch {
      setStatus(TEXT.exportNotFound, "error");
      render();
      return;
    }

    render();

    window.setTimeout(() => {
      if (state.gpxCaptureTime < state.lastTriggerCaptureStart) {
        setStatus(TEXT.exportNoCapture, "warning");
        render();
      }
    }, 7500);
  }

  function observeExportDialog() {
    if (state.exportObserver) {
      state.exportObserver.disconnect();
    }

    state.exportObserver = new MutationObserver(() => {
      const confirmButton = findExportConfirmButton();
      if (!confirmButton) {
        return;
      }

      suppressDownloadOnce();
      try {
        confirmButton.click();
      } catch {
        return;
      }

      if (state.exportObserver) {
        state.exportObserver.disconnect();
        state.exportObserver = null;
      }
    });

    state.exportObserver.observe(document.documentElement, {
      childList: true,
      subtree: true,
    });

    window.setTimeout(() => {
      if (state.exportObserver) {
        state.exportObserver.disconnect();
        state.exportObserver = null;
      }
    }, 6000);
  }

  function findExportButton() {
    const directSelectors = [
      ".icon-action[title='Export'] button",
      ".icon-action[title='GPX'] button",
      "button[title*='Export']",
      "button[title*='GPX']",
      "button[aria-label*='Export']",
      "button[aria-label*='GPX']",
      "[role='button'][title*='Export']",
      "[role='button'][title*='GPX']",
      "[data-testid*='export']",
    ];

    for (const selector of directSelectors) {
      const match = document.querySelector(selector);
      if (isVisible(match)) {
        return match;
      }
    }

    const candidates = Array.from(
      document.querySelectorAll("button, a, [role='button']"),
    );

    return (
      candidates.find((element) => {
        if (!isVisible(element)) {
          return false;
        }

        const text = normalizedText(element);
        return /^(export|gpx|export gpx|download|save)$/i.test(text);
      }) || null
    );
  }

  function findExportConfirmButton() {
    const directSelectors = [
      ".mymaps-dialog__saveBtn",
      "button[type='submit']",
      "[data-testid*='save']",
      "[data-testid*='export']",
    ];

    for (const selector of directSelectors) {
      const match = document.querySelector(selector);
      if (isVisible(match) && isConfirmish(match)) {
        return match;
      }
    }

    const candidates = Array.from(
      document.querySelectorAll("button, [role='button']"),
    );

    return (
      candidates.find((element) => {
        if (!isVisible(element)) {
          return false;
        }
        return isConfirmish(element);
      }) || null
    );
  }

  function isConfirmish(element) {
    const text = normalizedText(element);
    return /^(save|export|gpx|download|continue|ok)$/i.test(text);
  }

  function isRoutePlannerLikely() {
    if (/planovani-trasy/i.test(location.href)) {
      return true;
    }

    const plannerSelectors = [
      ".route-actions",
      ".route-modules",
      ".route-container",
      ".mymaps-panel",
      "#map",
      "[data-testid*='route']",
      "[class*='route-planner']",
    ];

    return plannerSelectors.some((selector) => document.querySelector(selector));
  }

  function syncMarkers() {
    const payload = sanitizeClimbsForMarkers(state.climbs);

    if (!state.markersEnabled || !payload.length) {
      window.postMessage({ type: "MAPYCLIMBS_CLEAR_CLIMB_MARKERS" }, location.origin);
      return;
    }

    window.postMessage(
      {
        type: "MAPYCLIMBS_INJECT_CLIMB_MARKERS",
        climbs: payload,
      },
      location.origin,
    );
  }

  function sanitizeClimbsForMarkers(climbs) {
    return climbs
      .filter((climb) => climb && climb.markerCoords)
      .map((climb) => ({
        distance: Number(climb.distance),
        elevation: Number(climb.elevation),
        category: climb.category,
        markerCoords: {
          lat: Number(climb.markerCoords.lat),
          lon: Number(climb.markerCoords.lon),
        },
      }))
      .filter((climb) => {
        return (
          Number.isFinite(climb.distance) &&
          Number.isFinite(climb.elevation) &&
          Number.isFinite(climb.markerCoords.lat) &&
          Number.isFinite(climb.markerCoords.lon)
        );
      });
  }

  function setStatus(message, kind) {
    state.status = message;
    state.statusKind = kind;
  }

  function suppressDownloadOnce() {
    window.postMessage({ type: "MAPYCLIMBS_SUPPRESS_DOWNLOAD" }, location.origin);
  }

  function readStorage(key, fallback) {
    try {
      if (typeof GM_getValue === "function") {
        return GM_getValue(key, fallback);
      }
    } catch {}

    try {
      const raw = localStorage.getItem(`mapyclimbs:${key}`);
      return raw == null ? fallback : JSON.parse(raw);
    } catch {
      return fallback;
    }
  }

  function writeStorage(key, value) {
    try {
      if (typeof GM_setValue === "function") {
        GM_setValue(key, value);
        return;
      }
    } catch {}

    try {
      localStorage.setItem(`mapyclimbs:${key}`, JSON.stringify(value));
    } catch {}
  }

  function parseGPX(gpxContent) {
    const xml = new DOMParser().parseFromString(gpxContent, "text/xml");
    if (xml.getElementsByTagName("parsererror").length > 0) {
      throw new Error("Invalid XML in GPX file");
    }

    const namespaces = [
      "http://www.topografix.com/GPX/1/1",
      "http://www.topografix.com/GPX/1/0",
      "",
    ];

    let trackPoints = null;
    for (const namespace of namespaces) {
      const points = namespace
        ? xml.getElementsByTagNameNS(namespace, "trkpt")
        : xml.getElementsByTagName("trkpt");
      if (points.length > 0) {
        trackPoints = points;
        break;
      }
    }

    if (!trackPoints || trackPoints.length === 0) {
      throw new Error("No track points found in GPX file");
    }

    const rawPoints = [];
    for (let index = 0; index < trackPoints.length; index += 1) {
      const point = trackPoints[index];
      const lat = Number.parseFloat(point.getAttribute("lat") || "");
      const lon = Number.parseFloat(point.getAttribute("lon") || "");
      const eleNode =
        point.getElementsByTagName("ele")[0] ||
        point.getElementsByTagNameNS("http://www.topografix.com/GPX/1/1", "ele")[0];
      const ele = eleNode ? Number.parseFloat(eleNode.textContent || "0") : 0;

      if (Number.isFinite(lat) && Number.isFinite(lon)) {
        rawPoints.push({
          lat,
          lon,
          ele: Number.isFinite(ele) ? ele : 0,
        });
      }
    }

    if (!rawPoints.length) {
      throw new Error("No valid track points found in GPX file");
    }

    const result = [];
    let cumulativeDistance = 0;

    rawPoints.forEach((point, index) => {
      if (index > 0) {
        const previous = rawPoints[index - 1];
        cumulativeDistance += haversineMeters(
          previous.lat,
          previous.lon,
          point.lat,
          point.lon,
        );
      }

      result.push([cumulativeDistance, point.ele, point.lat, point.lon]);
    });

    return result;
  }

  function detectClimbs(points, model = "aso") {
    if (!points || points.length < 2) {
      return [];
    }

    const mappedPoints = points.map((point) => ({
      distance: point[0],
      elevation: point[1],
      lat: point[2] ?? null,
      lon: point[3] ?? null,
    }));

    const segments = buildSegments(
      smoothOutSpikePoints(smoothElevation(downsamplePoints(mappedPoints))),
    );

    let climbs = mergeNearbyClimbs(findCandidateClimbs(segments), segments)
      .map((candidate) => trimLowGradeEnds(candidate))
      .map((candidate) => categorizeClimb(candidate, model))
      .filter(Boolean);

    climbs = climbs
      .flatMap((climb) =>
        splitOnLongPlateaus(climb)
          .map((splitCandidate) => categorizeClimb(splitCandidate, model))
          .filter(Boolean),
      );

    if (climbs.length > 1) {
      climbs = mergeNearbyClimbs(
        climbs.map((climb) => ({
          segments: climb.segments,
          totalDistance: climb.distance,
          totalElevation: climb.elevation,
        })),
        segments,
        1500,
      )
        .map((candidate) => trimLowGradeEnds(candidate))
        .map((candidate) => categorizeClimb(candidate, model))
        .filter(Boolean);
    }

    return climbs;
  }

  function categorizeDifficulty(distanceMeters, avgGrade, modelName) {
    const model = MODELS[modelName] || MODELS.aso;
    const score = model.score(distanceMeters, avgGrade);
    if (score < model.minScore) {
      return null;
    }

    const match = model.thresholds.find((threshold) => score >= threshold.min);
    return match ? { difficulty: score, category: match.category } : null;
  }

  function downsamplePoints(points) {
    if (points.length <= 2) {
      return points;
    }

    const downsampled = [points[0]];
    for (let index = 1; index < points.length; index += 1) {
      const previous = downsampled[downsampled.length - 1];
      const current = points[index];
      if (current.distance - previous.distance >= 12) {
        downsampled.push(current);
      }
    }

    const lastInputPoint = points[points.length - 1];
    const lastOutputPoint = downsampled[downsampled.length - 1];
    if (lastOutputPoint.distance !== lastInputPoint.distance) {
      downsampled.push(lastInputPoint);
    }

    return downsampled;
  }

  function smoothElevation(points) {
    if (points.length <= 2) {
      return points;
    }

    const localVariability = new Array(points.length);

    for (let index = 0; index < points.length; index += 1) {
      const baseDistance = points[index].distance;
      const baseElevation = points[index].elevation;
      let weightedSlope = 0;
      let totalWeight = 0;

      for (
        let probe = index;
        probe >= 0 && baseDistance - points[probe].distance <= 500;
        probe -= 1
      ) {
        const gap = baseDistance - points[probe].distance;
        const weight = 1 - gap / 500;
        const slope =
          gap > 0
            ? Math.abs(points[probe].elevation - baseElevation) / gap
            : 0;
        weightedSlope += slope * weight;
        totalWeight += weight;
      }

      for (
        let probe = index + 1;
        probe < points.length && points[probe].distance - baseDistance <= 500;
        probe += 1
      ) {
        const gap = points[probe].distance - baseDistance;
        const weight = 1 - gap / 500;
        const slope = Math.abs(points[probe].elevation - baseElevation) / gap;
        weightedSlope += slope * weight;
        totalWeight += weight;
      }

      localVariability[index] = totalWeight > 0 ? weightedSlope / totalWeight : 0;
    }

    const smoothed = new Array(points.length);

    for (let index = 0; index < points.length; index += 1) {
      const variability = localVariability[index];
      let windowSize;

      if (variability > 0.08) {
        windowSize = 50;
      } else if (variability > 0.03) {
        windowSize = 50 + ((0.08 - variability) / (0.08 - 0.03)) * 100;
      } else {
        windowSize = 150 + ((0.03 - variability) / 0.03) * 100;
      }

      windowSize = Math.max(50, Math.min(250, windowSize));

      const baseDistance = points[index].distance;
      let elevationSum = 0;
      let count = 0;

      for (
        let probe = index;
        probe >= 0 && baseDistance - points[probe].distance <= windowSize;
        probe -= 1
      ) {
        elevationSum += points[probe].elevation;
        count += 1;
      }

      for (
        let probe = index + 1;
        probe < points.length && points[probe].distance - baseDistance <= windowSize;
        probe += 1
      ) {
        elevationSum += points[probe].elevation;
        count += 1;
      }

      smoothed[index] = {
        distance: points[index].distance,
        elevation: count > 0 ? elevationSum / count : points[index].elevation,
        lat: points[index].lat,
        lon: points[index].lon,
      };
    }

    return smoothed;
  }

  function smoothOutSpikePoints(points) {
    if (points.length <= 2) {
      return points;
    }

    const adjusted = points.map((point) => ({ ...point }));

    for (let index = 1; index < adjusted.length - 1; index += 1) {
      const previous = points[index - 1];
      const current = points[index];
      const next = points[index + 1];

      const previousSlope = Math.abs(
        (current.elevation - previous.elevation) /
          (current.distance - previous.distance),
      );
      const nextSlope = Math.abs(
        (next.elevation - current.elevation) / (next.distance - current.distance),
      );

      if (
        (previousSlope > 0.12 && nextSlope < 0.08) ||
        (nextSlope > 0.12 && previousSlope < 0.08)
      ) {
        adjusted[index] = {
          ...adjusted[index],
          elevation: (previous.elevation + next.elevation) / 2,
        };
      }
    }

    return adjusted;
  }

  function buildSegments(points) {
    const segments = [];

    for (let index = 1; index < points.length; index += 1) {
      const previous = points[index - 1];
      const current = points[index];
      const distance = current.distance - previous.distance;
      const elevation = current.elevation - previous.elevation;
      const gradient = distance > 0 ? (elevation / distance) * 100 : 0;

      segments.push({
        startDistance: previous.distance,
        endDistance: current.distance,
        distance,
        elevation,
        gradient,
        startElevation: previous.elevation,
        endElevation: current.elevation,
        startLat: previous.lat,
        startLon: previous.lon,
        endLat: current.lat,
        endLon: current.lon,
      });
    }

    return segments;
  }

  function findCandidateClimbs(segments) {
    const candidates = [];
    let current = null;
    let descentDistance = 0;

    for (const segment of segments) {
      if (segment.gradient <= -1) {
        descentDistance += segment.distance;
      } else {
        descentDistance = 0;
      }

      if (segment.gradient >= 2 && current === null) {
        current = {
          segments: [segment],
          totalDistance: segment.distance,
          totalElevation: segment.elevation,
        };
        descentDistance = 0;
        continue;
      }

      if (current !== null) {
        current.segments.push(segment);
        current.totalDistance += segment.distance;
        current.totalElevation += segment.elevation;

        if (descentDistance >= 150) {
          if (current.totalDistance >= 300) {
            const trimmed = trimTrailingGradient(current, 0);
            if (trimmed) {
              candidates.push(trimmed);
            }
          }
          current = null;
          descentDistance = 0;
        }
      }
    }

    if (current && current.totalDistance >= 300) {
      const trimmed = trimTrailingGradient(current, 0);
      if (trimmed) {
        candidates.push(trimmed);
      }
    }

    return candidates;
  }

  function trimTrailingGradient(climb, minimumGradient) {
    const trimmed = {
      ...climb,
      segments: [...climb.segments],
    };

    while (
      trimmed.segments.length > 0 &&
      trimmed.segments[trimmed.segments.length - 1].gradient < minimumGradient
    ) {
      const removed = trimmed.segments.pop();
      trimmed.totalDistance -= removed.distance;
      trimmed.totalElevation -= removed.elevation;
    }

    if (!trimmed.segments.length || trimmed.totalDistance <= 0) {
      return null;
    }

    const avgGrade = (trimmed.totalElevation / trimmed.totalDistance) * 100;
    if (
      trimmed.totalDistance >= 300 &&
      trimmed.totalElevation >= 30 &&
      avgGrade >= 2
    ) {
      return trimmed;
    }

    return null;
  }

  function mergeNearbyClimbs(climbs, allSegments, maxGap = 2000) {
    if (climbs.length <= 1) {
      return climbs;
    }

    const merged = [climbs[0]];

    for (let index = 1; index < climbs.length; index += 1) {
      const previous = merged[merged.length - 1];
      const current = climbs[index];
      const lastPreviousSegment = previous.segments[previous.segments.length - 1];
      const firstCurrentSegment = current.segments[0];

      const gapDistance =
        firstCurrentSegment.startDistance - lastPreviousSegment.endDistance;
      const elevationDrop =
        lastPreviousSegment.endElevation - firstCurrentSegment.startElevation;
      const combinedElevation =
        previous.totalElevation + current.totalElevation;
      const maxAllowedDrop = Math.max(50, combinedElevation * 0.15);

      if (
        gapDistance >= 0 &&
        gapDistance <= maxGap &&
        elevationDrop <= maxAllowedDrop
      ) {
        const bridgingSegments = allSegments.filter((segment) => {
          return (
            segment.startDistance >= lastPreviousSegment.endDistance - 0.1 &&
            segment.startDistance < firstCurrentSegment.startDistance
          );
        });

        const mergedSegments = [
          ...previous.segments,
          ...bridgingSegments,
          ...current.segments,
        ];
        let totalDistance = 0;
        let totalElevation = 0;

        for (const segment of mergedSegments) {
          totalDistance += segment.distance;
          totalElevation += segment.elevation;
        }

        merged[merged.length - 1] = {
          segments: mergedSegments,
          totalDistance,
          totalElevation,
        };
      } else {
        merged.push(current);
      }
    }

    return merged;
  }

  function trimLowGradeEnds(climb) {
    if (!climb.segments || !climb.segments.length) {
      return { segments: [], totalDistance: 0, totalElevation: 0 };
    }

    let start = 0;
    while (start < climb.segments.length && climb.segments[start].gradient < 1.5) {
      start += 1;
    }

    let end = climb.segments.length - 1;
    while (end >= 0 && climb.segments[end].gradient < 1.5) {
      end -= 1;
    }

    if (start > end) {
      return { segments: [], totalDistance: 0, totalElevation: 0 };
    }

    const segments = climb.segments.slice(start, end + 1);
    let totalDistance = 0;
    let totalElevation = 0;

    for (const segment of segments) {
      totalDistance += segment.distance;
      totalElevation += segment.elevation;
    }

    if (totalDistance >= 100) {
      return { segments, totalDistance, totalElevation };
    }

    return { segments: [], totalDistance: 0, totalElevation: 0 };
  }

  function categorizeClimb(climb, modelName) {
    if (!climb || climb.totalDistance === 0 || climb.totalElevation === 0) {
      return null;
    }

    const avgGrade = (climb.totalElevation / climb.totalDistance) * 100;
    const difficulty = categorizeDifficulty(
      climb.totalDistance,
      avgGrade,
      modelName,
    );

    if (!difficulty) {
      return null;
    }

    const startSegment = climb.segments[0];
    const endSegment = climb.segments[climb.segments.length - 1];

    return {
      distance: climb.totalDistance,
      elevation: climb.totalElevation,
      avgGrade,
      difficulty: difficulty.difficulty,
      category: difficulty.category,
      segments: climb.segments,
      markerCoords:
        startSegment?.startLat != null && startSegment?.startLon != null
          ? { lat: startSegment.startLat, lon: startSegment.startLon }
          : null,
      endCoords:
        endSegment?.endLat != null && endSegment?.endLon != null
          ? { lat: endSegment.endLat, lon: endSegment.endLon }
          : null,
    };
  }

  function splitOnLongPlateaus(climb) {
    const fallback = () => ({
      segments: climb.segments,
      totalDistance: climb.distance,
      totalElevation: climb.elevation,
    });

    if (!climb || !climb.segments || climb.segments.length < 2) {
      return [fallback()];
    }

    const splits = [];
    let current = null;
    let flatDistance = 0;

    for (const segment of climb.segments) {
      if (segment.gradient < 2) {
        flatDistance += segment.distance;
        if (current) {
          current.segments.push(segment);
          current.totalDistance += segment.distance;
          current.totalElevation += segment.elevation;
        }

        if (flatDistance >= 400 && current) {
          const trimmed = trimTrailingGradient(current, 2);
          if (trimmed) {
            splits.push(trimmed);
          }
          current = null;
          flatDistance = 0;
        }
      } else {
        flatDistance = 0;
        if (!current) {
          current = { segments: [], totalDistance: 0, totalElevation: 0 };
        }
        current.segments.push(segment);
        current.totalDistance += segment.distance;
        current.totalElevation += segment.elevation;
      }
    }

    if (current) {
      const trimmed = trimTrailingGradient(current, 2);
      if (trimmed) {
        splits.push(trimmed);
      }
    }

    return splits.length ? splits : [fallback()];
  }

  function computeWindowMaxGrade(segments, targetDistance) {
    let best = 0;

    for (let start = 0; start < segments.length; start += 1) {
      let accumulatedDistance = 0;
      let weightedGradient = 0;

      for (let end = start; end < segments.length; end += 1) {
        accumulatedDistance += segments[end].distance;
        weightedGradient += segments[end].gradient * segments[end].distance;
        if (accumulatedDistance >= targetDistance) {
          best = Math.max(best, weightedGradient / accumulatedDistance);
          break;
        }
      }
    }

    return best;
  }

  function findSummit(segments) {
    let highestElevation = -Infinity;
    let distanceAtSummit = 0;

    for (const segment of segments) {
      if (segment.startElevation > highestElevation) {
        highestElevation = segment.startElevation;
        distanceAtSummit = segment.startDistance;
      }

      if (segment.endElevation > highestElevation) {
        highestElevation = segment.endElevation;
        distanceAtSummit = segment.endDistance;
      }
    }

    return { elev: highestElevation, dist: distanceAtSummit };
  }

  function estimateSpeedFactor(avgGrade) {
    return 12 / (1 + avgGrade / 5);
  }

  function estimateClimbMinutes(climb) {
    const distanceKm = climb.distance / 1000;
    return (distanceKm / estimateSpeedFactor(climb.avgGrade)) * 60;
  }

  function computeVAM(climb) {
    return Math.round(estimateSpeedFactor(climb.avgGrade) * climb.avgGrade * 10);
  }

  function computeFietsIndex(climb) {
    const distanceKm = climb.distance / 1000;
    if (!distanceKm) {
      return "0.0";
    }
    return ((climb.elevation * climb.elevation) / distanceKm / 1000).toFixed(1);
  }

  function haversineMeters(lat1, lon1, lat2, lon2) {
    const dLat = toRadians(lat2 - lat1);
    const dLon = toRadians(lon2 - lon1);
    const a =
      Math.sin(dLat / 2) ** 2 +
      Math.cos(toRadians(lat1)) *
        Math.cos(toRadians(lat2)) *
        Math.sin(dLon / 2) ** 2;

    return 6371000 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
  }

  function toRadians(value) {
    return (value * Math.PI) / 180;
  }

  function fmtKm(distanceMeters, decimals = 1) {
    return (distanceMeters / 1000).toFixed(decimals);
  }

  function fmtPct(value, decimals = 1) {
    return `${Number(value).toFixed(decimals)}%`;
  }

  function fmtDuration(minutes) {
    if (minutes >= 60) {
      return `${Math.floor(minutes / 60)}h ${Math.round(minutes % 60)}min`;
    }
    return `${Math.round(minutes)} min`;
  }

  function normalizedText(element) {
    return (element?.textContent || "").replace(/\s+/g, " ").trim();
  }

  function isVisible(element) {
    if (!(element instanceof Element)) {
      return false;
    }

    const style = window.getComputedStyle(element);
    return (
      style.display !== "none" &&
      style.visibility !== "hidden" &&
      element.getClientRects().length > 0
    );
  }

  function byId(id) {
    return document.getElementById(id);
  }

  function escapeHtml(value) {
    return String(value)
      .replace(/&/g, "&amp;")
      .replace(/</g, "&lt;")
      .replace(/>/g, "&gt;")
      .replace(/"/g, "&quot;")
      .replace(/'/g, "&#39;");
  }

  function installStyles() {
    const css = `
      #mapyclimbs-mobile-root {
        position: fixed;
        inset: 0;
        pointer-events: none;
        z-index: 2147483647;
        font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
      }

      #mapyclimbs-fab {
        position: fixed;
        right: 14px;
        bottom: calc(env(safe-area-inset-bottom, 0px) + 14px);
        pointer-events: auto;
        border: none;
        border-radius: 999px;
        background: linear-gradient(135deg, #14532d, #1e7a36);
        color: #fff;
        box-shadow: 0 16px 30px rgba(0, 0, 0, 0.24);
        display: flex;
        align-items: center;
        gap: 8px;
        padding: 12px 14px;
        font-size: 14px;
        font-weight: 700;
      }

      .mc-fab-count {
        min-width: 22px;
        height: 22px;
        padding: 0 6px;
        border-radius: 999px;
        background: rgba(255, 255, 255, 0.18);
        display: inline-flex;
        align-items: center;
        justify-content: center;
        font-size: 12px;
      }

      #mapyclimbs-overlay {
        position: fixed;
        inset: 0;
        pointer-events: auto;
        background: rgba(5, 12, 19, 0.42);
        backdrop-filter: blur(3px);
      }

      #mapyclimbs-sheet {
        position: fixed;
        left: 0;
        right: 0;
        bottom: 0;
        max-height: min(82vh, 760px);
        pointer-events: auto;
        background: #f7f6f1;
        color: #13202b;
        border-radius: 22px 22px 0 0;
        box-shadow: 0 -12px 40px rgba(0, 0, 0, 0.24);
        padding: 14px 14px calc(env(safe-area-inset-bottom, 0px) + 16px);
        overflow: hidden;
      }

      .mc-sheet-header {
        display: flex;
        align-items: flex-start;
        gap: 12px;
        margin-bottom: 10px;
      }

      .mc-title {
        font-size: 18px;
        line-height: 1.1;
        font-weight: 800;
      }

      .mc-status {
        margin-top: 4px;
        font-size: 12px;
        line-height: 1.35;
        color: #556370;
      }

      .mc-status[data-kind="ok"] {
        color: #146c2e;
      }

      .mc-status[data-kind="warning"] {
        color: #9a5b00;
      }

      .mc-status[data-kind="error"] {
        color: #b3261e;
      }

      .mc-status[data-kind="pending"] {
        color: #0d4a7c;
      }

      .mc-overlay-close {
        position: fixed;
        right: 16px;
        bottom: calc(env(safe-area-inset-bottom, 0px) + 16px);
        pointer-events: auto;
        width: 40px;
        height: 40px;
        border: none;
        background: transparent;
        color: #ffffff;
        font-size: 34px;
        font-weight: 400;
        line-height: 1;
        text-shadow: 0 2px 10px rgba(0, 0, 0, 0.55);
        z-index: 2147483647;
      }

      .mc-toolbar {
        display: grid;
        grid-template-columns: 1fr 1fr;
        gap: 8px;
        margin-bottom: 10px;
      }

      .mc-primary-btn,
      .mc-secondary-btn,
      .mc-pill {
        border: none;
        border-radius: 14px;
        padding: 12px 14px;
        font-size: 14px;
        font-weight: 700;
      }

      .mc-primary-btn {
        grid-column: 1 / -1;
        background: #153f25;
        color: #fff;
      }

      .mc-secondary-btn {
        background: #ffffff;
        color: #183041;
        box-shadow: inset 0 0 0 1px rgba(17, 38, 53, 0.1);
      }

      .mc-secondary-btn:disabled {
        opacity: 0.45;
      }

      .mc-settings {
        display: grid;
        gap: 8px;
        margin-bottom: 12px;
      }

      .mc-setting {
        display: flex;
        align-items: center;
        justify-content: space-between;
        gap: 10px;
        padding: 10px 12px;
        border-radius: 14px;
        background: rgba(255, 255, 255, 0.72);
      }

      .mc-setting > span {
        font-size: 13px;
        font-weight: 700;
      }

      .mc-pill-group {
        display: inline-flex;
        gap: 6px;
      }

      .mc-pill {
        padding: 8px 11px;
        background: #eef2f4;
        color: #4e5d68;
        font-size: 12px;
      }

      .mc-pill.is-active {
        background: #153f25;
        color: #fff;
      }

      .mc-toggle input {
        width: 18px;
        height: 18px;
      }

      .mc-content {
        overflow-y: auto;
        max-height: calc(min(82vh, 760px) - 220px);
        display: grid;
        gap: 10px;
        padding-right: 2px;
      }

      .mc-empty {
        padding: 18px 14px;
        border-radius: 16px;
        background: rgba(255, 255, 255, 0.72);
        color: #4d5a65;
        font-size: 14px;
        line-height: 1.45;
      }

      .mc-card {
        background: rgba(255, 255, 255, 0.88);
        border-radius: 18px;
        padding: 14px;
        box-shadow: inset 0 0 0 1px rgba(18, 35, 48, 0.06);
      }

      .mc-section-title {
        font-size: 12px;
        font-weight: 800;
        letter-spacing: 0.08em;
        text-transform: uppercase;
        color: #69757e;
        margin-bottom: 10px;
      }

      .mc-overview-stats,
      .mc-grid {
        display: grid;
        grid-template-columns: repeat(2, minmax(0, 1fr));
        gap: 10px;
      }

      .mc-stat,
      .mc-metric {
        display: grid;
        gap: 2px;
      }

      .mc-stat-value,
      .mc-metric-value {
        font-size: 15px;
        font-weight: 800;
        color: #10202a;
      }

      .mc-stat-label,
      .mc-metric-label {
        font-size: 10px;
        font-weight: 700;
        text-transform: uppercase;
        letter-spacing: 0.06em;
        color: #6b7781;
      }

      .mc-overview-strip {
        position: relative;
        height: 26px;
        margin-top: 12px;
        border-radius: 10px;
        background: #d7dde1;
        overflow: hidden;
      }

      .mc-strip-segment {
        position: absolute;
        top: 0;
        bottom: 0;
        display: inline-flex;
        align-items: center;
        justify-content: center;
        font-size: 11px;
        font-weight: 800;
        color: rgba(255, 255, 255, 0.95);
        text-shadow: 0 1px 2px rgba(0, 0, 0, 0.25);
      }

      .mc-overview-caption {
        margin-top: 10px;
        color: #53626d;
        font-size: 13px;
        font-weight: 700;
      }

      .mc-climb {
        border-left: 4px solid var(--mc-accent, #4CAF50);
      }

      .mc-climb-head {
        display: flex;
        align-items: center;
        justify-content: space-between;
        gap: 12px;
        margin-bottom: 12px;
      }

      .mc-climb-title {
        font-size: 16px;
        font-weight: 800;
      }

      .mc-climb-badge {
        padding: 6px 10px;
        border-radius: 999px;
        background: rgba(16, 32, 42, 0.06);
        color: #20303d;
        font-size: 12px;
        font-weight: 800;
      }

      .mc-meta {
        margin-top: 12px;
        padding-top: 12px;
        border-top: 1px solid rgba(18, 35, 48, 0.08);
        display: flex;
        flex-wrap: wrap;
        gap: 12px;
      }

      .mc-meta-item {
        display: inline-flex;
        gap: 6px;
        align-items: baseline;
        font-size: 12px;
        color: #56646f;
      }

      .mc-meta-item strong {
        color: #11202a;
      }

      .mc-footer-note {
        margin: 10px 4px 0;
        color: #66747f;
        font-size: 11px;
        line-height: 1.4;
      }
    `;

    try {
      if (typeof GM_addStyle === "function") {
        GM_addStyle(css);
        return;
      }
    } catch {}

    const style = document.createElement("style");
    style.textContent = css;
    (document.head || document.documentElement).appendChild(style);
  }

  function injectBridgeScript() {
    const script = document.createElement("script");
    script.textContent = `(${bridgeMain.toString()})();`;
    (document.head || document.documentElement).appendChild(script);
    script.remove();
  }

  function bridgeMain() {
    const MESSAGE_GPX = "MAPYCLIMBS_GPX_FETCHED";
    const MESSAGE_SUPPRESS = "MAPYCLIMBS_SUPPRESS_DOWNLOAD";
    const MESSAGE_INJECT_MARKERS = "MAPYCLIMBS_INJECT_CLIMB_MARKERS";
    const MESSAGE_CLEAR_MARKERS = "MAPYCLIMBS_CLEAR_CLIMB_MARKERS";

    let suppressDownload = false;
    let mapInstance = null;
    let mapClass = null;
    let markerLayer = null;
    let markerLayerOwner = null;
    let pendingMarkerClimbs = [];
    let markerVersion = 0;
    let injectedMarkerVersion = -1;
    let injectedMarkerMap = null;
    let markerSyncTimer = null;
    let domOverlayTimer = null;

    window.addEventListener("message", (event) => {
      if (event.source !== window || event.origin !== location.origin) {
        return;
      }

      const data = event.data;
      if (!data || typeof data !== "object") {
        return;
      }

      if (data.type === MESSAGE_SUPPRESS) {
        suppressDownload = true;
        return;
      }

      if (data.type === MESSAGE_CLEAR_MARKERS) {
        pendingMarkerClimbs = [];
        markerVersion += 1;
        clearMarkers();
        return;
      }

      if (data.type === MESSAGE_INJECT_MARKERS) {
        if (!Array.isArray(data.climbs)) {
          return;
        }
        queueMarkerInjection(data.climbs);
      }
    });

    document.addEventListener("visibilitychange", () => {
      if (document.visibilityState === "visible") {
        attemptMarkerInjection();
        renderDomMarkerOverlay();
      }
    });

    patchDownloadSuppression();
    patchFetch();
    patchXHR();
    watchSMap();

    function patchDownloadSuppression() {
      const originalClick = HTMLAnchorElement.prototype.click;
      HTMLAnchorElement.prototype.click = function patchedClick() {
        if (suppressDownload && this.download && String(this.href).startsWith("blob:")) {
          suppressDownload = false;
          return;
        }
        return originalClick.call(this);
      };
    }

    function patchFetch() {
      const originalFetch = window.fetch;
      window.fetch = function patchedFetch(...args) {
        const [input] = args;
        if (!isGpxRequest(input)) {
          return originalFetch.apply(this, args);
        }

        const request = originalFetch.apply(this, args);
        request
          .then((response) => {
            const clone = response.clone();
            const contentType = clone.headers.get("content-type") || "";

            if (
              contentType.includes("application/octet-stream") ||
              contentType.includes("application/blob")
            ) {
              clone
                .blob()
                .then((blob) => blob.text())
                .then((text) => postGPX(text, "fetch-blob"))
                .catch(() => {});
            } else {
              clone
                .text()
                .then((text) => postGPX(text, "fetch-text"))
                .catch(() => {});
            }
          })
          .catch(() => {});

        return request;
      };
    }

    function patchXHR() {
      const originalOpen = XMLHttpRequest.prototype.open;
      const originalSend = XMLHttpRequest.prototype.send;

      XMLHttpRequest.prototype.open = function patchedOpen(method, url, ...rest) {
        if (isGpxRequest(url)) {
          this.__mapyClimbsGPX = true;
        }
        return originalOpen.call(this, method, url, ...rest);
      };

      XMLHttpRequest.prototype.send = function patchedSend(body) {
        if (this.__mapyClimbsGPX) {
          this.addEventListener("readystatechange", () => {
            if (this.readyState !== 4 || (this.status !== 200 && this.status !== 0)) {
              return;
            }

            if (this.responseType === "blob" && this.response instanceof Blob) {
              this.response
                .text()
                .then((text) => postGPX(text, "xhr-blob"))
                .catch(() => {});
              return;
            }

            if (this.responseType === "" || this.responseType === "text") {
              postGPX(this.responseText || this.response || "", "xhr-text");
              return;
            }

            if (typeof this.response === "string") {
              postGPX(this.response, "xhr-response");
            }
          });
        }

        return originalSend.call(this, body);
      };
    }

    function isGpxRequest(input) {
      const url =
        typeof input === "string"
          ? input
          : input instanceof URL
            ? input.href
            : input && typeof input === "object"
              ? input.url
              : "";

      return (
        typeof url === "string" &&
        url.includes("tplannerexport") &&
        url.includes("export=gpx")
      );
    }

    function postGPX(gpxContent, source) {
      if (!gpxContent) {
        return;
      }

      window.postMessage(
        {
          type: MESSAGE_GPX,
          gpxContent,
          source,
          timestamp: Date.now(),
        },
        location.origin,
      );
    }

    function isMapLike(object) {
      return (
        !!object &&
        typeof object === "object" &&
        typeof object.addLayer === "function" &&
        typeof object.getCenter === "function"
      );
    }

    function rememberMap(candidate) {
      if (candidate && isMapLike(candidate)) {
        mapInstance = candidate;
        if (pendingMarkerClimbs.length) {
          attemptMarkerInjection();
        }
      }
    }

    function hookMapClass(SMap) {
      if (!SMap || SMap.__mapyClimbsHooked) {
        return;
      }

      SMap.__mapyClimbsHooked = true;
      mapClass = SMap;

      const methods = [
        "$constructor",
        "addLayer",
        "setCenter",
        "getCenter",
        "addDefaultLayer",
        "lock",
        "unlock",
        "redraw",
      ];

      if (!SMap.prototype) {
        return;
      }

      methods.forEach((methodName) => {
        if (typeof SMap.prototype[methodName] !== "function") {
          return;
        }

        const original = SMap.prototype[methodName];
        SMap.prototype[methodName] = function patchedMethod(...args) {
          rememberMap(this);
          const result = original.apply(this, args);
          if (pendingMarkerClimbs.length) {
            window.setTimeout(() => {
              attemptMarkerInjection();
              renderDomMarkerOverlay();
            }, 0);
          }
          return result;
        };
      });
    }

    function watchSMap() {
      if (window.SMap) {
        hookMapClass(window.SMap);
      }

      try {
        let current = window.SMap;
        Object.defineProperty(window, "SMap", {
          configurable: true,
          get() {
            return current;
          },
          set(value) {
            current = value;
            hookMapClass(value);
          },
        });
      } catch {}

      let attempts = 0;
      const timer = window.setInterval(() => {
        if (mapInstance || attempts > 20) {
          window.clearInterval(timer);
          return;
        }

        attempts += 1;
        if (window.SMap && !window.SMap.__mapyClimbsHooked) {
          hookMapClass(window.SMap);
        }
      }, 500);
    }

    function locateMap() {
      if (mapInstance && isMapLike(mapInstance)) {
        return mapInstance;
      }

      for (const key of Object.keys(window)) {
        try {
          const candidate = window[key];
          if (isMapLike(candidate)) {
            rememberMap(candidate);
            return mapInstance;
          }
        } catch {}
      }

      const elements = Array.from(
        document.querySelectorAll("div[id], div[class*='map'], div[class*='Map']"),
      );
      for (const element of elements) {
        for (const property of Object.getOwnPropertyNames(element)) {
          try {
            const candidate = element[property];
            if (isMapLike(candidate)) {
              rememberMap(candidate);
              return mapInstance;
            }
          } catch {}
        }
      }

      return null;
    }

    function clearMarkers() {
      const owner = markerLayerOwner || locateMap();
      const layer =
        markerLayer ||
        (owner && owner.__mapyClimbsMarkerLayer ? owner.__mapyClimbsMarkerLayer : null);

      if (owner && layer) {
        try {
          owner.removeLayer(layer);
        } catch {}
      }

      if (owner && owner.__mapyClimbsMarkerLayer) {
        owner.__mapyClimbsMarkerLayer = null;
      }

      markerLayer = null;
      markerLayerOwner = null;
      injectedMarkerMap = null;
      injectedMarkerVersion = -1;
      clearDomMarkerOverlay();
    }

    function queueMarkerInjection(climbs) {
      pendingMarkerClimbs = climbs.slice();
      markerVersion += 1;
      attemptMarkerInjection();
      ensureMarkerSyncLoop();
      ensureDomOverlayLoop();
      renderDomMarkerOverlay();
    }

    function ensureMarkerSyncLoop() {
      if (markerSyncTimer !== null) {
        return;
      }

      markerSyncTimer = window.setInterval(() => {
        if (!pendingMarkerClimbs.length) {
          return;
        }
        attemptMarkerInjection();
        renderDomMarkerOverlay();
      }, 1500);
    }

    function ensureDomOverlayLoop() {
      if (domOverlayTimer !== null) {
        return;
      }

      domOverlayTimer = window.setInterval(() => {
        if (!pendingMarkerClimbs.length) {
          clearDomMarkerOverlay();
          return;
        }
        renderDomMarkerOverlay();
      }, 700);
    }

    function attemptMarkerInjection() {
      if (!pendingMarkerClimbs.length) {
        clearMarkers();
        return false;
      }

      const map = locateMap();
      const alreadyCurrent =
        !!map &&
        markerLayer &&
        injectedMarkerMap === map &&
        injectedMarkerVersion === markerVersion;

      if (alreadyCurrent) {
        return true;
      }

      return injectMarkers(pendingMarkerClimbs);
    }

    function injectMarkers(climbs) {
      const map = locateMap();
      const SMap = mapClass || window.SMap;

      if (
        !map ||
        !SMap ||
        !SMap.Layer ||
        !SMap.Layer.Marker ||
        !SMap.Coords ||
        !SMap.Marker
      ) {
        return false;
      }

      clearMarkers();

      try {
        const nextLayer = new SMap.Layer.Marker();
        map.addLayer(nextLayer);
        if (typeof nextLayer.enable === "function") {
          nextLayer.enable();
        }
        map.__mapyClimbsMarkerLayer = nextLayer;
        markerLayer = nextLayer;
        markerLayerOwner = map;
        injectedMarkerMap = map;
        injectedMarkerVersion = markerVersion;

        climbs.forEach((climb, index) => {
          if (!climb || !climb.markerCoords) {
            return;
          }

          const color = markerColor(climb.category);
          const iconUrl =
            "data:image/svg+xml;charset=utf-8," +
            encodeURIComponent(markerSvg(color, index + 1));
          const coords = SMap.Coords.fromWGS84(
            climb.markerCoords.lon,
            climb.markerCoords.lat,
          );

          const marker = new SMap.Marker(coords, `mapyclimbs-${index}`, {
            url: iconUrl,
            size: [28, 34],
            anchor: { left: 14, bottom: 0 },
            title: `Climb ${index + 1} · Cat ${climb.category} · ${(climb.distance / 1000).toFixed(1)} km +${Math.round(climb.elevation)} m`,
          });

          nextLayer.addMarker(marker);
        });

        return true;
      } catch {
        markerLayer = null;
        markerLayerOwner = null;
        injectedMarkerMap = null;
        injectedMarkerVersion = -1;
        return false;
      }
    }

    function getMapElement() {
      return (
        document.querySelector("#map") ||
        document.querySelector("[id*='map']") ||
        document.querySelector("[class*='map']") ||
        null
      );
    }

    function clearDomMarkerOverlay() {
      const overlay = document.getElementById("mapyclimbs-dom-marker-overlay");
      if (overlay) {
        overlay.remove();
      }
    }

    function getViewportState() {
      const map = locateMap();

      if (map) {
        try {
          const center = typeof map.getCenter === "function" ? map.getCenter() : null;
          const zoom = typeof map.getZoom === "function" ? map.getZoom() : null;
          const wgs = center && typeof center.toWGS84 === "function" ? center.toWGS84() : null;
          if (
            Array.isArray(wgs) &&
            wgs.length >= 2 &&
            Number.isFinite(wgs[0]) &&
            Number.isFinite(wgs[1]) &&
            Number.isFinite(zoom)
          ) {
            return { lon: wgs[0], lat: wgs[1], zoom };
          }
        } catch {}
      }

      const params = new URLSearchParams(location.search);
      const lon = Number.parseFloat(params.get("x") || "");
      const lat = Number.parseFloat(params.get("y") || "");
      const zoom = Number.parseInt(params.get("z") || "", 10);

      if (Number.isFinite(lat) && Number.isFinite(lon) && Number.isFinite(zoom)) {
        return { lat, lon, zoom };
      }

      return null;
    }

    function projectPoint(lat, lon, centerLat, centerLon, zoom, width, height) {
      const scale = 256 * 2 ** zoom;
      const toX = (value) => ((value + 180) / 360) * scale;
      const toY = (value) => {
        const sine = Math.sin((value * Math.PI) / 180);
        return (0.5 - Math.log((1 + sine) / (1 - sine)) / (4 * Math.PI)) * scale;
      };

      return {
        x: width / 2 + toX(lon) - toX(centerLon),
        y: height / 2 + toY(lat) - toY(centerLat),
      };
    }

    function renderDomMarkerOverlay() {
      if (!pendingMarkerClimbs.length) {
        clearDomMarkerOverlay();
        return;
      }

      const mapElement = getMapElement();
      const viewport = getViewportState();
      if (!mapElement || !viewport) {
        return;
      }

      const rect = mapElement.getBoundingClientRect();
      if (!rect.width || !rect.height) {
        return;
      }

      let overlay = document.getElementById("mapyclimbs-dom-marker-overlay");
      if (!overlay) {
        overlay = document.createElement("div");
        overlay.id = "mapyclimbs-dom-marker-overlay";
        overlay.style.cssText =
          "position:fixed;pointer-events:none;z-index:2147483646;overflow:visible;";
        document.body.appendChild(overlay);
      }

      overlay.style.left = `${rect.left}px`;
      overlay.style.top = `${rect.top}px`;
      overlay.style.width = `${rect.width}px`;
      overlay.style.height = `${rect.height}px`;
      overlay.innerHTML = "";

      pendingMarkerClimbs.forEach((climb, index) => {
        const point = climb.endCoords || climb.markerCoords;
        if (!point) {
          return;
        }

        const projected = projectPoint(
          point.lat,
          point.lon,
          viewport.lat,
          viewport.lon,
          viewport.zoom,
          rect.width,
          rect.height,
        );

        if (
          projected.x < -20 ||
          projected.x > rect.width + 20 ||
          projected.y < -20 ||
          projected.y > rect.height + 20
        ) {
          return;
        }

        const pin = document.createElement("div");
        pin.style.cssText = [
          "position:absolute",
          `left:${Math.round(projected.x - 16)}px`,
          `top:${Math.round(projected.y - 16)}px`,
          "width:32px",
          "height:32px",
          "display:flex",
          "align-items:center",
          "justify-content:center",
          "pointer-events:none",
          "filter:drop-shadow(0 2px 6px rgba(0,0,0,0.35))",
        ].join(";");
        pin.innerHTML = [
          '<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">',
          `<circle cx="16" cy="16" r="14" fill="${markerColor(climb.category)}" stroke="#fff" stroke-width="2"/>`,
          `<text x="16" y="16" dy="0.35em" font-size="12" font-weight="bold" fill="#fff" text-anchor="middle" font-family="system-ui,sans-serif">${index + 1}</text>`,
          "</svg>",
        ].join("");
        overlay.appendChild(pin);
      });
    }

    function markerColor(category) {
      const colors = {
        HC: "#800020",
        "1": "#D32F2F",
        "2": "#F57C00",
        "3": "#FBC02D",
        "4": "#4CAF50",
      };
      return colors[category] || colors["4"];
    }

    function markerSvg(color, label) {
      return [
        '<svg xmlns="http://www.w3.org/2000/svg" width="28" height="34" viewBox="0 0 28 34">',
        `<path d="M14 0C6.27 0 0 6.27 0 14c0 9.6 14 20 14 20s14-10.4 14-20C28 6.27 21.73 0 14 0z" fill="${color}" stroke="#fff" stroke-width="1.5"/>`,
        '<circle cx="14" cy="14" r="9" fill="rgba(0,0,0,0.25)"/>',
        `<text x="14" y="19" font-size="11" font-weight="bold" fill="#fff" text-anchor="middle" font-family="sans-serif">${label}</text>`,
        "</svg>",
      ].join("");
    }
  }
})();