diff --git a/RutasDBUS/Components/Pages/PlanificadorRutas.Estado.cs b/RutasDBUS/Components/Pages/PlanificadorRutas.Estado.cs index a62589f..ad5ef94 100644 --- a/RutasDBUS/Components/Pages/PlanificadorRutas.Estado.cs +++ b/RutasDBUS/Components/Pages/PlanificadorRutas.Estado.cs @@ -55,10 +55,12 @@ public partial class PlanificadorRutas : ComponentBase private const double CoeficienteEsfuerzoAlturaPorDefecto = 5.2; private double _coeficienteEsfuerzoAltura = CoeficienteEsfuerzoAlturaPorDefecto; private double _pesoTransbordoRankingMinutos = 4.0; + private double _factorEquilibradoAndarBus = 12.0; private string _factorPenalizacionDistanciaPieTextoEdicion = "1.60"; private string _parametroPenalizacionDistanciaPieTextoEdicion = "100"; private string _coeficienteEsfuerzoAlturaTextoEdicion = "5.20"; private string _pesoTransbordoRankingTextoEdicion = "4,0"; + private string _factorEquilibradoAndarBusTextoEdicion = "12"; private double _velocidadBusKmH = 16.0; private int _minutosTransbordo = 5; private int _margenTransbordoSegundos = 120; @@ -142,6 +144,25 @@ public partial class PlanificadorRutas : ComponentBase } } + /// + /// Expone el factor que convierte metros andando a metros equivalentes de bus en el modo equilibrado. + /// + private string FactorEquilibradoAndarBusTexto + { + get => _factorEquilibradoAndarBusTextoEdicion; + set + { + var texto = (value ?? "").Replace(',', '.'); + _factorEquilibradoAndarBusTextoEdicion = texto; + + if (!double.TryParse(texto, NumberStyles.Float, CultureInfo.InvariantCulture, out var valor)) + return; + + if (double.IsFinite(valor)) + _factorEquilibradoAndarBus = Math.Max(0, valor); + } + } + private static bool TryParseDuracionMinutos(string texto, out double minutos) { minutos = 0; @@ -220,6 +241,7 @@ public partial class PlanificadorRutas : ComponentBase _parametroPenalizacionDistanciaPieTextoEdicion = _parametroPenalizacionDistanciaPieMetros.ToString("0", CultureInfo.InvariantCulture); _coeficienteEsfuerzoAlturaTextoEdicion = _coeficienteEsfuerzoAltura.ToString("0.00", CultureInfo.InvariantCulture); _pesoTransbordoRankingTextoEdicion = FormatearDuracionMinutos(_pesoTransbordoRankingMinutos); + _factorEquilibradoAndarBusTextoEdicion = _factorEquilibradoAndarBus.ToString("0.##", CultureInfo.InvariantCulture); } /// @@ -257,6 +279,16 @@ public partial class PlanificadorRutas : ComponentBase SincronizarTextosPenalizacionPie(); await PersistirConfiguracionAsync(); } + + /// + /// Normaliza el factor equilibrado al salir del campo. + /// + private async Task OnBlurFactorEquilibradoAndarBus() + { + SincronizarTextosPenalizacionPie(); + await PersistirConfiguracionAsync(); + await RecalcularRutaEquilibradaTrasCambioFactorAsync(); + } private int _radioCandidatasMetros = 1200; private int _maxCandidatasPorLado = 12; @@ -317,6 +349,8 @@ public partial class PlanificadorRutas : ComponentBase private int _indiceAlternativaSeleccionada = 0; private DateTime? _ultimaHoraReferenciaRuta = null; private TimeSpan? _tiempoUltimoCalculoRuta = null; + private bool _mostrarElevacionesRuta = false; + private bool _pintandoElevacionesRuta = false; private readonly Dictionary> _paradasItinerarioVisiblesPorId = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary> _paradasRutaVisiblesPorId = new(StringComparer.OrdinalIgnoreCase); @@ -1016,6 +1050,23 @@ public partial class PlanificadorRutas : ComponentBase } catch { } } + + private async Task DrawRouteElevationPointsJsAsync(string id, IReadOnlyList points) + { + try + { + await JS.InvokeVoidAsync("rt.drawElevPoints", $"route_elev_{id}", points, new + { + radius = 4, + opacity = 0.98, + fillOpacity = 0.95, + showLabels = false, + showSlopeTooltips = true, + className = "rt-route-elev-point" + }); + } + catch { } + } /// /// Ajusta rutas js. @@ -1168,55 +1219,6 @@ public partial class PlanificadorRutas : ComponentBase + UtilidadesTransitoDbus.CalcularDistanciaPieCorregidaMetros(alternativa.DistanciaPieFinAprox, parametros); } - /// - /// Estima si una alternativa equilibrada se parece mas al criterio Tiempo o al criterio Andar. - /// - private string FormatearBalanceEquilibradoAlternativa(AlternativaRuta? alternativa) - { - if (alternativa is null || alternativa.EsSoloAPie) - return string.Empty; - - var alternativasBus = _opcionesRuta - .Where(opcion => !opcion.EsSoloAPie) - .ToList(); - - if (alternativasBus.Count < 2) - return string.Empty; - - var duraciones = alternativasBus - .Select(DuracionOrdenacion) - .Where(double.IsFinite) - .ToList(); - - var costesAndar = alternativasBus - .Select(CalcularCosteAndarEquilibradoSegundos) - .Where(double.IsFinite) - .ToList(); - - if (duraciones.Count == 0 || costesAndar.Count == 0) - return string.Empty; - - var ventajaTiempo = CalcularVentajaNormalizada( - DuracionOrdenacion(alternativa), - duraciones.Min(), - duraciones.Max()); - - var ventajaAndar = CalcularVentajaNormalizada( - CalcularCosteAndarEquilibradoSegundos(alternativa), - costesAndar.Min(), - costesAndar.Max()); - - var total = ventajaTiempo + ventajaAndar; - if (!double.IsFinite(total) || total <= 0.000001) - return string.Empty; - - var porcentajeTiempo = (int)Math.Round((ventajaTiempo / total) * 100.0); - porcentajeTiempo = Math.Clamp(porcentajeTiempo, 0, 100); - var porcentajeAndar = 100 - porcentajeTiempo; - - return $"Tiempo {porcentajeTiempo}% / Andar {porcentajeAndar}%"; - } - private double CalcularCosteAndarEquilibradoSegundos(AlternativaRuta? alternativa) { if (alternativa is null) @@ -1229,30 +1231,70 @@ public partial class PlanificadorRutas : ComponentBase return EstimarDuracionPieSegundos(distanciaCorregida); } - private static double CalcularVentajaNormalizada(double valor, double mejor, double peor) + private double CalcularScoreEquilibradoFactor(AlternativaRuta? alternativa) { - if (!double.IsFinite(valor) || - !double.IsFinite(mejor) || - !double.IsFinite(peor) || - peor <= mejor) - { - return 0; - } + if (alternativa is null || alternativa.EsSoloAPie || alternativa.AlternativaBus is null) + return double.PositiveInfinity; - return Math.Clamp((peor - valor) / (peor - mejor), 0.0, 1.0); + var distanciaBus = Math.Max(0, alternativa.AlternativaBus.DistanciaTotalBus); + var distanciaAndando = Math.Max(0, alternativa.DistanciaPieTotalAprox); + var factor = double.IsFinite(_factorEquilibradoAndarBus) + ? Math.Max(0, _factorEquilibradoAndarBus) + : 12.0; + + var score = distanciaBus + (distanciaAndando * factor); + return double.IsFinite(score) && score >= 0 + ? score + : double.PositiveInfinity; } - private static double CalcularSacrificioNormalizado(double valor, double mejor, double peor) + private string FormatearScoreEquilibradoAlternativa(AlternativaRuta? alternativa) { - if (!double.IsFinite(valor) || - !double.IsFinite(mejor) || - !double.IsFinite(peor) || - peor <= mejor) + var score = CalcularScoreEquilibradoFactor(alternativa); + if (!double.IsFinite(score)) + return string.Empty; + + return $"Score {FormatearDistanciaTarjeta(score)} (x{_factorEquilibradoAndarBus.ToString("0.##", CultureInfo.InvariantCulture)})"; + } + + private string FactorEquilibradoAndarBusResumen + => _factorEquilibradoAndarBus.ToString("0.##", CultureInfo.InvariantCulture); + + private async Task RecalcularRutaEquilibradaTrasCambioFactorAsync() + { + if (!CriterioPrioritarioEquilibradoActivo) + return; + + if (_mapa is not null && _coordenadasOrigen is not null && _coordenadasDestino is not null) { - return 0; + _omitirRegistroHistorialEnSiguienteCalculo = true; + await CalcularRutaCompletaAsync(); + return; } - return Math.Clamp((valor - mejor) / (peor - mejor), 0.0, 1.0); + if (_opcionesRuta.Count == 0) + { + StateHasChanged(); + return; + } + + OrdenarOpcionesRutaSegunCriterio(); + var indicePreferido = BuscarIndiceEquilibrado(); + 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(); } /// diff --git a/RutasDBUS/Components/Pages/PlanificadorRutas.Historial.cs b/RutasDBUS/Components/Pages/PlanificadorRutas.Historial.cs index 31da2db..e1b527b 100644 --- a/RutasDBUS/Components/Pages/PlanificadorRutas.Historial.cs +++ b/RutasDBUS/Components/Pages/PlanificadorRutas.Historial.cs @@ -184,6 +184,7 @@ public partial class PlanificadorRutas existente.HoraSimulada = actual.HoraSimulada; existente.CriterioPrioritarioRuta = actual.CriterioPrioritarioRuta; existente.PreferenciaEtiquetaRuta = actual.PreferenciaEtiquetaRuta; + existente.FactorEquilibradoAndarBus = actual.FactorEquilibradoAndarBus; existente.ActivarFiltroLineas = actual.ActivarFiltroLineas; existente.LineasPermitidas = actual.LineasPermitidas.ToList(); existente.FechaActualizacionUtc = DateTime.UtcNow; @@ -369,6 +370,15 @@ public partial class PlanificadorRutas _criterioPrioritarioRuta = NormalizarCriterioRuta(item.CriterioPrioritarioRuta, item.PreferenciaEtiquetaRuta); SincronizarPreferenciaDesdeCriterioRuta(); + if (item.FactorEquilibradoAndarBus is { } factorEquilibrado && double.IsFinite(factorEquilibrado)) + { + _factorEquilibradoAndarBus = Math.Max(0, factorEquilibrado); + SincronizarTextosPenalizacionPie(); + + if (_configuracionTemporalPreviaHistorial is not null) + _configuracionTemporalPreviaHistorial.FactorEquilibradoAndarBus = _factorEquilibradoAndarBus; + } + _activarRangoLineas = item.ActivarFiltroLineas; _lineasPermitidas.Clear(); foreach (var linea in NormalizarLineasHistorial(item.LineasPermitidas)) @@ -445,6 +455,9 @@ public partial class PlanificadorRutas HoraSimulada = TimeOnly.FromDateTime(ahora).ToString("HH:mm:ss", CultureInfo.InvariantCulture), CriterioPrioritarioRuta = _criterioPrioritarioRuta, PreferenciaEtiquetaRuta = _preferenciaEtiquetaRuta, + FactorEquilibradoAndarBus = double.IsFinite(_factorEquilibradoAndarBus) + ? Math.Max(0, _factorEquilibradoAndarBus) + : 12.0, ActivarFiltroLineas = _activarRangoLineas, LineasPermitidas = ObtenerLineasPermitidasSeleccionadas().ToList(), FechaCreacionUtc = DateTime.UtcNow, @@ -573,6 +586,9 @@ public partial class PlanificadorRutas var fecha = item.UsarHoraSimulada ? item.FechaSimulada?.Trim() ?? string.Empty : string.Empty; var hora = item.UsarHoraSimulada ? item.HoraSimulada?.Trim() ?? string.Empty : string.Empty; var criterio = NormalizarCriterioRuta(item.CriterioPrioritarioRuta, item.PreferenciaEtiquetaRuta); + var factorEquilibrado = item.FactorEquilibradoAndarBus is { } factor && double.IsFinite(factor) + ? Math.Max(0, factor).ToString("0.######", CultureInfo.InvariantCulture) + : string.Empty; var lineas = item.ActivarFiltroLineas ? string.Join(",", NormalizarLineasHistorial(item.LineasPermitidas)) : string.Empty; @@ -587,6 +603,7 @@ public partial class PlanificadorRutas fecha, hora, criterio, + factorEquilibrado, item.ActivarFiltroLineas ? "1" : "0", lineas); } diff --git a/RutasDBUS/Components/Pages/PlanificadorRutas.Itinerarios.cs b/RutasDBUS/Components/Pages/PlanificadorRutas.Itinerarios.cs index ade58c0..5c211ab 100644 --- a/RutasDBUS/Components/Pages/PlanificadorRutas.Itinerarios.cs +++ b/RutasDBUS/Components/Pages/PlanificadorRutas.Itinerarios.cs @@ -1261,7 +1261,7 @@ public partial class PlanificadorRutas : ComponentBase private bool _elevPopupMinimizado = false; - private const string _versionApp = "(v-20260626a)"; + private const string _versionApp = "(v-20260709a)"; private double? _paradaElevG; diff --git a/RutasDBUS/Components/Pages/PlanificadorRutas.Persistencia.cs b/RutasDBUS/Components/Pages/PlanificadorRutas.Persistencia.cs index 707c81b..ed02c62 100644 --- a/RutasDBUS/Components/Pages/PlanificadorRutas.Persistencia.cs +++ b/RutasDBUS/Components/Pages/PlanificadorRutas.Persistencia.cs @@ -35,6 +35,7 @@ public partial class PlanificadorRutas public double? ParametroPenalizacionDistanciaPieMetros { get; set; } public double? CoeficienteEsfuerzoAltura { get; set; } public double? PesoTransbordoRankingMinutos { get; set; } + public double? FactorEquilibradoAndarBus { get; set; } public double VelocidadPieKmH { get; set; } public double VelocidadBusKmH { get; set; } public int ElevMaxPuntosPorTramo { get; set; } @@ -134,6 +135,7 @@ public partial class PlanificadorRutas ParametroPenalizacionDistanciaPieMetros = _parametroPenalizacionDistanciaPieMetros, CoeficienteEsfuerzoAltura = _coeficienteEsfuerzoAltura, PesoTransbordoRankingMinutos = _pesoTransbordoRankingMinutos, + FactorEquilibradoAndarBus = _factorEquilibradoAndarBus, VelocidadPieKmH = _velocidadPieKmH, VelocidadBusKmH = _velocidadBusKmH, ElevMaxPuntosPorTramo = _elevMaxPuntosPorTramo, @@ -188,6 +190,8 @@ public partial class PlanificadorRutas : coeficienteAltura; if (config.PesoTransbordoRankingMinutos is { } pesoTransbordo && double.IsFinite(pesoTransbordo)) _pesoTransbordoRankingMinutos = Math.Abs(pesoTransbordo - 8.0) < 0.0001 ? 4.0 : Math.Max(0, pesoTransbordo); + if (config.FactorEquilibradoAndarBus is { } factorEquilibrado && double.IsFinite(factorEquilibrado)) + _factorEquilibradoAndarBus = Math.Max(0, factorEquilibrado); _velocidadPieKmH = Math.Clamp(config.VelocidadPieKmH, 0.5, 25.0); _velocidadBusKmH = Math.Clamp( Math.Abs(config.VelocidadBusKmH - 15.0) < 0.0001 || @@ -290,6 +294,7 @@ public partial class PlanificadorRutas _parametroPenalizacionDistanciaPieMetros = 100.0; _coeficienteEsfuerzoAltura = CoeficienteEsfuerzoAlturaPorDefecto; _pesoTransbordoRankingMinutos = 4.0; + _factorEquilibradoAndarBus = 12.0; _velocidadPieKmH = 3.6; _velocidadBusKmH = 16.0; _minutosTransbordo = 5; @@ -336,6 +341,7 @@ public partial class PlanificadorRutas _parametroPenalizacionDistanciaPieMetros = 100.0; _coeficienteEsfuerzoAltura = CoeficienteEsfuerzoAlturaPorDefecto; _pesoTransbordoRankingMinutos = 4.0; + _factorEquilibradoAndarBus = 12.0; _velocidadPieKmH = 3.6; _velocidadBusKmH = 16.0; _minutosTransbordo = 5; diff --git a/RutasDBUS/Components/Pages/PlanificadorRutas.Planificacion.cs b/RutasDBUS/Components/Pages/PlanificadorRutas.Planificacion.cs index 221b52b..3c00323 100644 --- a/RutasDBUS/Components/Pages/PlanificadorRutas.Planificacion.cs +++ b/RutasDBUS/Components/Pages/PlanificadorRutas.Planificacion.cs @@ -1458,49 +1458,8 @@ public partial class PlanificadorRutas : ComponentBase if (candidatas.Count == 0) return _opcionesRuta.Count > 0 ? 0 : -1; - var duraciones = candidatas - .Select(x => DuracionOrdenacion(x.opcion)) - .Where(double.IsFinite) - .ToList(); - - var costesAndar = candidatas - .Select(x => CalcularCosteAndarEquilibradoSegundos(x.opcion)) - .Where(double.IsFinite) - .ToList(); - - if (duraciones.Count == 0 || costesAndar.Count == 0) - return candidatas - .OrderBy(x => x.opcion.PuntuacionCoste) - .ThenBy(x => DuracionOrdenacion(x.opcion)) - .Select(x => x.indice) - .FirstOrDefault(); - - var minDuracion = duraciones.Min(); - var maxDuracion = duraciones.Max(); - var minAndar = costesAndar.Min(); - var maxAndar = costesAndar.Max(); - return candidatas - .Select(x => - { - var sacrificioTiempo = CalcularSacrificioNormalizado( - DuracionOrdenacion(x.opcion), - minDuracion, - maxDuracion); - var sacrificioAndar = CalcularSacrificioNormalizado( - CalcularCosteAndarEquilibradoSegundos(x.opcion), - minAndar, - maxAndar); - return new - { - x.indice, - x.opcion, - PeorSacrificio = Math.Max(sacrificioTiempo, sacrificioAndar), - SacrificioMedio = (sacrificioTiempo + sacrificioAndar) / 2.0 - }; - }) - .OrderBy(x => x.PeorSacrificio) - .ThenBy(x => x.SacrificioMedio) + .OrderBy(x => CalcularScoreEquilibradoFactor(x.opcion)) .ThenBy(x => x.opcion.NumeroTransbordosReales) .ThenBy(x => x.opcion.TiempoEsperaTotalSegundos) .ThenBy(x => x.opcion.PuntuacionCoste) @@ -1528,6 +1487,14 @@ public partial class PlanificadorRutas : ComponentBase .ThenBy(TiempoPieOrdenacion) .ThenBy(opcion => opcion.PuntuacionCoste); } + else if (CriterioPrioritarioEquilibradoActivo) + { + rutasBus = rutasBus + .OrderBy(CalcularScoreEquilibradoFactor) + .ThenBy(opcion => opcion.NumeroTransbordosReales) + .ThenBy(opcion => opcion.TiempoEsperaTotalSegundos) + .ThenBy(opcion => opcion.PuntuacionCoste); + } var soloPie = _opcionesRuta .Where(opcion => opcion.EsSoloAPie) @@ -2141,6 +2108,7 @@ public partial class PlanificadorRutas : ComponentBase FactorPenalizacionDistanciaPie = _factorPenalizacionDistanciaPie, ParametroPenalizacionDistanciaPieMetros = _parametroPenalizacionDistanciaPieMetros, PesoTransbordoRankingMinutos = _pesoTransbordoRankingMinutos, + FactorEquilibradoAndarBus = _factorEquilibradoAndarBus, VelocidadBusKmH = _velocidadBusKmH, MinutosPorTransbordo = _minutosTransbordo, MargenTransbordoSegundos = Math.Clamp(_margenTransbordoSegundos, 0, 1800), diff --git a/RutasDBUS/Components/Pages/PlanificadorRutas.Renderizado.cs b/RutasDBUS/Components/Pages/PlanificadorRutas.Renderizado.cs index 2166953..4f964b2 100644 --- a/RutasDBUS/Components/Pages/PlanificadorRutas.Renderizado.cs +++ b/RutasDBUS/Components/Pages/PlanificadorRutas.Renderizado.cs @@ -66,6 +66,148 @@ public partial class PlanificadorRutas : ComponentBase StateHasChanged(); } } + + private async Task ToggleElevacionesRutaAsync(ChangeEventArgs e) + { + _mostrarElevacionesRuta = e.Value switch + { + bool valor => valor, + string texto when bool.TryParse(texto, out var valor) => valor, + _ => !_mostrarElevacionesRuta + }; + + if (_opcionesRuta.Count == 0 || + _indiceAlternativaSeleccionada < 0 || + _indiceAlternativaSeleccionada >= _opcionesRuta.Count) + { + StateHasChanged(); + return; + } + + _pintandoElevacionesRuta = true; + _mensajeRuta = _mostrarElevacionesRuta + ? "Aplicando elevaciones..." + : "Representando ruta seleccionada..."; + StateHasChanged(); + await Task.Yield(); + + try + { + await CerrarPopupsAsync(); + await PintarOpcionRutaCompleta(_opcionesRuta[_indiceAlternativaSeleccionada]); + } + finally + { + _pintandoElevacionesRuta = false; + StateHasChanged(); + } + } + + private async Task DrawWalkingRouteJsAsync( + string id, + double[,] linea, + int weight, + int versionPintado, + bool arrowEnd = true) + { + var puntosElevacion = await ConstruirPuntosElevacionMapaAsync(linea, CancellationToken.None); + ThrowIfPintadoObsoleto(versionPintado); + + await DrawRouteJsAsync( + id, + linea, + "#dc2626", + weight, + 0.9, + dashArray: DashArrayPiePunteado, + arrowEnd: arrowEnd, + lineCap: "round"); + + if (puntosElevacion is { Count: > 0 }) + { + ThrowIfPintadoObsoleto(versionPintado); + await DrawRouteElevationPointsJsAsync(id, puntosElevacion); + } + } + + private async Task?> ConstruirPuntosElevacionMapaAsync(double[,] linea, CancellationToken ct) + { + if (!_mostrarElevacionesRuta || linea.GetLength(0) < 2) + return null; + + var muestras = await ConstruirMuestrasElevacionAsync(linea, ct); + if (muestras.Count < 2) + return null; + + var puntos = new List(); + var hayPendienteCalculada = false; + + for (var i = 1; i < muestras.Count; i++) + { + ct.ThrowIfCancellationRequested(); + + var anterior = muestras[i - 1]; + var actual = muestras[i]; + var distancia = CalcularDistanciaMetros( + anterior.Latitud, + anterior.Longitud, + actual.Latitud, + actual.Longitud); + + if (distancia <= 0.5) + continue; + + var elevAnterior = ElevacionPreferidaMapa(anterior); + var elevActual = ElevacionPreferidaMapa(actual); + double? pendiente = null; + + if (elevAnterior is not null && elevActual is not null) + { + pendiente = (elevActual.Value - elevAnterior.Value) / distancia * 100.0; + hayPendienteCalculada = true; + } + + var (categoria, color) = ClasificarPendienteMapa(pendiente); + puntos.Add(new + { + lat = actual.Latitud, + lon = actual.Longitud, + color, + category = categoria, + slope = pendiente, + label = pendiente is null + ? "-" + : pendiente.Value.ToString("+0.0;-0.0;0.0", CultureInfo.InvariantCulture) + " %" + }); + } + + return hayPendienteCalculada && puntos.Count > 0 + ? puntos + : null; + } + + private static double? ElevacionPreferidaMapa(MuestraElevacion muestra) + => muestra.ElevacionIdee ?? muestra.Elevacion; + + private static (string Categoria, string Color) ClasificarPendienteMapa(double? pendientePorcentaje) + { + if (pendientePorcentaje is null || !double.IsFinite(pendientePorcentaje.Value)) + return ("sin-datos", "#94a3b8"); + + if (pendientePorcentaje.Value >= 8.0) + return ("subida-fuerte", "#ef4444"); + + if (pendientePorcentaje.Value >= 3.0) + return ("subida", "#f97316"); + + if (pendientePorcentaje.Value <= -8.0) + return ("bajada-fuerte", "#1d4ed8"); + + if (pendientePorcentaje.Value <= -3.0) + return ("bajada", "#38bdf8"); + + return ("llano", "#facc15"); + } @@ -95,6 +237,8 @@ public partial class PlanificadorRutas : ComponentBase await PintarParadasSolucionSeleccionadaAsync(opcion, versionPintado); } + ThrowIfPintadoObsoleto(versionPintado); + await ActualizarAlturasResumenRutaAsync(CancellationToken.None); ThrowIfPintadoObsoleto(versionPintado); await CerrarPopupsAsync(); @@ -207,7 +351,7 @@ public partial class PlanificadorRutas : ComponentBase }); ThrowIfPintadoObsoleto(versionPintado); - await DrawRouteJsAsync("walk_full", linea, "#dc2626", 5, 0.9, dashArray: DashArrayPiePunteado, arrowEnd: true, lineCap: "round"); + await DrawWalkingRouteJsAsync("walk_full", linea, 5, versionPintado); ThrowIfPintadoObsoleto(versionPintado); _mensajeRuta = CapitalizarPalabrasClave( @@ -307,7 +451,7 @@ public partial class PlanificadorRutas : ComponentBase }); ThrowIfPintadoObsoleto(versionPintado); - await DrawRouteJsAsync("walk_1", w1.line, "#dc2626", 4, 0.9, dashArray: DashArrayPiePunteado, arrowEnd: true, lineCap: "round"); + await DrawWalkingRouteJsAsync("walk_1", w1.line, 4, versionPintado); ThrowIfPintadoObsoleto(versionPintado); } @@ -384,7 +528,7 @@ public partial class PlanificadorRutas : ComponentBase }); ThrowIfPintadoObsoleto(versionPintado); - await DrawRouteJsAsync($"walk_union_{i}", lineaUnion, "#dc2626", 4, 0.9, dashArray: DashArrayPiePunteado, arrowEnd: true, lineCap: "round"); + await DrawWalkingRouteJsAsync($"walk_union_{i}", lineaUnion, 4, versionPintado); ThrowIfPintadoObsoleto(versionPintado); ultimoPuntoDibujado = ObtenerUltimoPunto(lineaUnion); } @@ -435,7 +579,7 @@ public partial class PlanificadorRutas : ComponentBase }); ThrowIfPintadoObsoleto(versionPintado); - await DrawRouteJsAsync($"walk_transfer_real_{i}", lineaTransferencia, "#dc2626", 4, 0.9, dashArray: DashArrayPiePunteado, arrowEnd: true, lineCap: "round"); + await DrawWalkingRouteJsAsync($"walk_transfer_real_{i}", lineaTransferencia, 4, versionPintado); ThrowIfPintadoObsoleto(versionPintado); ultimoPuntoDibujado = ObtenerUltimoPunto(lineaTransferencia); @@ -478,7 +622,7 @@ public partial class PlanificadorRutas : ComponentBase }); ThrowIfPintadoObsoleto(versionPintado); - await DrawRouteJsAsync($"walk_transfer_{i}", wt.line, "#dc2626", 4, 0.9, dashArray: DashArrayPiePunteado, arrowEnd: true, lineCap: "round"); + await DrawWalkingRouteJsAsync($"walk_transfer_{i}", wt.line, 4, versionPintado); ThrowIfPintadoObsoleto(versionPintado); } } @@ -688,7 +832,7 @@ public partial class PlanificadorRutas : ComponentBase }); ThrowIfPintadoObsoleto(versionPintado); - await DrawRouteJsAsync("walk_union_final", lineaUnionFinal, "#dc2626", 4, 0.9, dashArray: DashArrayPiePunteado, arrowEnd: true, lineCap: "round"); + await DrawWalkingRouteJsAsync("walk_union_final", lineaUnionFinal, 4, versionPintado); ThrowIfPintadoObsoleto(versionPintado); EstablecerPrimerPunto(w2.line, ObtenerUltimoPunto(lineaUnionFinal)); } @@ -715,7 +859,7 @@ public partial class PlanificadorRutas : ComponentBase }); ThrowIfPintadoObsoleto(versionPintado); - await DrawRouteJsAsync("walk_2", w2.line, "#dc2626", 4, 0.9, dashArray: DashArrayPiePunteado, arrowEnd: true, lineCap: "round"); + await DrawWalkingRouteJsAsync("walk_2", w2.line, 4, versionPintado); ThrowIfPintadoObsoleto(versionPintado); } diff --git a/RutasDBUS/Components/Pages/PlanificadorRutas.razor b/RutasDBUS/Components/Pages/PlanificadorRutas.razor index 356bcb9..d88ad32 100644 --- a/RutasDBUS/Components/Pages/PlanificadorRutas.razor +++ b/RutasDBUS/Components/Pages/PlanificadorRutas.razor @@ -628,7 +628,7 @@ class="route-priority-option @(CriterioPrioritarioEquilibradoActivo ? "route-priority-option-active" : "")" title="Mantener el criterio equilibrado actual" @onclick="@(() => CambiarCriterioPrioritarioRutasAsync(CriterioRutaEquilibrado))"> - Equilibrado* + Equilib. A - - } - else - { - - - - } - - + + + +
+ +
+ +
@@ -1346,7 +1356,7 @@ -
+
+
+ +
+ + + 1 m andando = @FactorEquilibradoAndarBusResumen m bus + +
+
+
@@ -1410,20 +1437,6 @@ @onblur="OnBlurCoeficienteEsfuerzoAltura" />
- - -
- -
- - - -
-
- @*
diff --git a/RutasDBUS/Modelos/Historial/RegistroHistorialRuta.cs b/RutasDBUS/Modelos/Historial/RegistroHistorialRuta.cs index b0bf499..a365b91 100644 --- a/RutasDBUS/Modelos/Historial/RegistroHistorialRuta.cs +++ b/RutasDBUS/Modelos/Historial/RegistroHistorialRuta.cs @@ -18,6 +18,7 @@ public sealed class RegistroHistorialRuta public string? HoraSimulada { get; set; } public string? CriterioPrioritarioRuta { get; set; } public string? PreferenciaEtiquetaRuta { get; set; } + public double? FactorEquilibradoAndarBus { get; set; } public bool ActivarFiltroLineas { get; set; } public List LineasPermitidas { get; set; } = new(); public DateTime FechaCreacionUtc { get; set; } = DateTime.UtcNow; diff --git a/RutasDBUS/Modelos/Planificacion/ParadaCandidata.cs b/RutasDBUS/Modelos/Planificacion/ParadaCandidata.cs index 999395a..9361648 100644 --- a/RutasDBUS/Modelos/Planificacion/ParadaCandidata.cs +++ b/RutasDBUS/Modelos/Planificacion/ParadaCandidata.cs @@ -26,6 +26,7 @@ public sealed class ParametrosPlanificadorRuta public double FactorPenalizacionDistanciaPie { get; set; } = 1.6; public double ParametroPenalizacionDistanciaPieMetros { get; set; } = 100.0; public double PesoTransbordoRankingMinutos { get; set; } = 4.0; + public double FactorEquilibradoAndarBus { get; set; } = 12.0; // selección candidatas public int RadioParadasCandidatasMetros { get; set; } = 1200; diff --git a/RutasDBUS/Servicios/Historial/ServicioHistorialRutasCompartido.cs b/RutasDBUS/Servicios/Historial/ServicioHistorialRutasCompartido.cs index 0dd685a..8e44cd2 100644 --- a/RutasDBUS/Servicios/Historial/ServicioHistorialRutasCompartido.cs +++ b/RutasDBUS/Servicios/Historial/ServicioHistorialRutasCompartido.cs @@ -86,6 +86,7 @@ public sealed class ServicioHistorialRutasCompartido : IHistorialRutasCompartido existente.HoraSimulada = registro.HoraSimulada; existente.CriterioPrioritarioRuta = registro.CriterioPrioritarioRuta; existente.PreferenciaEtiquetaRuta = registro.PreferenciaEtiquetaRuta; + existente.FactorEquilibradoAndarBus = registro.FactorEquilibradoAndarBus; existente.ActivarFiltroLineas = registro.ActivarFiltroLineas; existente.LineasPermitidas = NormalizarLineas(registro.LineasPermitidas).ToList(); @@ -296,6 +297,7 @@ public sealed class ServicioHistorialRutasCompartido : IHistorialRutasCompartido HoraSimulada = item.HoraSimulada, CriterioPrioritarioRuta = item.CriterioPrioritarioRuta, PreferenciaEtiquetaRuta = item.PreferenciaEtiquetaRuta, + FactorEquilibradoAndarBus = item.FactorEquilibradoAndarBus, ActivarFiltroLineas = item.ActivarFiltroLineas, LineasPermitidas = NormalizarLineas(item.LineasPermitidas).ToList(), FechaCreacionUtc = item.FechaCreacionUtc, @@ -313,6 +315,9 @@ public sealed class ServicioHistorialRutasCompartido : IHistorialRutasCompartido var criterio = NormalizarTextoClave(item.CriterioPrioritarioRuta); if (string.IsNullOrWhiteSpace(criterio)) criterio = NormalizarTextoClave(item.PreferenciaEtiquetaRuta); + var factorEquilibrado = item.FactorEquilibradoAndarBus is { } factor && double.IsFinite(factor) + ? Math.Max(0, factor).ToString("0.######", CultureInfo.InvariantCulture) + : string.Empty; var lineas = item.ActivarFiltroLineas ? string.Join(",", NormalizarLineas(item.LineasPermitidas)) : string.Empty; @@ -327,6 +332,7 @@ public sealed class ServicioHistorialRutasCompartido : IHistorialRutasCompartido fecha, hora, criterio, + factorEquilibrado, item.ActivarFiltroLineas ? "1" : "0", lineas); } diff --git a/RutasDBUS/Servicios/Planificacion/BuscadorRutasTemporalesDbus.cs b/RutasDBUS/Servicios/Planificacion/BuscadorRutasTemporalesDbus.cs index bcff0b5..b64cced 100644 --- a/RutasDBUS/Servicios/Planificacion/BuscadorRutasTemporalesDbus.cs +++ b/RutasDBUS/Servicios/Planificacion/BuscadorRutasTemporalesDbus.cs @@ -704,11 +704,13 @@ public sealed class BuscadorRutasTemporalesDbus if (!origenPorCodigo.TryGetValue(primeraParada, out var origen)) continue; + var distanciaTotalBus = CalcularDistanciaBusEstado(estadoDestino); var ruta = new RutaBusAlternativa { Tramos = tramosBus, NumeroTransbordos = numeroTransbordos, - CodigosParadasTransbordo = ConstruirCodigosTransbordo(tramosBus) + CodigosParadasTransbordo = ConstruirCodigosTransbordo(tramosBus), + DistanciaTotalBus = distanciaTotalBus }; var duracionEstimada = Math.Max(0, (estadoDestino.Llegada - instanteSalida).TotalSeconds) + destino.TiempoAPie; @@ -748,7 +750,8 @@ public sealed class BuscadorRutasTemporalesDbus return SeleccionarPoolBusqueda( mejores.Values, - Math.Max(12, Math.Max(1, parametros.MaxAlternativasBusPorPar) * 8)); + Math.Max(12, Math.Max(1, parametros.MaxAlternativasBusPorPar) * 8), + parametros); } /// @@ -756,7 +759,8 @@ public sealed class BuscadorRutasTemporalesDbus /// private static List SeleccionarPoolBusqueda( IEnumerable candidatas, - int maxPorCoste) + int maxPorCoste, + ParametrosPlanificadorRuta parametros) { var lista = candidatas.ToList(); if (lista.Count == 0) @@ -766,6 +770,7 @@ public sealed class BuscadorRutasTemporalesDbus var firmas = new HashSet(StringComparer.OrdinalIgnoreCase); var limiteCoste = Math.Max(1, maxPorCoste); var limiteAndar = Math.Max(8, limiteCoste / 2); + var limiteEquilibrado = Math.Max(8, limiteCoste / 2); Agregar(lista .OrderBy(x => x.PuntuacionCoste) @@ -781,6 +786,13 @@ public sealed class BuscadorRutasTemporalesDbus .ThenBy(x => x.RutaBus.NumeroTransbordos) .Take(limiteAndar)); + Agregar(lista + .OrderBy(x => CalcularScoreBusquedaEquilibrado(x, parametros)) + .ThenBy(x => x.DuracionEstimadaSegundos) + .ThenBy(x => x.RutaBus.NumeroTransbordos) + .ThenBy(x => x.PuntuacionCoste) + .Take(limiteEquilibrado)); + return seleccionadas .OrderBy(x => x.PuntuacionCoste) .ThenBy(x => x.DuracionEstimadaSegundos) @@ -799,6 +811,55 @@ public sealed class BuscadorRutasTemporalesDbus } } + private static double CalcularScoreBusquedaEquilibrado( + ResultadoBusquedaRutaBusDbus resultado, + ParametrosPlanificadorRuta parametros) + { + var distanciaBus = Math.Max(0, resultado.RutaBus.DistanciaTotalBus); + var distanciaAndando = Math.Max(0, resultado.DistanciaPieTotal); + var factor = double.IsFinite(parametros.FactorEquilibradoAndarBus) + ? Math.Max(0, parametros.FactorEquilibradoAndarBus) + : 12.0; + + var score = distanciaBus + (distanciaAndando * factor); + return double.IsFinite(score) && score >= 0 + ? score + : double.PositiveInfinity; + } + + private static double CalcularDistanciaBusEstado(EstadoBusquedaParadaDbus estadoDestino) + { + var distancia = 0.0; + var cursor = estadoDestino; + while (cursor is not null) + { + if (cursor.Movimiento is MovimientoBusDbus movimiento) + distancia += CalcularDistanciaBusPatron(movimiento.Patron, movimiento.IndiceSubida, movimiento.IndiceBajada); + + cursor = cursor.EstadoAnterior; + } + + return double.IsFinite(distancia) && distancia > 0 + ? distancia + : 0; + } + + private static double CalcularDistanciaBusPatron(PatronTransitoDbus patron, int indiceInicio, int indiceFin) + { + if (indiceInicio < 0 || + indiceFin <= indiceInicio || + indiceFin >= patron.DistanciaAcumuladaPorParadaMetros.Count) + { + return 0; + } + + var distancia = patron.DistanciaAcumuladaPorParadaMetros[indiceFin] - + patron.DistanciaAcumuladaPorParadaMetros[indiceInicio]; + return double.IsFinite(distancia) && distancia > 0 + ? distancia + : 0; + } + /// /// Reconstruye los tramos de bus de una ruta. /// diff --git a/RutasDBUS/Servicios/Planificacion/PlanificadorRutasServicio.cs b/RutasDBUS/Servicios/Planificacion/PlanificadorRutasServicio.cs index 3100fa6..8e6794d 100644 --- a/RutasDBUS/Servicios/Planificacion/PlanificadorRutasServicio.cs +++ b/RutasDBUS/Servicios/Planificacion/PlanificadorRutasServicio.cs @@ -30,6 +30,8 @@ public sealed class PlanificadorRutasServicio : IPlanificadorRutas new(StringComparer.OrdinalIgnoreCase); private static readonly ConcurrentDictionary lineas, double distancia)> _cacheLineasBusPlanificacion = new(StringComparer.OrdinalIgnoreCase); + private readonly ConcurrentDictionary _cacheParadasEstimacion = + new(StringComparer.OrdinalIgnoreCase); private const double UmbralCosturaVisualMetros = 12.0; private const double UmbralUnionPeatonalAutomaticaMetros = 250.0; private const double ToleranciaEmpateRapidezSegundos = 5.0; @@ -46,6 +48,7 @@ public sealed class PlanificadorRutasServicio : IPlanificadorRutas private const double UmbralAccesoDestinoTransbordoRobustoMetros = 350.0; private const double ToleranciaDominanciaFinalSegundos = 45.0; private const double ToleranciaDominanciaFinalMetros = 40.0; + private const double ToleranciaDominanciaFinalBusMetros = 120.0; private const double MejoraClaraDominanciaFinalSegundos = 90.0; private const double MejoraClaraDominanciaFinalMetros = 120.0; private const double AhorroClaroAndandoFinalMetros = 250.0; @@ -79,6 +82,7 @@ public sealed class PlanificadorRutasServicio : IPlanificadorRutas _cacheDuracionBusRefinamiento.Clear(); _cacheSegmentosHorarios.Clear(); _cacheLineasBusPlanificacion.Clear(); + _cacheParadasEstimacion.Clear(); } /// @@ -208,7 +212,8 @@ public sealed class PlanificadorRutasServicio : IPlanificadorRutas candidatasBus = SeleccionarPoolBusqueda( porFirma.Values, - Math.Max(16, Math.Max(1, parametros.MaxAlternativasBusPorPar) * 10)); + Math.Max(16, Math.Max(1, parametros.MaxAlternativasBusPorPar) * 10), + parametros); } var extensionesCircularesInicio = BuscarExtensionesCircularesInicialesConHorario( @@ -236,7 +241,8 @@ public sealed class PlanificadorRutasServicio : IPlanificadorRutas candidatasBus = SeleccionarPoolBusqueda( porFirma.Values, - Math.Max(16, Math.Max(1, parametros.MaxAlternativasBusPorPar) * 10)); + Math.Max(16, Math.Max(1, parametros.MaxAlternativasBusPorPar) * 10), + parametros); } } @@ -287,7 +293,7 @@ public sealed class PlanificadorRutasServicio : IPlanificadorRutas .Select(grupo => grupo.OrderBy(x => x.PuntuacionCoste).First()) .ToList(); - alternativasBus = SeleccionarPoolFinal(DeduplicarAlternativasEquivalentes(alternativasBus)); + alternativasBus = SeleccionarPoolFinal(DeduplicarAlternativasEquivalentes(alternativasBus), parametros); var finales = SeleccionarAlternativasFinales(alternativasBus, alternativasSoloPie, parametros); if (finales.Count == 0) @@ -444,14 +450,14 @@ public sealed class PlanificadorRutasServicio : IPlanificadorRutas if (mejores.Count > maxResultados * 3) { - mejores = SeleccionarPoolBusqueda(mejores.Values, maxResultados * 2) + mejores = SeleccionarPoolBusqueda(mejores.Values, maxResultados * 2, parametros) .ToDictionary(x => UtilidadesTransitoDbus.FirmaRutaBus(x.RutaBus), StringComparer.OrdinalIgnoreCase); } } } } - return SeleccionarPoolBusqueda(mejores.Values, maxResultados); + return SeleccionarPoolBusqueda(mejores.Values, maxResultados, parametros); } /// @@ -612,7 +618,7 @@ public sealed class PlanificadorRutasServicio : IPlanificadorRutas } } - return SeleccionarPoolBusqueda(mejores.Values, maxResultados); + return SeleccionarPoolBusqueda(mejores.Values, maxResultados, parametros); } /// @@ -681,7 +687,7 @@ public sealed class PlanificadorRutasServicio : IPlanificadorRutas } } - return SeleccionarPoolBusqueda(mejores.Values, maxResultados); + return SeleccionarPoolBusqueda(mejores.Values, maxResultados, parametros); void ExplorarPrimeraPiernaDirecta( ParadaCandidata origen, @@ -1169,7 +1175,7 @@ public sealed class PlanificadorRutasServicio : IPlanificadorRutas void PodarMejores() { - mejores = SeleccionarPoolBusqueda(mejores.Values, maxResultados * 2) + mejores = SeleccionarPoolBusqueda(mejores.Values, maxResultados * 2, parametros) .ToDictionary( x => UtilidadesTransitoDbus.FirmaRutaBus(x.RutaBus), StringComparer.OrdinalIgnoreCase); @@ -1194,7 +1200,8 @@ public sealed class PlanificadorRutasServicio : IPlanificadorRutas var maxResultados = Math.Max(24, Math.Max(1, parametros.MaxAlternativasBusPorPar) * 12); var basesExtension = SeleccionarPoolBusqueda( candidatasBase, - Math.Max(12, Math.Max(1, parametros.MaxAlternativasBusPorPar) * 4)); + Math.Max(12, Math.Max(1, parametros.MaxAlternativasBusPorPar) * 4), + parametros); foreach (var baseRuta in basesExtension) { @@ -1331,7 +1338,7 @@ public sealed class PlanificadorRutasServicio : IPlanificadorRutas } } - return SeleccionarPoolBusqueda(mejores.Values, maxResultados); + return SeleccionarPoolBusqueda(mejores.Values, maxResultados, parametros); } private static PatronTransitoDbus? ResolverPatronDesdeTramo(IndiceTransitoDbus indiceTransito, TramoBus tramo) @@ -1397,9 +1404,10 @@ public sealed class PlanificadorRutasServicio : IPlanificadorRutas /// /// Conserva candidatas buenas por coste y tambien un cupo por menor caminata. /// - private static List SeleccionarPoolBusqueda( + private List SeleccionarPoolBusqueda( IEnumerable candidatas, - int maxPorCoste) + int maxPorCoste, + ParametrosPlanificadorRuta? parametros = null) { var lista = candidatas.ToList(); if (lista.Count == 0) @@ -1409,6 +1417,7 @@ public sealed class PlanificadorRutasServicio : IPlanificadorRutas var firmas = new HashSet(StringComparer.OrdinalIgnoreCase); var limiteCoste = Math.Max(1, maxPorCoste); var limiteAndar = Math.Max(8, limiteCoste / 2); + var limiteEquilibrado = Math.Max(8, limiteCoste / 2); var duracionMinima = lista .Select(x => x.DuracionEstimadaSegundos) .Where(double.IsFinite) @@ -1432,6 +1441,13 @@ public sealed class PlanificadorRutasServicio : IPlanificadorRutas .ThenBy(x => x.RutaBus.NumeroTransbordos) .Take(limiteAndar)); + Agregar(lista + .OrderBy(x => CalcularScoreBusquedaEquilibrado(x, parametros)) + .ThenBy(x => x.DuracionEstimadaSegundos) + .ThenBy(x => x.RutaBus.NumeroTransbordos) + .ThenBy(x => x.PuntuacionCoste) + .Take(limiteEquilibrado)); + Agregar(lista .Where(x => x.DuracionEstimadaSegundos <= limiteDuracionAndarRazonable) .OrderBy(x => x.DistanciaPieTotal) @@ -1465,6 +1481,25 @@ public sealed class PlanificadorRutasServicio : IPlanificadorRutas } } + private double CalcularScoreBusquedaEquilibrado( + ResultadoBusquedaRutaBusDbus resultado, + ParametrosPlanificadorRuta? parametros) + { + var distanciaBus = Math.Max(0, resultado.RutaBus.DistanciaTotalBus); + if (distanciaBus <= 0 && resultado.RutaBus.Tramos.Count > 0) + distanciaBus = CalcularDistanciaBusCandidata(resultado.RutaBus.Tramos); + + var distanciaAndando = Math.Max(0, resultado.DistanciaPieTotal); + var factor = parametros is not null && double.IsFinite(parametros.FactorEquilibradoAndarBus) + ? Math.Max(0, parametros.FactorEquilibradoAndarBus) + : 12.0; + + var score = distanciaBus + (distanciaAndando * factor); + return double.IsFinite(score) && score >= 0 + ? score + : double.PositiveInfinity; + } + private static bool DebeBuscarTransbordosRobustos( IReadOnlyList candidatasBus, IReadOnlyList candidatasOrigen, @@ -1700,11 +1735,19 @@ public sealed class PlanificadorRutasServicio : IPlanificadorRutas .ToList(); } - private static List SeleccionarPoolFinal(IReadOnlyList alternativas) + private static List SeleccionarPoolFinal( + IReadOnlyList alternativas, + ParametrosPlanificadorRuta parametros) { var seleccionadas = new List(); var firmas = new HashSet(StringComparer.OrdinalIgnoreCase); + Agregar(alternativas + .OrderBy(x => CalcularScoreEquilibrado(x, parametros)) + .ThenBy(x => x.PuntuacionCoste) + .ThenBy(x => x.DuracionTotalAproxSegundos) + .Take(16)); + Agregar(alternativas .OrderBy(x => x.PuntuacionCoste) .ThenBy(x => x.DuracionTotalAproxSegundos) @@ -2683,6 +2726,59 @@ public sealed class PlanificadorRutasServicio : IPlanificadorRutas return lineas; } + private double CalcularDistanciaBusCandidata(IReadOnlyList tramos) + { + if (tramos.Count == 0) + return 0; + + var distanciaTotal = 0.0; + foreach (var tramo in tramos) + { + if (!tramo.EsValido) + continue; + + var codigos = tramo.Variante.CodigosParadas; + var indiceInicio = Math.Clamp(tramo.IndiceInicio, 0, codigos.Count - 1); + var indiceFin = Math.Clamp(tramo.IndiceFin, 0, codigos.Count - 1); + for (var indice = indiceInicio + 1; indice <= indiceFin; indice++) + { + if (!TryResolverParadaEstimacion(codigos[indice - 1], out var origen) || + !TryResolverParadaEstimacion(codigos[indice], out var destino)) + { + continue; + } + + distanciaTotal += UtilidadesTransitoDbus.CalcularDistanciaMetros( + origen.Latitud, + origen.Longitud, + destino.Latitud, + destino.Longitud); + } + } + + return double.IsFinite(distanciaTotal) && distanciaTotal > 0 + ? distanciaTotal + : 0; + } + + private bool TryResolverParadaEstimacion(string codigoParada, out Parada parada) + { + if (_cacheParadasEstimacion.TryGetValue(codigoParada, out parada!)) + return true; + + var encontrada = _redBusServicio.Paradas + .FirstOrDefault(p => p.Codigo.Equals(codigoParada, StringComparison.OrdinalIgnoreCase)); + if (encontrada is null) + { + parada = default!; + return false; + } + + _cacheParadasEstimacion[codigoParada] = encontrada; + parada = encontrada; + return true; + } + private static string CrearClaveLineasBus(IReadOnlyList tramos) { return string.Join(";", tramos.Select(tramo => @@ -2761,7 +2857,8 @@ public sealed class PlanificadorRutasServicio : IPlanificadorRutas var priorizarTiempo = string.Equals(preferenciaEtiquetaRuta, "M\u00e1s r\u00e1pida", StringComparison.OrdinalIgnoreCase); var modoMenosAndar = string.Equals(preferenciaEtiquetaRuta, "Menos andar", StringComparison.OrdinalIgnoreCase); var alternativasBusRazonables = FiltrarAlternativasRazonablesFinales(alternativasBus, masRapidaBus); - var alternativasBusParaAndar = modoMenosAndar + var modoEquilibrado = string.IsNullOrWhiteSpace(preferenciaEtiquetaRuta); + var alternativasBusParaAndar = modoMenosAndar || modoEquilibrado ? AmpliarAlternativasPorAhorroAndando(alternativasBus, alternativasBusRazonables) : alternativasBusRazonables; var menosTransbordos = alternativasBusRazonables @@ -2775,9 +2872,8 @@ public sealed class PlanificadorRutasServicio : IPlanificadorRutas modoMenosAndar ? null : masRapidaBus, parametros); var priorizarAndar = string.Equals(preferenciaEtiquetaRuta, "Menos andar", StringComparison.OrdinalIgnoreCase); - var modoEquilibrado = string.IsNullOrWhiteSpace(preferenciaEtiquetaRuta); var equilibrada = modoEquilibrado - ? ElegirEquilibradaRazonable(alternativasBusRazonables, parametros) + ? ElegirEquilibradaRazonable(alternativasBusParaAndar, parametros) : null; if (priorizarAndar) @@ -2788,8 +2884,8 @@ public sealed class PlanificadorRutasServicio : IPlanificadorRutas } else if (modoEquilibrado) { - AddAlternativaBus(finales, usadas, masRapidaBus, "M\u00e1s r\u00e1pida"); AddAlternativaBus(finales, usadas, equilibrada, "Equilibrada"); + AddAlternativaBus(finales, usadas, masRapidaBus, "M\u00e1s r\u00e1pida"); AddAlternativaBus(finales, usadas, menosAndar, "Menos andar"); AddAlternativaBus(finales, usadas, menosTransbordos, "Menos transbordos"); } @@ -2804,7 +2900,7 @@ public sealed class PlanificadorRutasServicio : IPlanificadorRutas ? alternativasBusParaAndar.OrderBy(x => CalcularCosteAndarEquilibradoSegundos(x, parametros)).ThenBy(x => x.PuntuacionCoste) : priorizarTiempo ? alternativasBusRazonables.OrderBy(x => DuracionVisibleOrdenacion(x)).ThenBy(x => x.DistanciaPieTotalAprox).ThenBy(x => x.PuntuacionCoste) - : alternativasBusRazonables.OrderBy(x => CalcularScoreEquilibrado(x, alternativasBusRazonables, parametros)) + : alternativasBusParaAndar.OrderBy(x => CalcularScoreEquilibrado(x, parametros)) .ThenBy(x => x.PuntuacionCoste); foreach (var alternativa in candidatasRelleno) @@ -2841,8 +2937,7 @@ public sealed class PlanificadorRutasServicio : IPlanificadorRutas return null; return alternativasBus - .OrderBy(x => CalcularScoreEquilibrado(x, alternativasBus, parametros)) - .ThenBy(x => CalcularSacrificioMedioEquilibrado(x, alternativasBus, parametros)) + .OrderBy(x => CalcularScoreEquilibrado(x, parametros)) .ThenBy(x => x.NumeroTransbordosReales) .ThenBy(x => x.TiempoEsperaTotalSegundos) .ThenBy(x => x.PuntuacionCoste) @@ -2851,69 +2946,28 @@ public sealed class PlanificadorRutasServicio : IPlanificadorRutas private static double CalcularScoreEquilibrado( AlternativaRuta alternativa, - IReadOnlyList alternativasBus, ParametrosPlanificadorRuta parametros) { - var (sacrificioTiempo, sacrificioAndar) = CalcularSacrificiosEquilibrado( - alternativa, - alternativasBus, - parametros); + if (alternativa.AlternativaBus is null) + return double.PositiveInfinity; - return Math.Max(sacrificioTiempo, sacrificioAndar); - } + var distanciaBus = Math.Max(0, alternativa.AlternativaBus.DistanciaTotalBus); + var distanciaAndando = Math.Max(0, alternativa.DistanciaPieTotalAprox); + var factor = double.IsFinite(parametros.FactorEquilibradoAndarBus) + ? Math.Max(0, parametros.FactorEquilibradoAndarBus) + : 12.0; - private static double CalcularSacrificioMedioEquilibrado( - AlternativaRuta alternativa, - IReadOnlyList alternativasBus, - ParametrosPlanificadorRuta parametros) - { - var (sacrificioTiempo, sacrificioAndar) = CalcularSacrificiosEquilibrado( - alternativa, - alternativasBus, - parametros); - - return (sacrificioTiempo + sacrificioAndar) / 2.0; - } - - private static (double Tiempo, double Andar) CalcularSacrificiosEquilibrado( - AlternativaRuta alternativa, - IReadOnlyList alternativasBus, - ParametrosPlanificadorRuta parametros) - { - var duraciones = alternativasBus - .Select(DuracionVisibleOrdenacion) - .Where(double.IsFinite) - .ToList(); - - var costesAndar = alternativasBus - .Select(x => CalcularCosteAndarEquilibradoSegundos(x, parametros)) - .Where(double.IsFinite) - .ToList(); - - if (duraciones.Count == 0 || costesAndar.Count == 0) - return (0, 0); - - var sacrificioTiempo = CalcularSacrificioNormalizado( - DuracionVisibleOrdenacion(alternativa), - duraciones.Min(), - duraciones.Max()); - - var sacrificioAndar = CalcularSacrificioNormalizado( - CalcularCosteAndarEquilibradoSegundos(alternativa, parametros), - costesAndar.Min(), - costesAndar.Max()); - - return (sacrificioTiempo, sacrificioAndar); + var score = distanciaBus + (distanciaAndando * factor); + return double.IsFinite(score) && score >= 0 + ? score + : double.PositiveInfinity; } private static double CalcularCosteAndarEquilibradoSegundos( AlternativaRuta alternativa, ParametrosPlanificadorRuta parametros) { - var distanciaCorregida = - UtilidadesTransitoDbus.CalcularDistanciaPieCorregidaMetros(alternativa.DistanciaPieInicioAprox, parametros) + - UtilidadesTransitoDbus.CalcularDistanciaPieCorregidaMetros(alternativa.DistanciaPieTransbordosAprox, parametros) + - UtilidadesTransitoDbus.CalcularDistanciaPieCorregidaMetros(alternativa.DistanciaPieFinAprox, parametros); + var distanciaCorregida = CalcularDistanciaAndandoCorregidaMetros(alternativa, parametros); if (!double.IsFinite(distanciaCorregida) || distanciaCorregida < 0) return double.PositiveInfinity; @@ -2921,18 +2975,12 @@ public sealed class PlanificadorRutasServicio : IPlanificadorRutas return UtilidadesTransitoDbus.EstimarDuracionPieSegundos(distanciaCorregida, parametros); } - private static double CalcularSacrificioNormalizado(double valor, double mejor, double peor) - { - if (!double.IsFinite(valor) || - !double.IsFinite(mejor) || - !double.IsFinite(peor) || - peor <= mejor) - { - return 0; - } - - return Math.Clamp((valor - mejor) / (peor - mejor), 0.0, 1.0); - } + private static double CalcularDistanciaAndandoCorregidaMetros( + AlternativaRuta alternativa, + ParametrosPlanificadorRuta parametros) + => UtilidadesTransitoDbus.CalcularDistanciaPieCorregidaMetros(alternativa.DistanciaPieInicioAprox, parametros) + + UtilidadesTransitoDbus.CalcularDistanciaPieCorregidaMetros(alternativa.DistanciaPieTransbordosAprox, parametros) + + UtilidadesTransitoDbus.CalcularDistanciaPieCorregidaMetros(alternativa.DistanciaPieFinAprox, parametros); private static double DuracionVisibleOrdenacion(AlternativaRuta alternativa) => double.IsFinite(alternativa.DuracionTotalAproxSegundos) @@ -3049,6 +3097,7 @@ public sealed class PlanificadorRutasServicio : IPlanificadorRutas { var duracionCandidata = DuracionVisibleOrdenacion(candidata); var pieCandidata = candidata.DistanciaPieTotalAprox; + var busCandidata = candidata.AlternativaBus?.DistanciaTotalBus ?? double.PositiveInfinity; foreach (var otra in alternativas) { @@ -3060,9 +3109,11 @@ public sealed class PlanificadorRutasServicio : IPlanificadorRutas var duracionOtra = DuracionVisibleOrdenacion(otra); var pieOtra = otra.DistanciaPieTotalAprox; + var busOtra = otra.AlternativaBus?.DistanciaTotalBus ?? double.PositiveInfinity; var noPierdeTiempo = duracionOtra <= duracionCandidata + ToleranciaDominanciaFinalSegundos; var noPierdeAndando = pieOtra <= pieCandidata + ToleranciaDominanciaFinalMetros; - if (!noPierdeTiempo || !noPierdeAndando) + var noPierdeBus = busOtra <= busCandidata + ToleranciaDominanciaFinalBusMetros; + if (!noPierdeTiempo || !noPierdeAndando || !noPierdeBus) continue; var mejoraTransbordo = otra.NumeroTransbordosReales < candidata.NumeroTransbordosReales; diff --git a/RutasDBUS/wwwroot/app.css b/RutasDBUS/wwwroot/app.css index 1a2ea84..76efeda 100644 --- a/RutasDBUS/wwwroot/app.css +++ b/RutasDBUS/wwwroot/app.css @@ -807,6 +807,74 @@ html, body { background: #2563eb; } +.route-visual-options { + display: flex; + align-items: center; + justify-content: flex-end; + margin: -.15rem 0 .4rem; +} + +.route-elevation-switch { + display: inline-flex; + align-items: center; + gap: .38rem; + min-height: 24px; + color: #cbd5e1; + font-size: .7rem; + font-weight: 700; + cursor: pointer; + pointer-events: auto; + user-select: none; +} + +.route-elevation-switch input { + position: absolute; + opacity: 0; + pointer-events: none; +} + +.route-elevation-switch-track { + position: relative; + width: 34px; + height: 18px; + flex: 0 0 34px; + border-radius: 999px; + border: 1px solid rgba(148, 163, 184, .5); + background: rgba(2, 6, 23, .7); + transition: background .15s ease, border-color .15s ease; +} + +.route-elevation-switch-thumb { + position: absolute; + top: 2px; + left: 2px; + width: 12px; + height: 12px; + border-radius: 999px; + background: #94a3b8; + box-shadow: 0 1px 4px rgba(0, 0, 0, .35); + transition: transform .15s ease, background .15s ease; +} + +.route-elevation-switch--active { + color: #f8fafc; +} + +.route-elevation-switch--active .route-elevation-switch-track { + border-color: rgba(251, 191, 36, .85); + background: linear-gradient(90deg, rgba(56, 189, 248, .75), rgba(249, 115, 22, .85)); +} + +.route-elevation-switch--active .route-elevation-switch-thumb { + transform: translateX(16px); + background: #f8fafc; +} + +.route-elevation-switch:has(input:disabled) { + cursor: default; + opacity: .55; +} + /* Lista de alternativas de ruta */ .route-alt-list { @@ -872,12 +940,6 @@ html, body { font-weight: 800; } -.route-alt-balance { - color: #bfdbfe; - white-space: nowrap; - font-weight: 700; -} - /* Separador fino dentro del panel */ .route-panel-divider { @@ -1549,6 +1611,19 @@ html, body { text-align: center; } +.config-dual-labeled--equilibrado { + grid-template-columns: minmax(0, 4rem) minmax(0, 4.4rem); + align-items: end; +} + +.config-equilibrado-help { + align-self: end; + padding-bottom: .12rem; + font-size: .58rem; + line-height: 1.08; + text-align: left; +} + .num-stepper { @@ -1870,11 +1945,6 @@ html, body { min-width: 0; } -.line-filter__range--segundos-parada { - grid-template-columns: auto minmax(120px, 150px); - margin-top: .5rem; -} - .line-filter__selector-head { display: flex; align-items: center; @@ -2023,6 +2093,65 @@ html, body { pointer-events: none; } +.leaflet-tooltip.rt-elev-tooltip { + background: rgba(15, 23, 42, .92) !important; + border: 1px solid rgba(226, 232, 240, .35) !important; + border-radius: 6px !important; + color: #f8fafc !important; + font-size: .68rem !important; + font-weight: 800 !important; + padding: 2px 6px !important; + box-shadow: 0 8px 18px rgba(0, 0, 0, .35) !important; +} + +.leaflet-tooltip.rt-elev-tooltip:before { + display: none !important; +} + +.rt-route-elev-point { + stroke: #020617 !important; + stroke-width: 1.8px !important; + filter: drop-shadow(0 1px 2px rgba(2, 6, 23, .75)); +} + +.rt-route-elev-point--subida, +.rt-route-elev-point--subida-fuerte { + animation: rt-route-elev-pulse-up 1.45s ease-in-out infinite; +} + +.rt-route-elev-point--bajada, +.rt-route-elev-point--bajada-fuerte { + animation: rt-route-elev-pulse-down 1.45s ease-in-out infinite; +} + +.rt-route-elev-point--llano { + opacity: .88; +} + +@keyframes rt-route-elev-pulse-up { + 0%, 100% { + stroke-width: 1.8px; + opacity: .95; + } + + 50% { + stroke-width: 4px; + opacity: .72; + } +} + +@keyframes rt-route-elev-pulse-down { + 0%, 100% { + stroke-width: 1.8px; + opacity: .95; + } + + 50% { + stroke-width: 3.5px; + opacity: .68; + } +} + diff --git a/RutasDBUS/wwwroot/app.js b/RutasDBUS/wwwroot/app.js index 37b2a4a..0547fd3 100644 --- a/RutasDBUS/wwwroot/app.js +++ b/RutasDBUS/wwwroot/app.js @@ -103,6 +103,7 @@ window.rt.clearRoutes = function () { if (!window.rt.ensureRouteLayer()) return; window.rt._routeLayer.clearLayers(); window.rt._routeLines = {}; + if (window.rt.clearRouteElevPoints) window.rt.clearRouteElevPoints(); if (window.rt._routeStopsLayer) { window.rt._routeStopsLayer.clearLayers(); @@ -112,13 +113,11 @@ window.rt.clearRoutes = function () { window.rt.drawRoute = function (id, coords, options) { if (!window.rt.ensureRouteLayer()) return; - // borrar línea anterior si existe if (window.rt._routeLines[id]) { window.rt._routeLayer.removeLayer(window.rt._routeLines[id]); delete window.rt._routeLines[id]; } - // borrar flecha anterior si existe const arrowId = id + "__arrow"; if (window.rt._routeLines[arrowId]) { window.rt._routeLayer.removeLayer(window.rt._routeLines[arrowId]); @@ -128,38 +127,40 @@ window.rt.drawRoute = function (id, coords, options) { 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 arrowClass = dashArray ? "rt-arrow rt-arrow--dashed" : "rt-arrow"; - const arrowInnerHtml = dashArray ? '' : ''; + + 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; + }; const lineOpts = { color, weight, opacity }; if (dashArray) lineOpts.dashArray = dashArray; if (lineCap) lineOpts.lineCap = lineCap; - const line = window.L.polyline(coords, lineOpts); - - // ✅ Hacer la línea clicable y avisar a Blazor (igual que drawPreviewLine) - 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 line = buildLine(coords, lineOpts); line.addTo(window.rt._routeLayer); window.rt._routeLines[id] = line; - // Flecha SOLO al final if (arrowEnd && coords && coords.length >= 2) { const a = coords[coords.length - 2]; const b = coords[coords.length - 1]; @@ -174,6 +175,9 @@ window.rt.drawRoute = function (id, coords, options) { 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 ? '' : ''; + const icon = window.L.divIcon({ className: "rt-arrow-icon", html: `
{ + const group = window.rt._elevGroups[id]; + if (group) window.rt._elevLayer.removeLayer(group); + delete window.rt._elevGroups[id]; + }); +}; + // points: [{ lat, lon, label }] // options: { radius, opacity, fillOpacity, showLabels, labelEvery } window.rt.drawElevPoints = function (id, points, options) { @@ -294,18 +310,28 @@ window.rt.drawElevPoints = function (id, points, options) { 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 + fillOpacity: fillOpacity, + color: pointColor, + fillColor: pointColor, + className: className }); if (showLabels && every > 0 && (i % every === 0)) { @@ -342,6 +368,15 @@ window.rt.drawElevPoints = function (id, points, options) { 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.addTo(group); }