Files
RutasDBUS/RutasDBUS/wwwroot/app.js
Pedro 8a75c583ca cambio foco elemento compartido
poner km en los tramos de bus
line 16 oscurecida
flechas de sentido en las paradas
2026-07-28 08:17:04 +02:00

1593 lines
53 KiB
JavaScript

window.initBrilloMapa = function (dotNetHelper) {
document.addEventListener("keydown", function (e) {
if (e.key && e.key.toLowerCase() === "m") {
dotNetHelper.invokeMethodAsync("AlternarBrillo");
}
});
};
window.initAltInspector = function (dotNetHelper) {
document.addEventListener("keydown", function (e) {
if (e.key === "Z" || e.key === "z") dotNetHelper.invokeMethodAsync("SetInspectorActivo", true);
}, true);
document.addEventListener("keyup", function (e) {
if (e.key === "Z" || e.key === "z") dotNetHelper.invokeMethodAsync("SetInspectorActivo", false);
}, true);
window.addEventListener("blur", function () {
dotNetHelper.invokeMethodAsync("SetInspectorActivo", false);
});
};
// -----------------------------------------------------------------------------
// Leaflet helpers (sin flechas) para RealTimeMap
// -----------------------------------------------------------------------------
(function () {
function hookLeaflet(L) {
if (!L || window.__leafletHooked) return;
// Hook SUPER temprano: constructor del mapa
const originalInitialize = L.Map && L.Map.prototype && L.Map.prototype.initialize;
if (typeof originalInitialize === "function") {
L.Map.prototype.initialize = function (...args) {
const res = originalInitialize.apply(this, args);
window.__rtLeafletMap = this; // guardamos el mapa creado
return res;
};
}
// Hook adicional: factory L.map (por si acaso)
const originalMapFactory = L.map;
if (typeof originalMapFactory === "function") {
L.map = function (...args) {
const map = originalMapFactory.apply(this, args);
window.__rtLeafletMap = map;
return map;
};
}
window.__leafletHooked = true;
console.log("[rt] Leaflet hook OK");
}
// Si Leaflet ya existe:
if (window.L) {
hookLeaflet(window.L);
return;
}
// Si Leaflet se asigna más tarde: definimos setter
let _L = null;
Object.defineProperty(window, "L", {
configurable: true,
enumerable: true,
get() { return _L; },
set(v) {
_L = v;
// restauramos propiedad normal
try {
delete window.L;
window.L = v;
} catch { /* ignore */ }
hookLeaflet(v);
}
});
})();
window.rt = window.rt || {};
window.rt.ensureRouteLayer = function () {
const map = window.__rtLeafletMap;
if (!map || !window.L) {
// ayuda para diagnosticar
// console.log("[rt] no map yet", { hasL: !!window.L, hasMap: !!map });
return false;
}
if (!window.rt._routeLayer) {
window.rt._routeLayer = window.L.layerGroup().addTo(map);
}
if (!window.rt._routeLines) window.rt._routeLines = {};
if (!window.rt._routeCoords) window.rt._routeCoords = {};
return true;
};
window.rt.clearRoutes = function () {
if (!window.rt.ensureRouteLayer()) return;
window.rt._routeLayer.clearLayers();
window.rt._routeLines = {};
window.rt._routeCoords = {};
if (window.rt._routeStopsLayer) {
window.rt._routeStopsLayer.clearLayers();
window.rt._routeStopGroups = {};
}
};
window.rt.drawRoute = function (id, coords, options) {
if (!window.rt.ensureRouteLayer()) return;
if (window.rt._routeLines[id]) {
window.rt._routeLayer.removeLayer(window.rt._routeLines[id]);
delete window.rt._routeLines[id];
}
const arrowId = id + "__arrow";
if (window.rt._routeLines[arrowId]) {
window.rt._routeLayer.removeLayer(window.rt._routeLines[arrowId]);
delete window.rt._routeLines[arrowId];
}
const routeCoords = Array.isArray(coords)
? coords
.map(function (point) {
return [Number(point && point[0]), Number(point && point[1])];
})
.filter(function (point) {
return Number.isFinite(point[0]) && Number.isFinite(point[1]);
})
: [];
if (routeCoords.length > 0) window.rt._routeCoords[id] = routeCoords;
else delete window.rt._routeCoords[id];
const color = (options && options.color) || "#000";
const weight = (options && options.weight) || 4;
const opacity = (options && options.opacity) || 0.9;
const dashArray = options && options.dashArray;
const lineCap = options && options.lineCap;
const arrowEnd = !!(options && options.arrowEnd);
const elevationSegments = Array.isArray(options && options.elevationSegments)
? options.elevationSegments
: [];
const attachRouteClick = function (line) {
line.on("click", function (e) {
try {
if (e && e.originalEvent) {
if (e.originalEvent.preventDefault) e.originalEvent.preventDefault();
if (e.originalEvent.stopPropagation) e.originalEvent.stopPropagation();
if (e.originalEvent.stopImmediatePropagation) e.originalEvent.stopImmediatePropagation();
}
if (window.rt._dotNetHelper) {
window.rt._dotNetHelper.invokeMethodAsync("OnPreviewItinerarioClick", id);
}
} catch (e) { }
});
};
const buildLine = function (lineCoords, lineOpts) {
const line = window.L.polyline(lineCoords, lineOpts);
attachRouteClick(line);
return line;
};
let arrowColor = color;
let hasElevationPaint = false;
if (elevationSegments.length > 0 && coords && coords.length >= 2) {
const group = window.L.layerGroup();
const progressAlongSegment = function (segmentCoords, latlng) {
if (!latlng || !Array.isArray(segmentCoords) || segmentCoords.length < 2) return 0.5;
const start = segmentCoords[0];
const end = segmentCoords[segmentCoords.length - 1];
const startLat = Number(start && start[0]);
const startLon = Number(start && start[1]);
const endLat = Number(end && end[0]);
const endLon = Number(end && end[1]);
if (![startLat, startLon, endLat, endLon, latlng.lat, latlng.lng].every(Number.isFinite))
return 0.5;
const lonScale = Math.cos(((startLat + endLat) / 2) * Math.PI / 180);
const dx = (endLon - startLon) * lonScale;
const dy = endLat - startLat;
const px = (latlng.lng - startLon) * lonScale;
const py = latlng.lat - startLat;
const lengthSquared = dx * dx + dy * dy;
if (lengthSquared <= Number.EPSILON) return 0.5;
const progress = (px * dx + py * dy) / lengthSquared;
return Math.max(0, Math.min(1, progress));
};
const elevationTooltip = function (segment, progress) {
const t = Number.isFinite(progress) ? Math.max(0, Math.min(1, progress)) : 0.5;
const slope = segment && typeof segment.slope === "number" ? segment.slope : null;
const distance = segment && typeof segment.distance === "number" ? segment.distance : null;
const elevationStart = segment && typeof segment.elevationStart === "number" ? segment.elevationStart : null;
const elevationEnd = segment && typeof segment.elevationEnd === "number" ? segment.elevationEnd : null;
const slopeText = slope !== null && Number.isFinite(slope)
? ((slope > 0 ? "+" : "") + slope.toFixed(1) + " %")
: (segment && segment.label ? String(segment.label) : "-");
const distanceText = distance !== null && Number.isFinite(distance)
? (distance * t).toFixed(2) + " m"
: "-";
const estimatedElevation = elevationStart !== null && Number.isFinite(elevationStart) &&
elevationEnd !== null && Number.isFinite(elevationEnd)
? elevationStart + (elevationEnd - elevationStart) * t
: null;
const elevationText = estimatedElevation !== null
? estimatedElevation.toFixed(2) + " m"
: "-";
return [
"Pendiente: " + slopeText,
"Dist. anterior: " + distanceText,
"Altura: " + elevationText
].join("<br>");
};
elevationSegments.forEach(function (segment) {
const segmentCoords = segment && segment.coords;
if (!Array.isArray(segmentCoords) || segmentCoords.length < 2) return;
const segmentColor = (segment && segment.color) || color;
arrowColor = segmentColor;
const segmentOpts = {
color: segmentColor,
weight,
opacity,
lineCap: lineCap || "round",
bubblingMouseEvents: false
};
if (dashArray) segmentOpts.dashArray = dashArray;
const segmentLine = buildLine(segmentCoords, segmentOpts);
const label = elevationTooltip(segment, 0.5);
if (label) {
segmentLine.bindTooltip(label, {
sticky: true,
direction: "top",
opacity: 0.92,
className: "rt-elev-tooltip"
});
const updateTooltipAtCursor = function (e) {
const progress = progressAlongSegment(segmentCoords, e && e.latlng);
segmentLine.setTooltipContent(elevationTooltip(segment, progress));
};
segmentLine.on("mouseover", updateTooltipAtCursor);
segmentLine.on("mousemove", updateTooltipAtCursor);
segmentLine.on("click", function (e) {
if (!window.rt.isMobileLayout || !window.rt.isMobileLayout()) return;
updateTooltipAtCursor(e);
try { segmentLine.openTooltip(e && e.latlng); } catch { }
});
}
segmentLine.addTo(group);
hasElevationPaint = true;
});
if (hasElevationPaint) {
group.addTo(window.rt._routeLayer);
window.rt._routeLines[id] = group;
}
}
if (!hasElevationPaint) {
const lineOpts = { color, weight, opacity };
if (dashArray) lineOpts.dashArray = dashArray;
if (lineCap) lineOpts.lineCap = lineCap;
const line = buildLine(coords, lineOpts);
line.addTo(window.rt._routeLayer);
window.rt._routeLines[id] = line;
}
if (arrowEnd && coords && coords.length >= 2) {
const a = coords[coords.length - 2];
const b = coords[coords.length - 1];
// bearing aprox (en grados)
const lat1 = a[0] * Math.PI / 180, lon1 = a[1] * Math.PI / 180;
const lat2 = b[0] * Math.PI / 180, lon2 = b[1] * Math.PI / 180;
const y = Math.sin(lon2 - lon1) * Math.cos(lat2);
const x = Math.cos(lat1) * Math.sin(lat2) - Math.sin(lat1) * Math.cos(lat2) * Math.cos(lon2 - lon1);
let brng = Math.atan2(y, x) * 180 / Math.PI;
brng = (brng + 360) % 360;
const size = Math.max(14, Math.min(28, weight * 4)); // tamaño ligado al grosor
const arrowClass = dashArray ? "rt-arrow rt-arrow--dashed" : "rt-arrow";
const arrowInnerHtml = dashArray ? '<span class="rt-arrow-dash-head"></span>' : '';
const icon = window.L.divIcon({
className: "rt-arrow-icon",
html: `<div class="${arrowClass}" style="
width:${size}px;height:${size}px;
transform: rotate(${brng}deg);
--arrow-color:${arrowColor};
">${arrowInnerHtml}</div>`,
iconSize: [size, size],
iconAnchor: [size / 2, size / 2]
});
const marker = window.L.marker(b, { icon, interactive: false });
marker.addTo(window.rt._routeLayer);
window.rt._routeLines[arrowId] = marker;
}
};
window.rt.fitRoutes = function (coords) {
const map = window.__rtLeafletMap;
if (!map || !window.L) return false;
const routePoints = [];
if (Array.isArray(coords)) {
coords.forEach(function (point) {
routePoints.push(point);
});
} else {
if (!window.rt._routeCoords) return false;
Object.values(window.rt._routeCoords).forEach(function (routeCoords) {
if (!Array.isArray(routeCoords)) return;
routeCoords.forEach(function (point) {
routePoints.push(point);
});
});
}
const points = routePoints
.filter(function (point) {
return Array.isArray(point)
&& point.length >= 2
&& Number.isFinite(Number(point[0]))
&& Number.isFinite(Number(point[1]));
})
.map(function (point) {
return [Number(point[0]), Number(point[1])];
});
if (points.length === 0) return false;
const visibleBounds = map.getBounds();
const algunPuntoVisible = visibleBounds && points.some(function (point) {
return visibleBounds.contains(window.L.latLng(point[0], point[1]));
});
if (algunPuntoVisible) return false;
const geometryBounds = window.L.latLngBounds(points);
if (!geometryBounds.isValid()) return false;
map.panTo(geometryBounds.getCenter(), {
animate: true,
duration: 0.35
});
return true;
};
window.rt = window.rt || {};
window.rt.setZoomLimits = function (minZoom, maxZoom) {
const map = window.__rtLeafletMap;
if (!map || !window.L) return;
map.options.minZoom = minZoom;
map.options.maxZoom = maxZoom;
map.setMinZoom(minZoom);
map.setMaxZoom(maxZoom);
map.eachLayer(l => {
if (!l) return;
// límites generales
if (typeof l.setMaxZoom === "function") l.setMaxZoom(maxZoom);
if (typeof l.setMinZoom === "function") l.setMinZoom(minZoom);
// ✅ SOLO TileLayer
if (l instanceof window.L.TileLayer) {
l.options.maxNativeZoom = 18; // tiles reales hasta 18
l.options.maxZoom = maxZoom; // permitimos 19
try { l.redraw(); } catch (e) { }
}
});
try { map.invalidateSize(); } catch (e) { }
if (window.rt.addZoomIndicator) window.rt.addZoomIndicator();
};
window.rt = window.rt || {};
// guardar referencia a Blazor para callbacks JS -> .NET
window.rt.setDotNetHelper = function (dotNetHelper) {
window.rt._dotNetHelper = dotNetHelper;
};
// -----------------------------------------------------------------------------
// Elev points layer (puntos de elevación + número)
// -----------------------------------------------------------------------------
window.rt = window.rt || {};
window.rt.ensureElevLayer = function () {
const map = window.__rtLeafletMap;
if (!map || !window.L) return false;
if (!window.rt._elevLayer) {
window.rt._elevLayer = window.L.layerGroup().addTo(map);
}
if (!window.rt._elevGroups) window.rt._elevGroups = {};
return true;
};
window.rt.clearElevPoints = function (id) {
if (!window.rt.ensureElevLayer()) return;
if (id) {
const g = window.rt._elevGroups && window.rt._elevGroups[id];
if (g) {
window.rt._elevLayer.removeLayer(g);
delete window.rt._elevGroups[id];
}
return;
}
// sin id => limpiar todo
window.rt._elevLayer.clearLayers();
window.rt._elevGroups = {};
};
// points: [{ lat, lon, label }]
// options: { radius, opacity, fillOpacity, showLabels, labelEvery }
window.rt.drawElevPoints = function (id, points, options) {
if (!window.rt.ensureElevLayer()) return;
// borrar lo anterior con ese id
if (window.rt._elevGroups[id]) {
window.rt._elevLayer.removeLayer(window.rt._elevGroups[id]);
delete window.rt._elevGroups[id];
}
const radius = (options && options.radius) || 3;
const opacity = (options && options.opacity) || 0.9;
const fillOpacity = (options && options.fillOpacity) || 0.9;
const showLabels = options && options.showLabels !== false; // default true
const every = (options && options.labelEvery) || 1; // default 1
const showSlopeTooltips = !!(options && options.showSlopeTooltips);
const baseClassName = (options && options.className) || "";
const defaultColor = (options && options.color) || "#facc15";
const group = window.L.layerGroup();
if (points && points.length) {
for (let i = 0; i < points.length; i++) {
const p = points[i];
const pointColor = (p && p.color) || defaultColor;
const category = (p && p.category) ? String(p.category) : "";
const categoryClass = category ? `${baseClassName}--${category}` : "";
const className = [baseClassName, categoryClass, p && p.className].filter(Boolean).join(" ");
const m = window.L.circleMarker([p.lat, p.lon], {
radius: radius,
weight: 1,
opacity: opacity,
fillOpacity: fillOpacity,
color: pointColor,
fillColor: pointColor,
className: className,
bubblingMouseEvents: false
});
if (showLabels && every > 0 && (i % every === 0)) {
const txt = (p.label != null ? String(p.label) : String(i));
// -------------------------------------------------------
// ✅ Evitar que el número pise la ruta:
// si el tramo local es "vertical" -> etiqueta a un lado
// si es "horizontal" -> arriba
// -------------------------------------------------------
let dir = "top";
let off = [0, -3];
const prev = (i > 0) ? points[i - 1] : null;
const next = (i < points.length - 1) ? points[i + 1] : null;
const a = prev || p;
const b = next || p;
const dLat = Math.abs((b.lat) - (a.lat));
const dLon = Math.abs((b.lon) - (a.lon));
// tramo más vertical => poner a la derecha
if (dLat > dLon) {
dir = "right"; // puedes cambiar a "left" si prefieres
off = [5, 0]; // separación lateral
}
m.bindTooltip(txt, {
permanent: true,
direction: dir,
offset: off,
opacity: 0.95,
className: "rt-elev-label"
});
}
else if (showSlopeTooltips && p && typeof p.slope === "number" && Number.isFinite(p.slope)) {
const label = (p.slope > 0 ? "+" : "") + p.slope.toFixed(1) + "%";
m.bindTooltip(label, {
sticky: true,
direction: "top",
opacity: 0.92,
className: "rt-elev-tooltip"
});
m.on("click", function (e) {
if (!window.rt.isMobileLayout || !window.rt.isMobileLayout()) return;
try { m.openTooltip(e && e.latlng); } catch { }
});
}
m.addTo(group);
}
}
group.addTo(window.rt._elevLayer);
window.rt._elevGroups[id] = group;
};
window.rt = window.rt || {};
window.rt = window.rt || {};
window.rt.addZoomIndicator = function () {
const map = window.__rtLeafletMap;
if (!map || !window.L) return;
// evitar duplicados
if (window.__rtZoomIndicator) return;
const zoomContainer = map.zoomControl && map.zoomControl._container;
if (!zoomContainer) return;
// botones nativos
const btnIn = zoomContainer.querySelector(".leaflet-control-zoom-in");
const btnOut = zoomContainer.querySelector(".leaflet-control-zoom-out");
if (!btnIn || !btnOut) return;
// indicador
const div = window.L.DomUtil.create("div", "leaflet-control-zoom-indicator-inset");
div.innerText = map.getZoom();
// que no robe clicks
window.L.DomEvent.disableClickPropagation(div);
window.L.DomEvent.disableScrollPropagation(div);
// ✅ insertarlo ENTRE + y -
zoomContainer.insertBefore(div, btnOut);
map.on("zoomend", () => {
div.innerText = map.getZoom();
});
window.__rtZoomIndicator = div;
};
window.rt = window.rt || {};
window.rt.blockLeafletDoubleClick = function () {
if (window.__rtDblClickBlocked) return; // evitar duplicados
window.__rtDblClickBlocked = true;
document.addEventListener("dblclick", function (e) {
// si el doble click ocurre dentro de un mapa Leaflet, lo anulamos
const isOnMap = e.target && e.target.closest && e.target.closest(".leaflet-container");
if (!isOnMap) return;
e.preventDefault();
e.stopPropagation();
// clave: corta el evento incluso para listeners “antes”
if (e.stopImmediatePropagation) e.stopImmediatePropagation();
return false;
}, true); // CAPTURING: lo pillamos antes que Leaflet
};
window.rt.isMobileLayout = function () {
return !!(window.matchMedia && window.matchMedia("(max-width: 780px)").matches);
};
window.rt.initMobileMapControls = function () {
if (window.rt._mobileMapControlsReady) return;
window.rt._mobileMapControlsReady = true;
const holdMilliseconds = 620;
const movementTolerance = 12;
let holdTimer = null;
let press = null;
let suppressClickUntil = 0;
const cancelHold = function () {
if (holdTimer !== null) {
window.clearTimeout(holdTimer);
holdTimer = null;
}
press = null;
};
document.addEventListener("pointerdown", function (event) {
if (!window.rt.isMobileLayout())
return;
if (event.isPrimary === false) {
cancelHold();
return;
}
const mapContainer = event.target && event.target.closest
? event.target.closest(".leaflet-container")
: null;
if (!mapContainer) return;
if (event.target.closest(".leaflet-control, .leaflet-marker-icon, .leaflet-popup"))
return;
cancelHold();
press = {
pointerId: event.pointerId,
clientX: event.clientX,
clientY: event.clientY,
mapContainer
};
holdTimer = window.setTimeout(function () {
const currentPress = press;
holdTimer = null;
if (!currentPress || !currentPress.mapContainer.isConnected)
return;
const map = window.__rtLeafletMap;
if (!map || !window.L || !window.rt._dotNetHelper)
return;
const rect = currentPress.mapContainer.getBoundingClientRect();
const point = window.L.point(
currentPress.clientX - rect.left,
currentPress.clientY - rect.top);
const latLng = map.containerPointToLatLng(point);
suppressClickUntil = Date.now() + 900;
try { navigator.vibrate?.(18); } catch { }
window.rt._dotNetHelper.invokeMethodAsync("OnMobileMapLongPress", latLng.lat, latLng.lng);
press = null;
}, holdMilliseconds);
}, true);
document.addEventListener("pointermove", function (event) {
if (!press || event.pointerId !== press.pointerId) return;
if (Math.abs(event.clientX - press.clientX) > movementTolerance
|| Math.abs(event.clientY - press.clientY) > movementTolerance) {
cancelHold();
}
}, true);
document.addEventListener("pointerup", cancelHold, true);
document.addEventListener("pointercancel", cancelHold, true);
document.addEventListener("click", function (event) {
if (Date.now() >= suppressClickUntil) return;
const isOnMap = event.target && event.target.closest
? event.target.closest(".leaflet-container")
: null;
if (!isOnMap) return;
event.preventDefault();
event.stopPropagation();
if (event.stopImmediatePropagation) event.stopImmediatePropagation();
}, true);
document.addEventListener("contextmenu", function (event) {
if (!window.rt.isMobileLayout()) return;
const isOnMap = event.target && event.target.closest
? event.target.closest(".leaflet-container")
: null;
if (isOnMap) event.preventDefault();
}, true);
};
// -----------------------------------------------------------------------------
// Preview layer (línea completa) - NO toca la ruta actual
// -----------------------------------------------------------------------------
window.rt = window.rt || {};
window.rt.ensurePreviewLayer = function () {
const map = window.__rtLeafletMap;
if (!map || !window.L) return false;
if (!window.rt._previewLayer) {
window.rt._previewLayer = window.L.layerGroup().addTo(map);
}
if (!window.rt._previewLines) window.rt._previewLines = {};
return true;
};
window.rt.clearPreview = function () {
if (!window.rt.ensurePreviewLayer()) return;
window.rt._previewLayer.clearLayers();
window.rt._previewLines = {};
// ✅ también limpiar paradas destacadas
if (window.rt._itStopsLayer) {
window.rt._itStopsLayer.clearLayers();
window.rt._itStopGroups = {};
}
};
window.rt.drawPreviewLine = function (id, coords, options) {
if (!window.rt.ensurePreviewLayer()) return;
const empty = !coords || coords.length === 0;
const arrowId = id + "__arrow";
// toggle: si ya existe, lo quitamos (línea + flecha(s))
if (window.rt._previewLines[id]) {
window.rt._previewLayer.removeLayer(window.rt._previewLines[id]);
delete window.rt._previewLines[id];
if (window.rt._previewLines[arrowId]) {
window.rt._previewLayer.removeLayer(window.rt._previewLines[arrowId]);
delete window.rt._previewLines[arrowId];
}
return;
}
if (empty) return;
const map = window.__rtLeafletMap;
const color = (options && options.color) || "#60a5fa";
const weight = (options && options.weight) || 4;
const opacity = (options && options.opacity) || 0.6;
const dashArray = options && options.dashArray;
const arrowEnd = !!(options && options.arrowEnd);
const arrowClass = dashArray ? "rt-arrow rt-arrow--dashed" : "rt-arrow";
const arrowInnerHtml = dashArray ? '<span class="rt-arrow-dash-head"></span>' : '';
const lineOpts = { color, weight, opacity };
if (dashArray) lineOpts.dashArray = dashArray;
const line = window.L.polyline(coords, lineOpts);
// ✅ Hacer la línea clicable y avisar a Blazor
line.on("click", function (e) {
try {
if (e && e.originalEvent) {
if (e.originalEvent.preventDefault) e.originalEvent.preventDefault();
if (e.originalEvent.stopPropagation) e.originalEvent.stopPropagation();
if (e.originalEvent.stopImmediatePropagation) e.originalEvent.stopImmediatePropagation();
}
if (window.rt._dotNetHelper) {
window.rt._dotNetHelper.invokeMethodAsync("OnPreviewItinerarioClick", id);
}
} catch (e) { }
});
line.addTo(window.rt._previewLayer);
window.rt._previewLines[id] = line;
// ✅ Flecha al FINAL
if (arrowEnd && coords.length >= 2 && map) {
// pane por encima de las paradas
if (!map.getPane("rtArrowPane")) {
const pane = map.createPane("rtArrowPane");
pane.style.zIndex = 760; // > rtStopsPane (750)
pane.style.pointerEvents = "none"; // no roba clicks
}
const size = Math.max(14, Math.min(28, weight * 4));
function addArrow(from, to, storeId) {
const lat1 = from[0] * Math.PI / 180, lon1 = from[1] * Math.PI / 180;
const lat2 = to[0] * Math.PI / 180, lon2 = to[1] * Math.PI / 180;
const y = Math.sin(lon2 - lon1) * Math.cos(lat2);
const x = Math.cos(lat1) * Math.sin(lat2) -
Math.sin(lat1) * Math.cos(lat2) * Math.cos(lon2 - lon1);
let brng = Math.atan2(y, x) * 180 / Math.PI;
brng = (brng + 360) % 360;
const icon = window.L.divIcon({
className: "rt-arrow-icon",
html: `<div class="${arrowClass}" style="
width:${size}px;height:${size}px;
transform: rotate(${brng}deg);
--arrow-color:${color};
">${arrowInnerHtml}</div>`,
iconSize: [size, size],
iconAnchor: [size / 2, size / 2]
});
const marker = window.L.marker(to, {
icon,
pane: "rtArrowPane",
interactive: false,
zIndexOffset: 1000
});
marker.addTo(window.rt._previewLayer);
window.rt._previewLines[storeId] = marker;
}
addArrow(coords[coords.length - 2], coords[coords.length - 1], arrowId);
}
};
window.rt = window.rt || {};
window.rt.getZoom = function () {
const map = window.__rtLeafletMap;
return map ? map.getZoom() : 14;
};
window.rt = window.rt || {};
// Devuelve true si el click está dentro del círculo (en píxeles)
window.rt.isClickInsideCirclePx = function (clickLat, clickLon, targetLat, targetLon, radiusPx) {
const map = window.__rtLeafletMap;
if (!map || !window.L) return false;
const pClick = map.latLngToContainerPoint([clickLat, clickLon]);
const pTarget = map.latLngToContainerPoint([targetLat, targetLon]);
const dx = pClick.x - pTarget.x;
const dy = pClick.y - pTarget.y;
return (dx * dx + dy * dy) <= (radiusPx * radiusPx);
};
// -----------------------------------------------------------------------------
// Itinerario stops layer (paradas destacadas) - toggle por id
// -----------------------------------------------------------------------------
window.rt = window.rt || {};
window.rt.ensureItStopsLayer = function () {
const map = window.__rtLeafletMap;
if (!map || !window.L) return false;
// pane por encima para que queden SOBRE las líneas
if (!map.getPane("rtStopsPane")) {
const pane = map.createPane("rtStopsPane");
pane.style.zIndex = 750;
}
if (!window.rt._itStopsLayer) {
window.rt._itStopsLayer = window.L.layerGroup().addTo(map);
}
if (!window.rt._itStopGroups) window.rt._itStopGroups = {};
return true;
};
window.rt.addStopDirectionMarker = function (group, stop, pane, radius) {
if (!group || !stop || !window.L) return;
const bearing = Number(stop.bearing);
const lat = Number(stop.lat);
const lon = Number(stop.lon);
if (!Number.isFinite(bearing) || !Number.isFinite(lat) || !Number.isFinite(lon)) return;
const size = Math.max(14, Number(radius || 7) * 2);
const icon = window.L.divIcon({
className: "rt-stop-direction-icon",
html: `<span class="rt-stop-direction-arrow" style="--stop-bearing:${bearing}deg"></span>`,
iconSize: [size, size],
iconAnchor: [size / 2, size / 2]
});
window.L.marker([lat, lon], {
pane,
icon,
interactive: false,
keyboard: false,
zIndexOffset: 1000
}).addTo(group);
};
// stops: [{lat, lon, code, bearing}]
window.rt.toggleItStops = function (id, stops, options) {
if (!window.rt.ensureItStopsLayer()) return;
// toggle: si existe => quitar
if (window.rt._itStopGroups[id]) {
window.rt._itStopsLayer.removeLayer(window.rt._itStopGroups[id]);
delete window.rt._itStopGroups[id];
return;
}
const radius = (options && options.radius) || 7;
const color = (options && options.color) || "#0f172a";
const fillColor = (options && options.fillColor) || "#22c55e";
const weight = (options && options.weight) || 2;
const opacity = (options && options.opacity) || 1.0;
const fillOpacity = (options && options.fillOpacity) || 1.0;
const group = window.L.layerGroup();
if (stops && stops.length) {
for (let i = 0; i < stops.length; i++) {
const s = stops[i];
const m = window.L.circleMarker([s.lat, s.lon], {
pane: "rtStopsPane",
radius,
color,
weight,
opacity,
fillColor,
fillOpacity,
interactive: true
});
if (s.code && window.rt._dotNetHelper) {
m.on("click", function (e) {
try {
if (e && e.originalEvent) {
if (e.originalEvent.preventDefault) e.originalEvent.preventDefault();
if (e.originalEvent.stopPropagation) e.originalEvent.stopPropagation();
if (e.originalEvent.stopImmediatePropagation) e.originalEvent.stopImmediatePropagation();
}
window.rt._dotNetHelper.invokeMethodAsync("OnHighlightedStopClick", s.code);
} catch (err) { }
});
}
m.addTo(group);
window.rt.addStopDirectionMarker(group, s, "rtStopsPane", radius);
}
}
group.addTo(window.rt._itStopsLayer);
window.rt._itStopGroups[id] = group;
};
// -----------------------------------------------------------------------------
// Route solution stops layer (paradas destacadas de la solución seleccionada)
// -----------------------------------------------------------------------------
window.rt = window.rt || {};
window.rt.ensureRouteStopsLayer = function () {
const map = window.__rtLeafletMap;
if (!map || !window.L) return false;
if (!map.getPane("rtRouteStopsPane")) {
const pane = map.createPane("rtRouteStopsPane");
pane.style.zIndex = 752;
pane.style.pointerEvents = "none";
}
if (!window.rt._routeStopsLayer) {
window.rt._routeStopsLayer = window.L.layerGroup().addTo(map);
}
if (!window.rt._routeStopGroups) window.rt._routeStopGroups = {};
return true;
};
window.rt.drawRouteStops = function (id, stops, options) {
if (!window.rt.ensureRouteStopsLayer()) return;
if (window.rt._routeStopGroups[id]) {
window.rt._routeStopsLayer.removeLayer(window.rt._routeStopGroups[id]);
delete window.rt._routeStopGroups[id];
}
if (!stops || !stops.length) return;
const radius = (options && options.radius) || 8;
const color = (options && options.color) || "#0f172a";
const fillColor = (options && options.fillColor) || "#22c55e";
const weight = (options && options.weight) || 2;
const opacity = (options && options.opacity) || 1.0;
const fillOpacity = (options && options.fillOpacity) || 1.0;
const group = window.L.layerGroup();
for (let i = 0; i < stops.length; i++) {
const s = stops[i];
const m = window.L.circleMarker([s.lat, s.lon], {
pane: "rtRouteStopsPane",
radius,
color,
weight,
opacity,
fillColor,
fillOpacity,
interactive: true
});
if (s.code && window.rt._dotNetHelper) {
m.on("click", function (e) {
try {
if (e && e.originalEvent) {
if (e.originalEvent.preventDefault) e.originalEvent.preventDefault();
if (e.originalEvent.stopPropagation) e.originalEvent.stopPropagation();
if (e.originalEvent.stopImmediatePropagation) e.originalEvent.stopImmediatePropagation();
}
window.rt._dotNetHelper.invokeMethodAsync("OnHighlightedStopClick", s.code);
} catch (err) { }
});
}
m.addTo(group);
window.rt.addStopDirectionMarker(group, s, "rtRouteStopsPane", radius);
}
group.addTo(window.rt._routeStopsLayer);
window.rt._routeStopGroups[id] = group;
};
window.rt.clearRouteStops = function (id) {
if (!window.rt.ensureRouteStopsLayer()) return;
if (id) {
const group = window.rt._routeStopGroups && window.rt._routeStopGroups[id];
if (group) {
window.rt._routeStopsLayer.removeLayer(group);
delete window.rt._routeStopGroups[id];
}
return;
}
window.rt._routeStopsLayer.clearLayers();
window.rt._routeStopGroups = {};
};
// -----------------------------------------------------------------------------
// Origin / destination draggable markers
// -----------------------------------------------------------------------------
window.rt = window.rt || {};
window.rt.ensureEndpointLayer = function () {
const map = window.__rtLeafletMap;
if (!map || !window.L) return false;
if (!map.getPane("rtEndpointPane")) {
const pane = map.createPane("rtEndpointPane");
pane.style.zIndex = 780;
}
if (!window.rt._endpointLayer) {
window.rt._endpointLayer = window.L.layerGroup().addTo(map);
}
if (!window.rt._endpointMarkers) window.rt._endpointMarkers = {};
return true;
};
window.rt.setEndpointMarker = function (kind, lat, lon, options) {
if (!window.rt.ensureEndpointLayer()) return;
if (window.rt._endpointMarkers[kind]) {
window.rt._endpointLayer.removeLayer(window.rt._endpointMarkers[kind]);
delete window.rt._endpointMarkers[kind];
}
if (typeof lat !== "number" || typeof lon !== "number") return;
const color = (options && options.color) || "#22c55e";
const title = (options && options.title) || "";
const cssKind = kind === "destination" ? "destination" : "origin";
const requestedSize = (options && options.size) || 18;
const size = window.rt.isMobileLayout && window.rt.isMobileLayout()
? Math.max(24, requestedSize)
: requestedSize;
const icon = window.L.divIcon({
className: "rt-endpoint-icon",
html: `<div class="rt-endpoint-marker rt-endpoint-marker--${cssKind}" style="--endpoint-color:${color}; width:${size}px; height:${size}px;"></div>`,
iconSize: [size, size],
iconAnchor: [size / 2, size / 2]
});
const marker = window.L.marker([lat, lon], {
pane: "rtEndpointPane",
draggable: true,
autoPan: true,
icon,
title
});
const stopEvent = function (e) {
if (!e || !e.originalEvent) return;
if (e.originalEvent.preventDefault) e.originalEvent.preventDefault();
if (e.originalEvent.stopPropagation) e.originalEvent.stopPropagation();
if (e.originalEvent.stopImmediatePropagation) e.originalEvent.stopImmediatePropagation();
};
marker.on("click", stopEvent);
marker.on("mousedown", stopEvent);
marker.on("dragend", function (e) {
try {
const pos = e.target.getLatLng();
if (window.rt._dotNetHelper) {
window.rt._dotNetHelper.invokeMethodAsync("OnEndpointDragged", kind, pos.lat, pos.lng);
}
} catch (err) { }
});
marker.addTo(window.rt._endpointLayer);
window.rt._endpointMarkers[kind] = marker;
};
window.rt.clearEndpointMarkers = function (kind) {
if (!window.rt.ensureEndpointLayer()) return;
if (kind) {
const marker = window.rt._endpointMarkers && window.rt._endpointMarkers[kind];
if (marker) {
window.rt._endpointLayer.removeLayer(marker);
delete window.rt._endpointMarkers[kind];
}
return;
}
window.rt._endpointLayer.clearLayers();
window.rt._endpointMarkers = {};
};
// -----------------------------------------------------------------------------
// Punto Z y lugares cercanos del callejero
// -----------------------------------------------------------------------------
window.rt.ensureInspectorLayer = function () {
const map = window.__rtLeafletMap;
if (!map || !window.L) return false;
if (!map.getPane("rtInspectorPane")) {
const pane = map.createPane("rtInspectorPane");
pane.style.zIndex = 790;
}
const inspectorTooltipPane = map.getPane("rtInspectorTooltipPane")
|| map.createPane("rtInspectorTooltipPane");
inspectorTooltipPane.style.zIndex = 10000;
inspectorTooltipPane.style.pointerEvents = "none";
if (!window.rt._inspectorLayer) {
window.rt._inspectorLayer = window.L.layerGroup().addTo(map);
}
return true;
};
window.rt.setInspectorMarkers = function (lat, lon, places) {
if (!window.rt.ensureInspectorLayer()) return;
window.rt._inspectorLayer.clearLayers();
const zLat = Number(lat);
const zLon = Number(lon);
if (!Number.isFinite(zLat) || !Number.isFinite(zLon)) return;
const puntoZ = window.L.circleMarker([zLat, zLon], {
pane: "rtInspectorPane",
radius: 12,
color: "#713f12",
weight: 3,
opacity: 1,
fillColor: "#fde047",
fillOpacity: 0.42,
bubblingMouseEvents: false
});
puntoZ.bindTooltip("Punto Z", {
pane: "rtInspectorTooltipPane",
direction: "top",
offset: [0, -16],
opacity: 0.95,
className: "rt-inspector-tooltip"
});
puntoZ.addTo(window.rt._inspectorLayer);
const lugares = Array.isArray(places) ? places.slice(0, 3) : [];
const coincidencias = {};
lugares.forEach(function (place) {
const placeLat = Number(place && place.latitud);
const placeLon = Number(place && place.longitud);
if (!Number.isFinite(placeLat) || !Number.isFinite(placeLon)) return;
const clave = `${placeLat.toFixed(6)}|${placeLon.toFixed(6)}`;
coincidencias[clave] = (coincidencias[clave] || 0) + 1;
});
const posicionCoincidente = {};
lugares.forEach(function (place, index) {
const placeLat = Number(place && place.latitud);
const placeLon = Number(place && place.longitud);
if (!Number.isFinite(placeLat) || !Number.isFinite(placeLon)) return;
const clave = `${placeLat.toFixed(6)}|${placeLon.toFixed(6)}`;
const totalCoincidentes = coincidencias[clave] || 1;
const posicion = posicionCoincidente[clave] || 0;
posicionCoincidente[clave] = posicion + 1;
let offsetX = 0;
if (totalCoincidentes === 2)
offsetX = posicion === 0 ? -14 : 14;
else if (totalCoincidentes >= 3)
offsetX = [-26, 0, 26][Math.min(posicion, 2)];
window.L.polyline(
[[zLat, zLon], [placeLat, placeLon]],
{
pane: "rtInspectorPane",
color: "#0f766e",
weight: 1.5,
opacity: 0.75,
dashArray: "4 5",
interactive: false
})
.addTo(window.rt._inspectorLayer);
const numero = index + 1;
const size = 24;
const icon = window.L.divIcon({
className: "rt-inspector-place-icon",
html: `<span class="rt-inspector-place-marker">${numero}</span>`,
iconSize: [size, size],
iconAnchor: [(size / 2) - offsetX, size / 2]
});
const texto = place && typeof place.texto === "string"
? place.texto
: `Lugar ${numero}`;
const marker = window.L.marker([placeLat, placeLon], {
pane: "rtInspectorPane",
icon,
title: texto,
keyboard: false,
bubblingMouseEvents: false,
zIndexOffset: 20 + numero
});
const tooltip = document.createElement("span");
tooltip.textContent = texto;
marker.bindTooltip(tooltip, {
pane: "rtInspectorTooltipPane",
direction: "top",
offset: [offsetX, -24],
opacity: 0.95,
className: "rt-inspector-tooltip"
});
marker.addTo(window.rt._inspectorLayer);
});
};
window.rt.clearInspectorMarkers = function () {
if (window.rt._inspectorLayer) {
window.rt._inspectorLayer.clearLayers();
}
};
window.rt = window.rt || {};
window.rt.scrollToHorarioProximo = function () {
const el = document.getElementById("horario-proximo-anchor");
if (!el) return;
el.scrollIntoView({
block: "center",
behavior: "auto"
});
};
window.rt.initDraggablePopups = function () {
if (window.rt._draggablePopupsReady) return;
window.rt._draggablePopupsReady = true;
window.rt._draggablePopupPositions = window.rt._draggablePopupPositions || {};
window.rt._draggablePopupZCounter = Number.isFinite(window.rt._draggablePopupZCounter)
? window.rt._draggablePopupZCounter
: 1200;
const padding = 8;
const edgePeekX = 28;
const edgePeekY = 42;
function dragKey(popup) {
return popup && (popup.dataset.dragKey || popup.id || "");
}
function clamp(value, min, max) {
if (max < min) return min;
return Math.min(Math.max(value, min), max);
}
function parseZ(value) {
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) ? parsed : null;
}
function ensurePopupZIndex(popup) {
if (!popup) return;
const inlineZ = parseZ(popup.style.zIndex);
if (inlineZ != null) {
window.rt._draggablePopupZCounter = Math.max(window.rt._draggablePopupZCounter, inlineZ);
return;
}
const computedZ = parseZ(window.getComputedStyle(popup).zIndex);
if (computedZ != null) {
window.rt._draggablePopupZCounter = Math.max(window.rt._draggablePopupZCounter, computedZ);
}
window.rt._draggablePopupZCounter += 1;
popup.style.zIndex = String(window.rt._draggablePopupZCounter);
}
function bringPopupToFront(popup) {
if (!popup) return;
ensurePopupZIndex(popup);
window.rt._draggablePopupZCounter += 1;
popup.style.zIndex = String(window.rt._draggablePopupZCounter);
}
function clampPosition(left, top, width, height) {
return {
left: clamp(left, edgePeekX - width, window.innerWidth - edgePeekX),
top: clamp(top, edgePeekY - height, window.innerHeight - edgePeekY)
};
}
function constrainFreeHeight(popup, height) {
if (!popup || !popup.classList.contains("route-panel-overlay")) return null;
const maxHeight = Math.max(260, window.innerHeight - (padding * 2));
return Math.min(height || maxHeight, maxHeight);
}
function isHandleRecoverablyHidden(popup) {
if (!popup) return false;
const handle = popup.querySelector(".draggable-popup-handle");
if (!handle) return false;
const rect = handle.getBoundingClientRect();
const visibleTop = Math.max(rect.top, 0);
const visibleBottom = Math.min(rect.bottom, window.innerHeight);
const visibleHeight = Math.max(0, visibleBottom - visibleTop);
return visibleHeight < 18;
}
function setFreePosition(popup, left, top, width, height) {
popup.classList.add("draggable-popup--free");
popup.style.setProperty("--drag-left", `${left}px`);
popup.style.setProperty("--drag-top", `${top}px`);
popup.style.right = "auto";
popup.style.bottom = "auto";
popup.style.width = `${width}px`;
const constrainedHeight = constrainFreeHeight(popup, height);
if (constrainedHeight != null) {
popup.style.height = `${constrainedHeight}px`;
popup.style.maxHeight = `${constrainedHeight}px`;
} else {
popup.style.removeProperty("height");
popup.style.removeProperty("max-height");
}
}
function savePosition(popup) {
const key = dragKey(popup);
if (!key) return;
const rect = popup.getBoundingClientRect();
window.rt._draggablePopupPositions[key] = {
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height
};
}
function applySavedPositions() {
document.querySelectorAll(".draggable-popup").forEach((popup) => {
const key = dragKey(popup);
const saved = key ? window.rt._draggablePopupPositions[key] : null;
if (saved && !popup.classList.contains("draggable-popup--free")) {
const rect = popup.getBoundingClientRect();
const width = saved.width || rect.width;
const height = saved.height || rect.height;
const pos = clampPosition(saved.left, saved.top, width, height);
setFreePosition(popup, pos.left, pos.top, width, height);
}
ensurePopupZIndex(popup);
});
}
document.addEventListener("pointerdown", function (event) {
const popup = event.target && event.target.closest
? event.target.closest(".draggable-popup")
: null;
if (!popup) return;
bringPopupToFront(popup);
if (window.rt.isMobileLayout && window.rt.isMobileLayout()) return;
if (event.button !== 0) return;
if (event.target.closest("button,a,input,textarea,select,label")) return;
const handle = event.target && event.target.closest
? event.target.closest(".draggable-popup-handle")
: null;
const fallbackWholePopup =
!handle &&
popup.classList.contains("draggable-popup--free") &&
isHandleRecoverablyHidden(popup);
if (!handle && !fallbackWholePopup) return;
const dragSurface = handle || popup;
event.preventDefault();
const rect = popup.getBoundingClientRect();
const width = rect.width;
const height = rect.height;
const startX = event.clientX;
const startY = event.clientY;
const startLeft = rect.left;
const startTop = rect.top;
const initial = clampPosition(startLeft, startTop, width, height);
setFreePosition(popup, initial.left, initial.top, width, height);
popup.classList.add("draggable-popup--dragging");
try { dragSurface.setPointerCapture(event.pointerId); } catch { }
function onMove(moveEvent) {
const pos = clampPosition(
startLeft + moveEvent.clientX - startX,
startTop + moveEvent.clientY - startY,
width,
height);
popup.style.setProperty("--drag-left", `${pos.left}px`);
popup.style.setProperty("--drag-top", `${pos.top}px`);
}
function onUp(upEvent) {
document.removeEventListener("pointermove", onMove, true);
document.removeEventListener("pointerup", onUp, true);
document.removeEventListener("pointercancel", onUp, true);
popup.classList.remove("draggable-popup--dragging");
savePosition(popup);
try { dragSurface.releasePointerCapture(upEvent.pointerId); } catch { }
}
document.addEventListener("pointermove", onMove, true);
document.addEventListener("pointerup", onUp, true);
document.addEventListener("pointercancel", onUp, true);
}, true);
document.addEventListener("focusin", function (event) {
const popup = event.target && event.target.closest
? event.target.closest(".draggable-popup")
: null;
if (!popup) return;
bringPopupToFront(popup);
}, true);
window.addEventListener("resize", function () {
document.querySelectorAll(".draggable-popup--free").forEach((popup) => {
const rect = popup.getBoundingClientRect();
const pos = clampPosition(rect.left, rect.top, rect.width, rect.height);
setFreePosition(popup, pos.left, pos.top, rect.width, rect.height);
savePosition(popup);
});
});
const observer = new MutationObserver(applySavedPositions);
observer.observe(document.body, { childList: true, subtree: true });
applySavedPositions();
};
window.rt.saveLocalValue = function (key, value) {
try {
if (!window.localStorage) return;
window.localStorage.setItem(key, value ?? "");
} catch { }
};
window.rt.loadLocalValue = function (key) {
try {
if (!window.localStorage) return null;
return window.localStorage.getItem(key);
} catch {
return null;
}
};
window.rt.clearLocalBrowserData = async function () {
const deleteIndexedDb = function (name) {
return new Promise((resolve) => {
try {
const req = window.indexedDB.deleteDatabase(name);
req.onsuccess = resolve;
req.onerror = resolve;
req.onblocked = resolve;
} catch {
resolve();
}
});
};
try { window.localStorage?.clear(); } catch { }
try { window.sessionStorage?.clear(); } catch { }
try {
if (window.caches?.keys) {
const keys = await window.caches.keys();
await Promise.all(keys.map((key) => window.caches.delete(key)));
}
} catch { }
try {
if (navigator.serviceWorker?.getRegistrations) {
const registrations = await navigator.serviceWorker.getRegistrations();
await Promise.all(registrations.map((registration) => registration.unregister()));
}
} catch { }
try {
if (window.indexedDB?.databases) {
const databases = await window.indexedDB.databases();
await Promise.all((databases || [])
.filter((database) => database && database.name)
.map((database) => deleteIndexedDb(database.name)));
}
} catch { }
};
window.rt.reloadPage = function () {
window.location.reload();
};