MapyClimbs Mobile

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

Du musst eine Erweiterung wie Tampermonkey, Greasemonkey oder Violentmonkey installieren, um dieses Skript zu installieren.

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

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

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.

Sie müssten eine Skript Manager Erweiterung installieren damit sie dieses Skript installieren können

(Ich habe schon ein Skript Manager, Lass mich es installieren!)

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