Greasy Fork is available in English.
Detect climbs on Mapy.com / Mapy.cz routes in mobile browsers with a floating analysis panel.
// ==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, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
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("");
}
}
})();