using System.Collections.Concurrent; using System.Globalization; using System.Text; using System.Text.Json; using System.Text.RegularExpressions; using LeafletForBlazor; using Microsoft.AspNetCore.Components; using Microsoft.JSInterop; using RutasDBUS.Modelos.Geocodificacion; using RutasDBUS.Modelos.Planificacion; using RutasDBUS.Modelos.RedBus; using RutasDBUS.Servicios; using RutasDBUS.Servicios.Caminata; using RutasDBUS.Servicios.Elevacion; using RutasDBUS.Servicios.Horarios; using RutasDBUS.Servicios.Planificacion; using RutasDBUS.Servicios.RedBus; namespace RutasDBUS.Components.Pages; public partial class PlanificadorRutas : ComponentBase { private IReadOnlyList ObtenerLineasPermitidasSeleccionadas() => _lineasPermitidas .Where(linea => linea > 0) .Distinct() .OrderBy(linea => linea) .ToList(); private IReadOnlyList LineasFiltroDisponibles() { var lineas = _infoVariantes .Select(CodigoLineaAsInt) .Concat(RedBusServicio.Lineas.Select(CodigoLineaAsInt)) .Where(linea => linea != int.MaxValue && linea > 0) .Distinct() .OrderBy(linea => linea) .ToList(); if (lineas.Count > 0) return lineas; return _lineasPermitidas .Where(linea => linea > 0) .Distinct() .OrderBy(linea => linea) .ToList(); } private bool LineaFiltroSeleccionada(int linea) => _lineasPermitidas.Contains(linea); private bool TodasLineasFiltroSeleccionadas() { var disponibles = LineasFiltroDisponibles(); return disponibles.Count > 0 && disponibles.All(_lineasPermitidas.Contains); } private string TextoBotonTodasLineasPermitidas() => TodasLineasFiltroSeleccionadas() ? "Ninguna" : "Todas"; private string TextoResumenLineasPermitidas() { var seleccionadas = ObtenerLineasPermitidasSeleccionadas(); var disponibles = LineasFiltroDisponibles(); if (seleccionadas.Count == 0) return "Ninguna linea seleccionada"; if (disponibles.Count > 0 && seleccionadas.Count >= disponibles.Count) return $"Todas las lineas ({disponibles.Count})"; return seleccionadas.Count <= 12 ? string.Join(", ", seleccionadas) : $"{seleccionadas.Count} lineas seleccionadas"; } private async Task ToggleLineaFiltroAsync(int linea, ChangeEventArgs args) { var seleccionada = args.Value is bool valor && valor; if (seleccionada) _lineasPermitidas.Add(linea); else _lineasPermitidas.Remove(linea); _busFilteredCache.Clear(); await PersistirConfiguracionAsync(); } private async Task ToggleTodasLineasPermitidasAsync() { var disponibles = LineasFiltroDisponibles(); if (disponibles.Count > 0 && disponibles.All(_lineasPermitidas.Contains)) { _lineasPermitidas.Clear(); } else { foreach (var linea in disponibles) _lineasPermitidas.Add(linea); } _busFilteredCache.Clear(); await PersistirConfiguracionAsync(); } /// /// Actualiza las alturas de origen y destino con la fuente seleccionada para el calculo. /// private async Task ActualizarAlturasResumenRutaAsync(CancellationToken ct) { if (_coordenadasOrigen is null || _coordenadasDestino is null) { _altitudOrigenResumenRuta = null; _altitudDestinoResumenRuta = null; return; } var origen = (_coordenadasOrigen[0], _coordenadasOrigen[1]); var destino = (_coordenadasDestino[0], _coordenadasDestino[1]); try { var puntos = new List<(double lat, double lon)> { origen, destino }; await AsegurarElevacionesFuenteCalculoAsync(puntos, ct); _altitudOrigenResumenRuta = ObtenerElevacionFuenteCalculo(origen.Item1, origen.Item2); _altitudDestinoResumenRuta = ObtenerElevacionFuenteCalculo(destino.Item1, destino.Item2); } catch { _altitudOrigenResumenRuta = null; _altitudDestinoResumenRuta = null; } } private async Task ActualizarCallejeroResumenRutaAsync(CancellationToken ct) { if (_coordenadasOrigen is null || _coordenadasDestino is null) { _callejeroOrigenResumenRuta = null; _callejeroDestinoResumenRuta = null; return; } try { var origen = await GeocodificadorLocal.BuscarMasCercanoAsync( _coordenadasOrigen[0], _coordenadasOrigen[1], ct); var destino = await GeocodificadorLocal.BuscarMasCercanoAsync( _coordenadasDestino[0], _coordenadasDestino[1], ct); _callejeroOrigenResumenRuta = FormatearLugarCallejeroResumen(origen); _callejeroDestinoResumenRuta = FormatearLugarCallejeroResumen(destino); } catch { _callejeroOrigenResumenRuta = null; _callejeroDestinoResumenRuta = null; } } private async Task ActualizarResumenTecnicoRutaAsync(CancellationToken ct) { await ActualizarAlturasResumenRutaAsync(ct); await ActualizarCallejeroResumenRutaAsync(ct); } private static readonly ConcurrentDictionary _elevIdeeCache = new(StringComparer.OrdinalIgnoreCase); private static readonly ConcurrentDictionary _elevLocal5Cache = new(StringComparer.OrdinalIgnoreCase); private static readonly ConcurrentDictionary _elevLocal2Cache = new(StringComparer.OrdinalIgnoreCase); private static readonly ConcurrentDictionary _elevLocal1Cache = new(StringComparer.OrdinalIgnoreCase); private static readonly SemaphoreSlim _semaforoElevacionIdee = new(1, 1); private static long _ideeReintentarDespuesTicksUtc; private const string FuenteElevacionLocal5 = "local5"; private const string FuenteElevacionLocal2 = "local2"; private const string FuenteElevacionLocal1 = "local1"; private const string FuenteElevacionIdee = "idee"; private const int TiempoMaximoIdeeSegundos = 10; private string _fuenteElevacionCalculo = FuenteElevacionLocal5; // IDEE: en el correo indican que 6 decimales es suficiente private const int ELEV_IDEE_DECIMALES_CACHE = 6; /// /// Gestiona idee clave. /// private static string ClaveElevacionIdee(double lat, double lon) { double rLat = Math.Round(lat, ELEV_IDEE_DECIMALES_CACHE); double rLon = Math.Round(lon, ELEV_IDEE_DECIMALES_CACHE); return $"{rLat.ToString("0.000000", CultureInfo.InvariantCulture)},{rLon.ToString("0.000000", CultureInfo.InvariantCulture)}"; } private static string NormalizarFuenteElevacion(string? fuente) { if (string.Equals(fuente, FuenteElevacionIdee, StringComparison.OrdinalIgnoreCase)) return FuenteElevacionIdee; if (string.Equals(fuente, FuenteElevacionLocal1, StringComparison.OrdinalIgnoreCase)) return FuenteElevacionLocal1; if (string.Equals(fuente, FuenteElevacionLocal2, StringComparison.OrdinalIgnoreCase)) return FuenteElevacionLocal2; if (string.Equals(fuente, FuenteElevacionLocal5, StringComparison.OrdinalIgnoreCase) || string.Equals(fuente, "local", StringComparison.OrdinalIgnoreCase)) { return FuenteElevacionLocal5; } return FuenteElevacionLocal5; } private bool ElevacionLocal5Disponible => ElevacionLocal.EstaDisponible(ModeloElevacionLocal.Local5); private bool ElevacionLocal2Disponible => ElevacionLocal.EstaDisponible(ModeloElevacionLocal.Local2); private bool ElevacionLocal1Disponible => ElevacionLocal.EstaDisponible(ModeloElevacionLocal.Local1); private bool UsarElevacionLocal5ParaCalculo => string.Equals(_fuenteElevacionCalculo, FuenteElevacionLocal5, StringComparison.OrdinalIgnoreCase) && ElevacionLocal5Disponible; private bool UsarElevacionLocal2ParaCalculo => string.Equals(_fuenteElevacionCalculo, FuenteElevacionLocal2, StringComparison.OrdinalIgnoreCase) && ElevacionLocal2Disponible; private bool UsarElevacionLocal1ParaCalculo => string.Equals(_fuenteElevacionCalculo, FuenteElevacionLocal1, StringComparison.OrdinalIgnoreCase) && ElevacionLocal1Disponible; private string EtiquetaFuenteElevacionCalculo => UsarElevacionLocal1ParaCalculo ? "Altura (L1)" : UsarElevacionLocal2ParaCalculo ? "Altura (L2)" : UsarElevacionLocal5ParaCalculo ? "Altura (L5)" : "Altura (G)"; private double? ObtenerElevacionFuenteCalculo(double lat, double lon) { var clave = ClaveElevacionIdee(lat, lon); if (UsarElevacionLocal1ParaCalculo) return _elevLocal1Cache.TryGetValue(clave, out var local1) ? local1 : null; if (UsarElevacionLocal2ParaCalculo) return _elevLocal2Cache.TryGetValue(clave, out var local2) ? local2 : null; if (UsarElevacionLocal5ParaCalculo) return _elevLocal5Cache.TryGetValue(clave, out var local5) ? local5 : null; return _elevIdeeCache.TryGetValue(clave, out var idee) ? idee : null; } private double? ObtenerElevacionFuenteCalculo(MuestraElevacion muestra) => UsarElevacionLocal1ParaCalculo ? muestra.ElevacionLocal1 : UsarElevacionLocal2ParaCalculo ? muestra.ElevacionLocal2 : UsarElevacionLocal5ParaCalculo ? muestra.ElevacionLocal5 : muestra.ElevacionIdee; private void ObtenerElevacionesLocalesPorLote( ModeloElevacionLocal modelo, ConcurrentDictionary cache, IReadOnlyList<(double lat, double lon)> puntos) { if (!ElevacionLocal.EstaDisponible(modelo) || puntos.Count == 0) return; var pendientes = puntos .Where(punto => !cache.ContainsKey(ClaveElevacionIdee(punto.lat, punto.lon))) .Distinct() .ToList(); if (pendientes.Count == 0) return; var elevaciones = ElevacionLocal.ObtenerElevaciones(modelo, pendientes); for (var i = 0; i < pendientes.Count && i < elevaciones.Count; i++) { if (elevaciones[i] is { } elevacion && double.IsFinite(elevacion)) cache[ClaveElevacionIdee(pendientes[i].lat, pendientes[i].lon)] = elevacion; } } private void ObtenerElevacionesLocal5PorLote(IReadOnlyList<(double lat, double lon)> puntos) => ObtenerElevacionesLocalesPorLote( ModeloElevacionLocal.Local5, _elevLocal5Cache, puntos); private void ObtenerElevacionesLocal2PorLote(IReadOnlyList<(double lat, double lon)> puntos) => ObtenerElevacionesLocalesPorLote( ModeloElevacionLocal.Local2, _elevLocal2Cache, puntos); private void ObtenerElevacionesLocal1PorLote(IReadOnlyList<(double lat, double lon)> puntos) => ObtenerElevacionesLocalesPorLote( ModeloElevacionLocal.Local1, _elevLocal1Cache, puntos); private void ObtenerElevacionesLocalesComparativasPorLote( IReadOnlyList<(double lat, double lon)> puntos) { ObtenerElevacionesLocal5PorLote(puntos); ObtenerElevacionesLocal2PorLote(puntos); ObtenerElevacionesLocal1PorLote(puntos); } private async Task AsegurarElevacionesFuenteCalculoAsync( IReadOnlyList<(double lat, double lon)> puntos, CancellationToken ct) { if (UsarElevacionLocal1ParaCalculo) { ObtenerElevacionesLocal1PorLote(puntos); return; } if (UsarElevacionLocal2ParaCalculo) { ObtenerElevacionesLocal2PorLote(puntos); return; } if (UsarElevacionLocal5ParaCalculo) { ObtenerElevacionesLocal5PorLote(puntos); return; } var faltan = puntos .Where(punto => !_elevIdeeCache.ContainsKey(ClaveElevacionIdee(punto.lat, punto.lon))) .Distinct() .ToList(); for (var i = 0; i < faltan.Count; i += ELEV_BATCH_MAX_PUNTOS) { ct.ThrowIfCancellationRequested(); await ObtenerElevacionesIdeePorLoteAsync( faltan.Skip(i).Take(ELEV_BATCH_MAX_PUNTOS).ToList(), ct); } } private sealed class RespuestaElevacionIdee { public string? id { get; set; } public List? values { get; set; } } /// /// Recupera elevations idee el lote. /// private async Task ObtenerElevacionesIdeePorLoteAsync(List<(double lat, double lon)> points, CancellationToken ct) { if (points.Count == 0 || DateTime.UtcNow.Ticks < Interlocked.Read(ref _ideeReintentarDespuesTicksUtc)) return; await _semaforoElevacionIdee.WaitAsync(ct); try { if (DateTime.UtcNow.Ticks < Interlocked.Read(ref _ideeReintentarDespuesTicksUtc)) return; points = points .Where(punto => !_elevIdeeCache.ContainsKey(ClaveElevacionIdee(punto.lat, punto.lon))) .Distinct() .ToList(); if (points.Count == 0) return; // Construir FeatureCollection GeoJSON, OJO: coordinates = [lon, lat] var features = new List(points.Count); foreach (var (lat, lon) in points) { ct.ThrowIfCancellationRequested(); double rLat = Math.Round(lat, ELEV_IDEE_DECIMALES_CACHE); double rLon = Math.Round(lon, ELEV_IDEE_DECIMALES_CACHE); features.Add(new { type = "Feature", geometry = new { type = "Point", coordinates = new[] { rLon, rLat } } }); } var geomObj = new { type = "FeatureCollection", features }; // En la API, "geom" va como STRING con el JSON dentro string geomJson = JsonSerializer.Serialize(geomObj); var body = new { inputs = new { crs = 4326, formato = "geojson", geom = geomJson, outputFormat = "array", withCoord = false } }; const string url = "https://api-processes.idee.es/processes/getElevation/execution"; try { using var limite = CancellationTokenSource.CreateLinkedTokenSource(ct); limite.CancelAfter(TimeSpan.FromSeconds(TiempoMaximoIdeeSegundos)); using var req = new HttpRequestMessage(HttpMethod.Post, url); req.Headers.Accept.ParseAdd("application/json"); req.Content = new StringContent(JsonSerializer.Serialize(body), System.Text.Encoding.UTF8, "application/json"); using var resp = await Http.SendAsync(req, limite.Token); if (!resp.IsSuccessStatusCode) { PosponerConsultasIdee(TimeSpan.FromSeconds(15)); return; } var json = await resp.Content.ReadAsStringAsync(limite.Token); var data = JsonSerializer.Deserialize(json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); if (data?.values is null || data.values.Count == 0) return; // values viene en el mismo orden que mandas los puntos int n = Math.Min(points.Count, data.values.Count); for (int i = 0; i < n; i++) { var (lat, lon) = points[i]; var k = ClaveElevacionIdee(lat, lon); _elevIdeeCache[k] = data.values[i]; } Interlocked.Exchange(ref _ideeReintentarDespuesTicksUtc, 0); } catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; } catch (OperationCanceledException) { PosponerConsultasIdee(TimeSpan.FromSeconds(45)); } catch { PosponerConsultasIdee(TimeSpan.FromSeconds(20)); // silencioso: elevación es informativa } } finally { _semaforoElevacionIdee.Release(); } } private static void PosponerConsultasIdee(TimeSpan demora) => Interlocked.Exchange( ref _ideeReintentarDespuesTicksUtc, DateTime.UtcNow.Add(demora).Ticks); // Ajustes (rendimiento) /* private const int ELEV_MAX_PUNTOS_POR_TRAMO = 24; */ private int _elevMaxPuntosPorTramo = 999; // configurable // muestreo por tramo private const int ELEV_BATCH_MAX_PUNTOS = 90; // por request (prudente) private static List<(double lat, double lon)> SampleLine(double[,] line, int maxPoints) { int n = line.GetLength(0); if (n <= 0) return new(); if (maxPoints < 2) maxPoints = 2; if (n <= maxPoints) { var all = new List<(double, double)>(n); for (int i = 0; i < n; i++) all.Add((line[i, 0], line[i, 1])); return all; } // muestreo equiespaciado incluyendo extremos var res = new List<(double, double)>(maxPoints); for (int i = 0; i < maxPoints; i++) { double t = (maxPoints == 1) ? 0 : (double)i / (maxPoints - 1); int idx = (int)Math.Round(t * (n - 1)); if (idx < 0) idx = 0; if (idx >= n) idx = n - 1; var p = (line[idx, 0], line[idx, 1]); if (res.Count == 0 || res[^1].Item1 != p.Item1 || res[^1].Item2 != p.Item2) res.Add(p); } return res; } /// /// Construye elev samples. /// private async Task> ConstruirMuestrasElevacionAsync(double[,] line, CancellationToken ct) { var sampled = SampleLine(line, _elevMaxPuntosPorTramo); await AsegurarElevacionesFuenteCalculoAsync(sampled, ct); return ConstruirMuestrasElevacionDesdeCache(line); } private List ConstruirMuestrasElevacionDesdeCache(double[,] line) { var sampled = SampleLine(line, _elevMaxPuntosPorTramo); var res = new List(sampled.Count); for (int i = 0; i < sampled.Count; i++) { var (lat, lon) = sampled[i]; var elevIdee = _elevIdeeCache.TryGetValue(ClaveElevacionIdee(lat, lon), out var e2) ? e2 : (double?)null; var elevLocal5 = _elevLocal5Cache.TryGetValue(ClaveElevacionIdee(lat, lon), out var local5) ? local5 : (double?)null; var elevLocal2 = _elevLocal2Cache.TryGetValue(ClaveElevacionIdee(lat, lon), out var local2) ? local2 : (double?)null; var elevLocal1 = _elevLocal1Cache.TryGetValue(ClaveElevacionIdee(lat, lon), out var local1) ? local1 : (double?)null; res.Add(new MuestraElevacion { Indice = i, Latitud = lat, Longitud = lon, ElevacionIdee = elevIdee, ElevacionLocal5 = elevLocal5, ElevacionLocal2 = elevLocal2, ElevacionLocal1 = elevLocal1 }); } return res; } private async Task CalcularEsfuerzoExtraPendienteAlternativasAsync( IReadOnlyList alternativas, CancellationToken ct) { foreach (var alternativa in alternativas) alternativa.EsfuerzoExtraPendienteMetros = 0; if (!_mostrarElevacionesRuta || alternativas.Count == 0) return; var lineasPorAlternativa = alternativas .Select(alternativa => new { Alternativa = alternativa, Lineas = (alternativa.EsSoloAPie ? alternativa.LineaSoloAPie is { } lineaSolo ? new[] { lineaSolo } : Enumerable.Empty() : alternativa.LineasPieCalculoElevacion) .Where(linea => linea.GetLength(0) >= 2) .ToList() }) .ToList(); var puntosPendientes = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var item in lineasPorAlternativa) { foreach (var linea in item.Lineas) { foreach (var (lat, lon) in SampleLine(linea, _elevMaxPuntosPorTramo)) { ct.ThrowIfCancellationRequested(); var clave = ClaveElevacionIdee(lat, lon); puntosPendientes.TryAdd( clave, (Math.Round(lat, ELEV_IDEE_DECIMALES_CACHE), Math.Round(lon, ELEV_IDEE_DECIMALES_CACHE))); } } } var puntos = puntosPendientes.Values.ToList(); await AsegurarElevacionesFuenteCalculoAsync(puntos, ct); foreach (var item in lineasPorAlternativa) { ct.ThrowIfCancellationRequested(); var esfuerzoExtra = 0.0; foreach (var linea in item.Lineas) { ct.ThrowIfCancellationRequested(); var muestras = ConstruirMuestrasElevacionDesdeCache(linea); esfuerzoExtra += CalcularResumenEsfuerzoElevacion(muestras).EsfuerzoExtraMetros; } item.Alternativa.EsfuerzoExtraPendienteMetros = double.IsFinite(esfuerzoExtra) ? Math.Max(0, esfuerzoExtra) : 0; } } /// /// Gestiona tramo bus es basura. /// private bool PrimerTramoBusEsBasura( RutaBusAlternativa alternativa, double[] coordsOrigen, double[] coordsDestino) { if (alternativa?.Tramos is null || alternativa.Tramos.Count < 2) return false; // si solo hay un bus, no aplicamos esta regla var tramos = alternativa.Tramos; var primer = tramos[0]; var segundo = tramos[1]; // Parada real donde subes al primer bus string codIniPrimer = primer.Variante.CodigosParadas[primer.IndiceInicio]; // Parada real donde bajas del primer bus string codFinPrimer = primer.Variante.CodigosParadas[primer.IndiceFin]; // Parada real donde subes al segundo bus string codIniSegundo = segundo.Variante.CodigosParadas[segundo.IndiceInicio]; if (!_paradaByCode.TryGetValue(codIniPrimer, out var pIniPrimer)) return false; if (!_paradaByCode.TryGetValue(codFinPrimer, out var pFinPrimer)) return false; if (!_paradaByCode.TryGetValue(codIniSegundo, out var pIniSegundo)) return false; // Distancia del primer tramo bus double distPrimerBus = 0; { double distTmp; var lineas = RedBusServicio.ConstruirLineasBus(new List { primer }, out distTmp); distPrimerBus = Math.Max(0, distTmp); } // Nº de paradas del primer tramo int paradasPrimerBus = ContarParadasRecorridas(primer); // Caminata del transbordo tras ese primer tramo double distWalkTransfer = CalcularDistanciaMetros( pFinPrimer.Latitud, pFinPrimer.Longitud, pIniSegundo.Latitud, pIniSegundo.Longitud); // Distancia desde origen hasta la parada de subida double distPieInicio = CalcularDistanciaMetros( coordsOrigen[0], coordsOrigen[1], pIniPrimer.Latitud, pIniPrimer.Longitud); // Distancia desde donde te deja ese primer bus hasta destino double distDesdeBajada1ADestino = CalcularDistanciaMetros( pFinPrimer.Latitud, pFinPrimer.Longitud, coordsDestino[0], coordsDestino[1]); // Distancia desde donde subes al primer bus hasta destino double distDesdeSubida1ADestino = CalcularDistanciaMetros( pIniPrimer.Latitud, pIniPrimer.Longitud, coordsDestino[0], coordsDestino[1]); double avanceUtil = distDesdeSubida1ADestino - distDesdeBajada1ADestino; // ===================================================== // Reglas SOLO para detectar "primer bus absurdo" // ===================================================== // 1) Bus muy corto + transbordo andando largo if (distPrimerBus <= 350 && distWalkTransfer >= 250) return true; // 2) Apenas 1 parada + luego caminata importante if (paradasPrimerBus <= 1 && distWalkTransfer >= 180) return true; // 3) El primer bus aporta poco avance real hacia destino if (avanceUtil <= 180 && distWalkTransfer >= 180) return true; // 4) Subes andando poco, haces mini-bus, y luego una caminata grande if (distPieInicio <= 180 && distPrimerBus <= 500 && distWalkTransfer >= 300) return true; // 5) El transbordo andando es casi tan grande como el propio tramo bus if (distPrimerBus > 0 && distWalkTransfer >= distPrimerBus * 0.9 && distWalkTransfer >= 200) return true; return false; } string FmtDist(double? d) { if (d is null) return "-"; if (d.Value < 50) return $"{d.Value:0.0} m"; return $"{Math.Round(d.Value):0} m"; } string FmtPend(double? pend, double? distPrev) { if (pend is null || distPrev is null) return "-"; if (distPrev.Value < 15) return "-"; return $"{pend.Value:+0.0;-0.0;0.0} %"; } /// /// Gestiona geometria completa variante. /// private double[,]? ConstruirGeometriaCompletaVariante(InfoVariante v) { if (v?.CodigosParadas is null || v.CodigosParadas.Count < 2) return null; // tramo completo: desde la primera parada a la última var tramo = new TramoBus { Variante = v, IndiceInicio = 0, IndiceFin = v.CodigosParadas.Count - 1 }; double dist; var lineas = RedBusServicio.ConstruirLineasBus(new List { tramo }, out dist); // ConstruirLineasBus devuelve lista de lineas por tramo (aquí solo 1) if (lineas is null || lineas.Count == 0) return null; return lineas[0]; } // ========================================================= // Rutado BUS filtrado (BFS + filtroVariante) // ========================================================= /// /// Busca ruta bus con filtro. /// private List? BuscarRutaBusConFiltro( string codigoParadaOrigen, string codigoParadaDestino, Func filtroVariante, out List codigosParadasTransbordo, int? maxTransbordos = null) { codigosParadasTransbordo = new(); if (!_indiceParadas.TryGetValue(codigoParadaOrigen, out var aparicionesOrigen)) return null; if (!_indiceParadas.TryGetValue(codigoParadaDestino, out var aparicionesDestino)) return null; // Estado = (variante, índice) // Coste = nº de transbordos (cambios de variante) desde que subes al primer bus var dq = new ColaDoble<(InfoVariante Variante, int IndiceParada)>(); var dist = new Dictionary<(InfoVariante Variante, int IndiceParada), int>(); var padre = new Dictionary<(InfoVariante Variante, int IndiceParada), (InfoVariante Variante, int IndiceParada)?>(); // Inicial: subir al primer bus NO cuenta como transbordo => 0 foreach (var ap in aparicionesOrigen) { if (!filtroVariante(ap.Variante)) continue; var nodo = (ap.Variante, ap.IndiceParada); if (dist.ContainsKey(nodo)) continue; dist[nodo] = 0; padre[nodo] = null; dq.PushFront(nodo); } (InfoVariante Variante, int IndiceParada)? objetivo = null; while (dq.Count > 0) { var actual = dq.PopFront(); var (variante, indice) = actual; int transUsados = dist[actual]; if (maxTransbordos is not null && transUsados > maxTransbordos.Value) continue; string codigoParadaActual = variante.CodigosParadas[indice]; if (codigoParadaActual == codigoParadaDestino) { objetivo = actual; break; // ✅ 0-1 BFS => primer objetivo es mínimo en transbordos } // ----------------------- // 1) AVANZAR (coste 0) // ----------------------- if (indice + 1 < variante.CodigosParadas.Count) { var sig = (variante, indice + 1); int nuevo = transUsados; // coste 0 if (!dist.TryGetValue(sig, out var best) || nuevo < best) { dist[sig] = nuevo; padre[sig] = actual; dq.PushFront(sig); // coste 0 => delante } } // ----------------------- // 2) TRANSBORDO MISMA PARADA (coste 1 si cambia variante) // ----------------------- if (_indiceParadas.TryGetValue(codigoParadaActual, out var aparicionesParada)) { foreach (var ap in aparicionesParada) { if (!filtroVariante(ap.Variante)) continue; // si es exactamente el mismo estado, nada if (ReferenceEquals(ap.Variante, variante) && ap.IndiceParada == indice) continue; int nuevo = transUsados + 1; // cambio de bus = +1 if (maxTransbordos is not null && nuevo > maxTransbordos.Value) continue; var sig = (ap.Variante, ap.IndiceParada); if (!dist.TryGetValue(sig, out var best) || nuevo < best) { dist[sig] = nuevo; padre[sig] = actual; dq.PushBack(sig); // coste 1 => detrás } } } // ----------------------- // 3) TRANSBORDO ANDANDO A PARADAS CERCANAS (coste 1 si cambia variante) // ----------------------- if (_paradasCercanas.TryGetValue(codigoParadaActual, out var vecinas)) { foreach (var codVecino in vecinas) { if (!_indiceParadas.TryGetValue(codVecino, out var aparicionesVecino)) continue; foreach (var ap in aparicionesVecino) { if (!filtroVariante(ap.Variante)) continue; int nuevo = transUsados + 1; if (maxTransbordos is not null && nuevo > maxTransbordos.Value) continue; var sig = (ap.Variante, ap.IndiceParada); if (!dist.TryGetValue(sig, out var best) || nuevo < best) { dist[sig] = nuevo; padre[sig] = actual; dq.PushBack(sig); } } } } } if (objetivo is null) return null; // reconstrucción camino var nodosCamino = new List<(InfoVariante Variante, int IndiceParada)>(); var nodoActual = objetivo.Value; while (true) { nodosCamino.Add(nodoActual); if (!padre.TryGetValue(nodoActual, out var anterior) || anterior is null) break; nodoActual = anterior.Value; } nodosCamino.Reverse(); int numeroTransbordos; var tramos = ConstruirTramosYTransbordosDesdeCamino( nodosCamino, codigoParadaOrigen, codigoParadaDestino, out codigosParadasTransbordo, out numeroTransbordos); // ✅ númeroTransbordos aquí será coherente con el 0-1 BFS (mínimo transbordos) return tramos; } /// /// Gestiona tramos y transbordos desde camino. /// private List ConstruirTramosYTransbordosDesdeCamino( List<(InfoVariante Variante, int IndiceParada)> nodosCamino, string codigoParadaOrigen, string codigoParadaDestino, out List codigosParadasTransbordo, out int numeroTransbordos) { codigosParadasTransbordo = new List(); for (int i = 1; i < nodosCamino.Count; i++) { var previo = nodosCamino[i - 1]; var ahora = nodosCamino[i]; if (!ReferenceEquals(previo.Variante, ahora.Variante)) { string codigoParada = ahora.Variante.CodigosParadas[ahora.IndiceParada]; if (!codigosParadasTransbordo.Contains(codigoParada) && codigoParada != codigoParadaOrigen && codigoParada != codigoParadaDestino) { codigosParadasTransbordo.Add(codigoParada); } } } var tramos = new List(); if (nodosCamino.Count <= 1) { numeroTransbordos = 0; return tramos; } var varianteActual = nodosCamino[0].Variante; int indiceInicio = nodosCamino[0].IndiceParada; int ultimoIndice = nodosCamino[0].IndiceParada; for (int i = 1; i < nodosCamino.Count; i++) { var (variante, indice) = nodosCamino[i]; if (ReferenceEquals(variante, varianteActual) && indice == ultimoIndice + 1) { ultimoIndice = indice; continue; } if (ultimoIndice > indiceInicio) { tramos.Add(new TramoBus { Variante = varianteActual, IndiceInicio = indiceInicio, IndiceFin = ultimoIndice }); } varianteActual = variante; indiceInicio = indice; ultimoIndice = indice; } if (ultimoIndice > indiceInicio) { tramos.Add(new TramoBus { Variante = varianteActual, IndiceInicio = indiceInicio, IndiceFin = ultimoIndice }); } codigosParadasTransbordo = new List(); for (var i = 1; i < tramos.Count; i++) { if (!UtilidadesTransitoDbus.EsTransbordoReal(tramos[i - 1], tramos[i])) continue; var codigoParada = tramos[i].CodigoParadaInicio; if (!codigosParadasTransbordo.Contains(codigoParada, StringComparer.OrdinalIgnoreCase) && codigoParada != codigoParadaOrigen && codigoParada != codigoParadaDestino) { codigosParadasTransbordo.Add(codigoParada); } } numeroTransbordos = UtilidadesTransitoDbus.ContarTransbordosReales(tramos); return tramos; } /// /// Normaliza sentido codigo. /// private static string NormalizarSentidoCodigo(string? codigo) { if (string.IsNullOrWhiteSpace(codigo)) return "?"; var s = codigo.Trim(); // aceptar códigos ya normalizados if (s.Equals("V", StringComparison.OrdinalIgnoreCase)) return "V"; if (s.Equals("I", StringComparison.OrdinalIgnoreCase)) return "I"; // aceptar textos if (s.Equals("Vuelta", StringComparison.OrdinalIgnoreCase)) return "V"; if (s.Equals("Ida", StringComparison.OrdinalIgnoreCase)) return "I"; // fallback razonable var c = char.ToUpperInvariant(s[0]); if (c == 'V') return "V"; if (c == 'I') return "I"; return "?"; } /// /// Gestiona linea itinerario sentido. /// private static string CodigoLineaItinerarioSentido(TramoBus tramo) { var v = tramo.Variante; var linea = v.Linea.Codigo?.ToString() ?? "?"; var itin = v.CodigoItinerario; // ✅ sentido real del JSON var sentido = NormalizarSentidoCodigo(v.Sentido?.Codigo); return $"{linea}.{itin}-{sentido}"; } /// /// Gestiona ruta bus. /// private static string FirmaRutaBus(RutaBusAlternativa alt) { return string.Join(">", alt.Tramos.Select(t => $"{CodigoLineaItinerarioSentido(t)}:{t.IndiceInicio}->{t.IndiceFin}") ); } /// /// Calcula transbordos reales. /// private static int CalcularTransbordosReales(RutaBusAlternativa alternativa) { return UtilidadesTransitoDbus.ContarTransbordosReales(alternativa?.Tramos); } // ========================================================= // Acciones UI // ========================================================= /// /// Resuelve parada desde texto. /// private Parada? ResolverParadaDesdeTexto(string texto) { if (string.IsNullOrWhiteSpace(texto)) return null; texto = texto.Trim(); int idx = texto.LastIndexOf('('); if (idx >= 0 && texto.EndsWith(")")) { var codigoRaw = texto.Substring(idx + 1, texto.Length - idx - 2).Trim(); var codNorm = NormalizarCodigoParada(codigoRaw); var paradaPorCodigo = RedBusServicio.Paradas.FirstOrDefault(p => NormalizarCodigoParada(p.Codigo).Equals(codNorm, StringComparison.OrdinalIgnoreCase)); if (paradaPorCodigo is not null) return paradaPorCodigo; } var codNorm2 = NormalizarCodigoParada(texto); var porCodigo = RedBusServicio.Paradas.FirstOrDefault(p => NormalizarCodigoParada(p.Codigo).Equals(codNorm2, StringComparison.OrdinalIgnoreCase)); if (porCodigo is not null) return porCodigo; var porNombre = RedBusServicio.Paradas .FirstOrDefault(p => p.Nombre.Contains(texto, StringComparison.OrdinalIgnoreCase) || p.NombreCorto.Contains(texto, StringComparison.OrdinalIgnoreCase) || p.Abreviatura.Contains(texto, StringComparison.OrdinalIgnoreCase)); return porNombre; } /// /// Cambia alternativa absoluta. /// private async Task CambiarAlternativaAbsoluta(int indice) { await CerrarTodasLasPreviewsAsync(); if (indice < 0 || indice >= _opcionesRuta.Count) return; _indiceAlternativaSeleccionada = indice; _proveedorSoloAPieRecalculo = _opcionesRuta[indice].EsSoloAPie ? _opcionesRuta[indice].ProveedorRuta : null; await CerrarPopupsAsync(); ResetEstadoRuta(limpiarOpciones: false, limpiarMensaje: false); _mensajeRuta = "Representando ruta seleccionada..."; StateHasChanged(); await Task.Yield(); await PintarOpcionRutaCompleta(_opcionesRuta[indice]); await RegistrarEscenarioActualEnHistorialLocalAsync(); } /// /// Cambia el criterio prioritario de rutas sugeridas. /// private async Task CambiarCriterioPrioritarioRutasAsync(string criterio) { var criterioNormalizado = NormalizarCriterioRuta(criterio); if (string.Equals(_criterioPrioritarioRuta, criterioNormalizado, StringComparison.OrdinalIgnoreCase)) { return; } _criterioPrioritarioRuta = criterioNormalizado; SincronizarPreferenciaDesdeCriterioRuta(); await PersistirConfiguracionAsync(); if (_mapa is not null && _coordenadasOrigen is not null && _coordenadasDestino is not null) { _omitirRegistroHistorialEnSiguienteCalculo = true; await CalcularRutaCompletaAsync(); return; } if (_opcionesRuta.Count == 0) { StateHasChanged(); return; } OrdenarOpcionesRutaSegunCriterio(); var indicePreferido = BuscarIndicePreferidoPorCriterio(criterioNormalizado); if (indicePreferido < 0 || indicePreferido >= _opcionesRuta.Count) { StateHasChanged(); return; } await CerrarTodasLasPreviewsAsync(); _indiceAlternativaSeleccionada = indicePreferido; await CerrarPopupsAsync(); ResetEstadoRuta(limpiarOpciones: false, limpiarMensaje: false); _mensajeRuta = "Representando ruta seleccionada..."; StateHasChanged(); await Task.Yield(); await PintarOpcionRutaCompleta(_opcionesRuta[indicePreferido]); StateHasChanged(); } private int BuscarIndicePreferidoPorCriterio(string criterio) { if (string.Equals(criterio, CriterioRutaEquilibrado, StringComparison.OrdinalIgnoreCase)) return BuscarIndiceEquilibrado(); if (string.Equals(criterio, CriterioRutaAndar, StringComparison.OrdinalIgnoreCase)) { return BuscarPrimerIndiceOrdenadoPor( opcion => CalcularCosteAndarEquilibradoSegundos(opcion), opcion => DuracionOrdenacion(opcion)); } return BuscarPrimerIndiceOrdenadoPor( opcion => DuracionOrdenacion(opcion), opcion => TiempoPieOrdenacion(opcion)); } private int BuscarIndiceEquilibrado() { var candidatas = _opcionesRuta .Select((opcion, indice) => new { opcion, indice }) .ToList(); if (candidatas.Count == 0) return _opcionesRuta.Count > 0 ? 0 : -1; return candidatas .OrderBy(x => CalcularScoreEquilibradoFactor(x.opcion)) .ThenBy(x => x.opcion.NumeroTransbordosReales) .ThenBy(x => x.opcion.TiempoEsperaTotalSegundos) .ThenBy(x => x.opcion.PuntuacionCoste) .Select(x => x.indice) .FirstOrDefault(); } private void OrdenarOpcionesRutaSegunCriterio() { if (_opcionesRuta.Count <= 1) return; IEnumerable ordenadas = _opcionesRuta; if (CriterioPrioritarioAndarActivo) { ordenadas = ordenadas .OrderBy(CalcularCosteAndarEquilibradoSegundos) .ThenBy(DuracionOrdenacion) .ThenBy(opcion => opcion.PuntuacionCoste); } else if (CriterioPrioritarioTiempoActivo) { ordenadas = ordenadas .OrderBy(DuracionOrdenacion) .ThenBy(TiempoPieOrdenacion) .ThenBy(opcion => opcion.PuntuacionCoste); } else if (CriterioPrioritarioEquilibradoActivo) { ordenadas = ordenadas .OrderBy(CalcularScoreEquilibradoFactor) .ThenBy(opcion => opcion.NumeroTransbordosReales) .ThenBy(opcion => opcion.TiempoEsperaTotalSegundos) .ThenBy(opcion => opcion.PuntuacionCoste); } var listaOrdenada = ordenadas.ToList(); _opcionesRuta.Clear(); _opcionesRuta.AddRange(listaOrdenada); FiltrarAlternativasBusRedundantes(); RenumerarEtiquetasGenericasOpcionesRuta(); } /// /// Conserva la mejor alternativa de cada direccion de acceso a una misma linea /// y descarta rutas en bus que no ahorran ninguna distancia andando. /// private void FiltrarAlternativasBusRedundantes() { if (_opcionesRuta.Count <= 1) return; var distanciaSoloPie = _opcionesRuta .Where(opcion => opcion.EsSoloAPie && double.IsFinite(opcion.DistanciaSoloAPie) && opcion.DistanciaSoloAPie > 0) .Select(opcion => opcion.DistanciaSoloAPie) .DefaultIfEmpty(double.PositiveInfinity) .Min(); var conservadas = new List(_opcionesRuta.Count); foreach (var candidata in _opcionesRuta) { if (candidata.EsSoloAPie) { conservadas.Add(candidata); continue; } if (double.IsFinite(distanciaSoloPie) && double.IsFinite(candidata.DistanciaPieTotalAprox) && candidata.DistanciaPieTotalAprox >= distanciaSoloPie) { continue; } var repiteDireccionAcceso = conservadas.Any(referencia => !referencia.EsSoloAPie && EsMismaLineaInicial(referencia, candidata) && CaminanEnLaMismaDireccionDesdeOrigen(referencia, candidata)); if (!repiteDireccionAcceso) conservadas.Add(candidata); } if (conservadas.Count == _opcionesRuta.Count) return; _opcionesRuta.Clear(); _opcionesRuta.AddRange(conservadas); } private static bool EsMismaLineaInicial( AlternativaRuta referencia, AlternativaRuta candidata) { var tramoReferencia = referencia.AlternativaBus?.Tramos.FirstOrDefault(); var tramoCandidata = candidata.AlternativaBus?.Tramos.FirstOrDefault(); if (tramoReferencia is null || tramoCandidata is null) return false; return string.Equals( UtilidadesTransitoDbus.LineaCodigoStr(tramoReferencia.Variante), UtilidadesTransitoDbus.LineaCodigoStr(tramoCandidata.Variante), StringComparison.OrdinalIgnoreCase) && tramoReferencia.Variante.CodigoItinerario == tramoCandidata.Variante.CodigoItinerario; } private bool CaminanEnLaMismaDireccionDesdeOrigen( AlternativaRuta referencia, AlternativaRuta candidata) { if (_coordenadasOrigen is null || _coordenadasOrigen.Length < 2 || referencia.OrigenParada is null || candidata.OrigenParada is null) { return false; } var latitudOrigen = _coordenadasOrigen[0]; var longitudOrigen = _coordenadasOrigen[1]; if (!double.IsFinite(latitudOrigen) || !double.IsFinite(longitudOrigen)) return false; var metrosPorGradoLatitud = 111_320d; var metrosPorGradoLongitud = metrosPorGradoLatitud * Math.Cos(latitudOrigen * Math.PI / 180d); var referenciaX = (referencia.OrigenParada.Longitud - longitudOrigen) * metrosPorGradoLongitud; var referenciaY = (referencia.OrigenParada.Latitud - latitudOrigen) * metrosPorGradoLatitud; var candidataX = (candidata.OrigenParada.Longitud - longitudOrigen) * metrosPorGradoLongitud; var candidataY = (candidata.OrigenParada.Latitud - latitudOrigen) * metrosPorGradoLatitud; var moduloReferencia = Math.Sqrt( referenciaX * referenciaX + referenciaY * referenciaY); var moduloCandidata = Math.Sqrt( candidataX * candidataX + candidataY * candidataY); if (!double.IsFinite(moduloReferencia) || !double.IsFinite(moduloCandidata) || moduloReferencia < 1d || moduloCandidata < 1d) { return false; } var cosenoDireccion = (referenciaX * candidataX + referenciaY * candidataY) / (moduloReferencia * moduloCandidata); return double.IsFinite(cosenoDireccion) && cosenoDireccion >= 0d; } private void RenumerarEtiquetasGenericasOpcionesRuta() { for (var i = 0; i < _opcionesRuta.Count; i++) { var etiqueta = _opcionesRuta[i].Etiqueta; if (!EsEtiquetaOpcionGenerica(etiqueta)) continue; _opcionesRuta[i].Etiqueta = $"Opcion {i + 1}"; } } private static bool EsEtiquetaOpcionGenerica(string? etiqueta) { if (string.IsNullOrWhiteSpace(etiqueta)) return false; return Regex.IsMatch( etiqueta.Trim(), @"^Opci(?:o|ó|ó)n\s+\d+$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); } private int BuscarPrimerIndiceOrdenadoPor( Func criterioPrincipal, Func criterioSecundario) { return _opcionesRuta .Select((opcion, indice) => new { opcion, indice }) .OrderBy(x => criterioPrincipal(x.opcion)) .ThenBy(x => criterioSecundario(x.opcion)) .ThenBy(x => x.opcion.PuntuacionCoste) .Select(x => x.indice) .FirstOrDefault(-1); } private double DuracionOrdenacion(AlternativaRuta alternativa) => double.IsFinite(alternativa.DuracionTotalAproxSegundos) ? alternativa.DuracionTotalAproxSegundos : double.PositiveInfinity; private double TiempoPieOrdenacion(AlternativaRuta alternativa) { if (alternativa.EsSoloAPie) return DuracionOrdenacion(alternativa); var tiempoTransbordosPie = EstimarDuracionPieSegundos(alternativa.DistanciaPieTransbordosAprox); return Math.Max(0, alternativa.TiempoPieInicio) + Math.Max(0, tiempoTransbordosPie) + Math.Max(0, alternativa.TiempoPieFin); } /// /// Intercambia origen destino. /// private async Task IntercambiarOrigenDestino() { (_coordenadasOrigen, _coordenadasDestino) = (_coordenadasDestino, _coordenadasOrigen); (_textoBusquedaOrigen, _textoBusquedaDestino) = (_textoBusquedaDestino, _textoBusquedaOrigen); (_ubicacionOrigenSeleccionada, _ubicacionDestinoSeleccionada) = (_ubicacionDestinoSeleccionada, _ubicacionOrigenSeleccionada); _sugerenciasBusquedaOrigen.Clear(); _sugerenciasBusquedaDestino.Clear(); _campoBusquedaActivo = null; await ClearRoutesJsAsync(); await CerrarTodasLasPreviewsAsync(); await SincronizarMarcadoresOrigenDestinoJsAsync(); if (_mapa is not null) { try { await _mapa.Geometric.DisplayPolylinesFromArray.deleteConnectors(); } catch { } } if (_coordenadasOrigen is not null && _coordenadasDestino is not null) await CalcularRutaCompletaAsync(conservarSeleccionSoloAPie: true); StateHasChanged(); } /// /// Busca ruta desde buscador. /// private async Task BuscarRutaDesdeBuscador() { if (string.IsNullOrWhiteSpace(_textoBusquedaOrigen) || string.IsNullOrWhiteSpace(_textoBusquedaDestino)) { return; } var po = await ResolverUbicacionBusquedaAsync(CampoBusquedaMapa.Origen); var pd = await ResolverUbicacionBusquedaAsync(CampoBusquedaMapa.Destino); if (po is null || pd is null) { _mensajeRuta = "No he podido interpretar el origen o el destino. Prueba a seleccionar una sugerencia de la lista."; _hayRutaValida = false; StateHasChanged(); return; } _coordenadasOrigen = new[] { po.Latitud, po.Longitud }; _coordenadasDestino = new[] { pd.Latitud, pd.Longitud }; _textoBusquedaOrigen = po.TextoEntrada; _textoBusquedaDestino = pd.TextoEntrada; _ubicacionOrigenSeleccionada = po; _ubicacionDestinoSeleccionada = pd; _sugerenciasBusquedaOrigen.Clear(); _sugerenciasBusquedaDestino.Clear(); _campoBusquedaActivo = null; if (_mapa is not null) { await ClearRoutesJsAsync(); try { await _mapa.Geometric.DisplayPolylinesFromArray.deleteConnectors(); } catch { } await _mapa.Geometric.DisplayPointsFromArray.deleteAllPoints(); await SincronizarMarcadoresOrigenDestinoJsAsync(); if (_mostrarParadas) await DibujarParadasAsync(); } await CerrarTodasLasPreviewsAsync(); await CerrarPopupsAsync(); await CalcularRutaCompletaAsync(conservarSeleccionSoloAPie: true); } /// /// Muestra inspector. /// private async Task MostrarInspectorAsync(double lat, double lon) { _inspectorPuntoAnterior = _popupInspectorVisible ? new PuntoInspectorAnterior( _inspectorLat, _inspectorLon, _inspectorElevIdee, _inspectorElevLocal5, _inspectorElevLocal2, _inspectorElevLocal1, ObtenerDireccionPuntoInspectorActual(), DistanciaAndandoMetros: null, CalculandoDatosAdicionales: true) : null; var versionInspector = ++_versionInspector; _inspectorLat = lat; _inspectorLon = lon; _inspectorElevIdee = null; _inspectorElevLocal5 = null; _inspectorElevLocal2 = null; _inspectorElevLocal1 = null; _inspectorLugaresCercanos = Array.Empty(); _inspectorSuperficieTexto = null; _inspectorSuperficieCargando = true; _popupInspectorVisible = true; ObtenerElevacionesLocalesComparativasPorLote(new[] { (lat, lon) }); var claveLocal = ClaveElevacionIdee(lat, lon); _inspectorElevLocal5 = _elevLocal5Cache.TryGetValue(claveLocal, out var elevLocal5) ? elevLocal5 : null; _inspectorElevLocal2 = _elevLocal2Cache.TryGetValue(claveLocal, out var elevLocal2) ? elevLocal2 : null; _inspectorElevLocal1 = _elevLocal1Cache.TryGetValue(claveLocal, out var elevLocal1) ? elevLocal1 : null; await ActualizarMarcadoresInspectorJsAsync(); StateHasChanged(); _ = CompletarSuperficiePuntoInspectorAsync(lat, lon, versionInspector); if (_inspectorPuntoAnterior is { } puntoAnterior) _ = CompletarDatosPuntoInspectorAnteriorAsync(puntoAnterior, versionInspector); var tareaLugares = GeocodificadorLocal.BuscarCercanosAsync( lat, lon, radioMetros: 20, maxResultados: 3, cancellationToken: CancellationToken.None); double? elevacionIdee = null; try { // ------------------------- // IDEE (cache primero) // ------------------------- var kIdee = ClaveElevacionIdee(lat, lon); if (_elevIdeeCache.TryGetValue(kIdee, out var cachedIdee)) { elevacionIdee = cachedIdee; } else { await ObtenerElevacionesIdeePorLoteAsync( new List<(double lat, double lon)> { (lat, lon) }, CancellationToken.None); elevacionIdee = _elevIdeeCache.TryGetValue(ClaveElevacionIdee(lat, lon), out var elevIdee) ? elevIdee : (double?)null; } } catch { } try { var lugares = await tareaLugares; if (versionInspector != _versionInspector) return; _inspectorLugaresCercanos = lugares; } catch { if (versionInspector != _versionInspector) return; _inspectorLugaresCercanos = Array.Empty(); } if (versionInspector != _versionInspector) return; _inspectorElevIdee = elevacionIdee; await ActualizarMarcadoresInspectorJsAsync(); StateHasChanged(); } private async Task ActualizarMarcadoresInspectorJsAsync() { var lugares = _inspectorLugaresCercanos .Select(cercano => new { latitud = cercano.Lugar.Latitud, longitud = cercano.Lugar.Longitud, texto = FormatearLugarCallejeroResumen(cercano) }) .ToList(); try { await JS.InvokeVoidAsync( "rt.setInspectorMarkers", _inspectorLat, _inspectorLon, lugares); } catch { } } private async Task CerrarInspectorAsync() { _versionInspector++; _popupInspectorVisible = false; _inspectorPuntoAnterior = null; _inspectorLugaresCercanos = Array.Empty(); _inspectorSuperficieCargando = false; _inspectorSuperficieTexto = null; try { await JS.InvokeVoidAsync("rt.clearInspectorMarkers"); } catch { } } /// /// Gestiona hacer clic en mapa. /// private async Task AlHacerClickEnMapa(RealTimeMap.ClicksMapArgs e) { var lat = e.location.latitude; var lon = e.location.longitude; if (_modoDibujoExclusionCaminata) { await OnExclusionPolygonMapClick(lat, lon); return; } // Inspector (Z) sigue siendo click simple inmediato if (_inspectorActivo) { await MostrarInspectorAsync(lat, lon); return; } // ¿Este click cae SOBRE una parada? var paradaHit = await DetectarParadaHitAsync(lat, lon); if (paradaHit is null && !_mostrarParadas) paradaHit = await DetectarParadaDestacadaHitAsync(lat, lon); // ===================================================== // 2º click (si había uno pendiente) // ===================================================== if (_primerClickPendiente is (double lat1, double lon1)) { _ctsDobleClick?.Cancel(); _ctsDobleClick?.Dispose(); _ctsDobleClick = null; _primerClickPendiente = null; _paradaPrimerClickPendiente = null; // ✅ si era parada, anulamos popup // si los dos clicks son cerca => doble click => PONER PUNTO SIEMPRE if (Cerca(lat1, lon1, lat, lon, DBLCLICK_MAX_METROS)) { // Si el segundo click cae en parada, ponemos el punto EXACTO en la parada if (paradaHit is not null) await EjecutarSeleccionOrigenDestinoAsync(paradaHit.Latitud, paradaHit.Longitud); else await EjecutarSeleccionOrigenDestinoAsync(lat, lon); } return; } // ===================================================== // 1º click (armamos la ventana de doble click) // ===================================================== _primerClickPendiente = (lat, lon); _paradaPrimerClickPendiente = paradaHit; _ctsDobleClick = new CancellationTokenSource(); var ct = _ctsDobleClick.Token; _ = Task.Run(async () => { try { await Task.Delay(DBLCLICK_MS, ct); if (ct.IsCancellationRequested) return; // Se acabó la ventana: NO hubo doble click var parada = _paradaPrimerClickPendiente; _primerClickPendiente = null; _paradaPrimerClickPendiente = null; // Si era parada => click simple => mostrar popup if (parada is not null) { await InvokeAsync(async () => { await MostrarPopupParadaAsync(parada); }); } // Si NO era parada: click simple no hace nada (como querías) } catch { } finally { if (!ct.IsCancellationRequested) { // por si acaso _primerClickPendiente = null; _paradaPrimerClickPendiente = null; } } }); } /// /// Selecciona origen o destino mediante pulsacion larga en dispositivos tactiles. /// [JSInvokable] public async Task OnMobileMapLongPress(double lat, double lon) { if (_modoDibujoExclusionCaminata) return; await InvokeAsync(async () => { if (_inspectorActivo) return; _ctsDobleClick?.Cancel(); _ctsDobleClick?.Dispose(); _ctsDobleClick = null; _primerClickPendiente = null; _paradaPrimerClickPendiente = null; var paradaHit = await DetectarParadaHitAsync(lat, lon); if (paradaHit is null && !_mostrarParadas) paradaHit = await DetectarParadaDestacadaHitAsync(lat, lon); if (paradaHit is not null) await EjecutarSeleccionOrigenDestinoAsync(paradaHit.Latitud, paradaHit.Longitud); else await EjecutarSeleccionOrigenDestinoAsync(lat, lon); }); } /// /// Gestiona seleccion origen destino. /// private async Task EjecutarSeleccionOrigenDestinoAsync(double lat, double lon) { if (_coordenadasOrigen is null) { _coordenadasOrigen = new[] { lat, lon }; await SincronizarMarcadoresOrigenDestinoJsAsync(); _mensajeRuta = string.Empty; _hayRutaValida = false; StateHasChanged(); return; } if (_coordenadasDestino is null) { _coordenadasDestino = new[] { lat, lon }; await SincronizarMarcadoresOrigenDestinoJsAsync(); await CalcularRutaCompletaAsync(conservarSeleccionSoloAPie: true); return; } _coordenadasDestino = new[] { lat, lon }; if (_mapa is not null) { try { await _mapa.Geometric.DisplayPolylinesFromArray.deleteConnectors(); } catch { } await ClearRoutesJsAsync(); await SincronizarMarcadoresOrigenDestinoJsAsync(); } await CalcularRutaCompletaAsync(conservarSeleccionSoloAPie: true); } /// /// Reacciona al arrastre de origen o destino desde el mapa. /// [JSInvokable] public async Task OnEndpointDragged(string kind, double lat, double lon) { await InvokeAsync(async () => { if (string.Equals(kind, "origin", StringComparison.OrdinalIgnoreCase)) { _coordenadasOrigen = new[] { lat, lon }; _ubicacionOrigenSeleccionada = null; _sugerenciasBusquedaOrigen.Clear(); } else if (string.Equals(kind, "destination", StringComparison.OrdinalIgnoreCase)) { _coordenadasDestino = new[] { lat, lon }; _ubicacionDestinoSeleccionada = null; _sugerenciasBusquedaDestino.Clear(); } else { return; } await SincronizarMarcadoresOrigenDestinoJsAsync(); await CerrarTodasLasPreviewsAsync(); await CerrarPopupsAsync(); if (_mapa is not null) { try { await _mapa.Geometric.DisplayPolylinesFromArray.deleteConnectors(); } catch { } await ClearRoutesJsAsync(); } if (_coordenadasOrigen is not null && _coordenadasDestino is not null) { await CalcularRutaCompletaAsync(conservarSeleccionSoloAPie: true); } else { ResetEstadoRuta(limpiarOpciones: true); _mensajeRuta = string.Empty; StateHasChanged(); } }); } // ========================================================= // Cálculo completo (optimizado + estricto real) // ========================================================= /// /// Calcula ruta completa. /// private string? _proveedorSoloAPieRecalculo; private string? ObtenerProveedorSoloAPieParaRecalculo(bool conservarSeleccion) { if (!conservarSeleccion) _proveedorSoloAPieRecalculo = null; else if (_indiceAlternativaSeleccionada >= 0 && _indiceAlternativaSeleccionada < _opcionesRuta.Count) { var seleccionada = _opcionesRuta[_indiceAlternativaSeleccionada]; _proveedorSoloAPieRecalculo = seleccionada.EsSoloAPie ? seleccionada.ProveedorRuta : null; } // Durante un calculo las opciones se vacian; otro cambio de punto conserva la preferencia. return _proveedorSoloAPieRecalculo; } private async Task CalcularRutaCompletaAsync(bool conservarSeleccionSoloAPie = false) { if (_mapa is null || _coordenadasOrigen is null || _coordenadasDestino is null) return; var proveedorSoloAPiePreferido = ObtenerProveedorSoloAPieParaRecalculo(conservarSeleccionSoloAPie); _mostrarElevacionesRuta = _mostrarElevacionesRutaConfiguracion; var confirmarDerivacionHistorial = !_aplicandoRegistroHistorial && PrepararDerivacionHistorialParaRecalculo(); var cronometroCalculo = System.Diagnostics.Stopwatch.StartNew(); _panelRutasMovilContraido = _mostrarBuscadorMapa; await CerrarTodasLasPreviewsAsync(); _ctsCalculo?.Cancel(); _ctsCalculo = new CancellationTokenSource(); var ct = _ctsCalculo.Token; var omitirRegistroHistorialEsteCalculo = _omitirRegistroHistorialEnSiguienteCalculo; _omitirRegistroHistorialEnSiguienteCalculo = false; int versionActual = Interlocked.Increment(ref _versionCalculo); IniciarNuevaVersionPintadoRuta(); ResetEstadoRuta(limpiarOpciones: true); _mensajeRuta = "Calculando ruta…"; StateHasChanged(); await ClearRoutesJsAsync(); try { await _mapa.Geometric.DisplayPolylinesFromArray.deleteConnectors(); } catch { } try { ct.ThrowIfCancellationRequested(); if (versionActual != _versionCalculo) throw new OperationCanceledException(); var salidaBase = AhoraLocal(); _ultimaHoraReferenciaRuta = salidaBase; var parametros = new ParametrosPlanificadorRuta { VelocidadPieKmH = _velocidadPieKmH, FactorPenalizacionDistanciaPie = _factorPenalizacionDistanciaPie, ParametroPenalizacionDistanciaPieMetros = _parametroPenalizacionDistanciaPieMetros, PesoTransbordoRankingMinutos = _pesoTransbordoRankingMinutos, FactorEquilibradoAndarBus = _factorEquilibradoAndarBus, UsarValhallaParaCaminatas = _walkProvider == ProveedorCaminata.Valhalla, PoligonosExclusionCaminata = ObtenerPoligonosExclusionCaminata(), VelocidadBusKmH = _velocidadBusKmH, MinutosPorTransbordo = _minutosTransbordo, MargenTransbordoSegundos = Math.Clamp(_margenTransbordoSegundos, 0, 1800), SegundosPorParadaBus = _segundosPorParadaBus, RadioParadasCandidatasMetros = _radioCandidatasMetros, MaxParadasCandidatasPorLado = _maxCandidatasPorLado, UsarOsrmParaRanking = _usarOsrmParaRanking, ActivarRangoLineas = _activarRangoLineas, LineaMin = _lineaMin, LineaMax = _lineaMax, LineasPermitidas = ObtenerLineasPermitidasSeleccionadas().ToList(), ActivarRangoItinerarios = _activarRangoItinerarios, ItinerarioMin = _itinerarioMin, ItinerarioMax = _itinerarioMax, FiltroSentidoItinerario = _filtroSentidoItinerario, ActivarRangoTransbordos = _activarRangoTransbordos, MinTransbordosPermitidos = _minTransbordosPermitidos, MaxTransbordosPermitidos = _maxTransbordosPermitidos, MaxParesEvaluados = _activarRangoItinerarios ? 280 : 140, MaxAlternativasBusPorPar = (_activarRangoTransbordos && _minTransbordosPermitidos > 0) ? (_activarRangoItinerarios ? 8 : 6) : (_activarRangoItinerarios ? 4 : 3), UsarHorariosReales = _usarHorariosTeoricos, FechaSalida = DateOnly.FromDateTime(salidaBase), HoraSalida = TimeOnly.FromDateTime(salidaBase), DescartarAlternativasSinHorario = false, MaxEsperaInicialMinutos = 90, MaxEsperaTransbordoMinutos = 60, MaxEsperaTotalMinutos = 120, PreferenciaEtiquetaRuta = _preferenciaEtiquetaRuta }; var resultado = await PlanificadorRutasServicio.CalcularAsync( _coordenadasOrigen, _coordenadasDestino, parametros, ct); ct.ThrowIfCancellationRequested(); if (versionActual != _versionCalculo) throw new OperationCanceledException(); var alternativas = resultado.Alternativas; var filtradas = alternativas .Where(a => a.EsSoloAPie || a.AlternativaBus is null || !PrimerTramoBusEsBasura(a.AlternativaBus, _coordenadasOrigen, _coordenadasDestino)) .ToList(); if (filtradas.Count == 0 && alternativas.Count > 0) filtradas = alternativas; _opcionesRuta.Clear(); _opcionesRuta.AddRange(filtradas); await CalcularEsfuerzoExtraPendienteAlternativasAsync(_opcionesRuta, ct); OrdenarOpcionesRutaSegunCriterio(); if (!resultado.Ok || _opcionesRuta.Count == 0) { _mensajeRuta = string.IsNullOrWhiteSpace(resultado.Mensaje) ? "No hay rutas disponibles para esta combinacion." : resultado.Mensaje; _hayRutaValida = false; try { await ActualizarResumenTecnicoRutaAsync(CancellationToken.None); } catch { } _tiempoUltimoCalculoRuta = cronometroCalculo.Elapsed; StateHasChanged(); return; } var idxPreferida = proveedorSoloAPiePreferido is not null ? _opcionesRuta.FindIndex(opcion => opcion.EsSoloAPie && string.Equals(opcion.ProveedorRuta, proveedorSoloAPiePreferido, StringComparison.OrdinalIgnoreCase)) : BuscarIndicePreferidoPorCriterio(_criterioPrioritarioRuta); if (idxPreferida < 0 || idxPreferida >= _opcionesRuta.Count) { idxPreferida = BuscarIndicePreferidoPorCriterio(_criterioPrioritarioRuta); if (idxPreferida < 0 || idxPreferida >= _opcionesRuta.Count) idxPreferida = 0; } _indiceAlternativaSeleccionada = idxPreferida; ResetEstadoRuta(limpiarOpciones: false, limpiarMensaje: false); _mensajeRuta = "Representando ruta seleccionada..."; StateHasChanged(); await Task.Yield(); await PintarOpcionRutaCompleta(_opcionesRuta[idxPreferida]); if (!omitirRegistroHistorialEsteCalculo) { await RegistrarEscenarioActualEnHistorialLocalAsync(); } if (confirmarDerivacionHistorial) ConfirmarDerivacionHistorial(); await ActualizarResumenTecnicoRutaAsync(CancellationToken.None); _tiempoUltimoCalculoRuta = cronometroCalculo.Elapsed; StateHasChanged(); } catch (OperationCanceledException) { } catch (Exception ex) { _mensajeRuta = $"Error calculando ruta: {ex.Message}"; _hayRutaValida = false; try { await ActualizarResumenTecnicoRutaAsync(CancellationToken.None); } catch { } _tiempoUltimoCalculoRuta = cronometroCalculo.Elapsed; StateHasChanged(); } } // ========================================================= // Pintado // ========================================================= }