1614 lines
58 KiB
C#
1614 lines
58 KiB
C#
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.Planificacion;
|
||
using RutasDBUS.Modelos.Geocodificacion;
|
||
using RutasDBUS.Modelos.RedBus;
|
||
using RutasDBUS.Servicios;
|
||
using RutasDBUS.Servicios.Caminata;
|
||
using RutasDBUS.Servicios.Horarios;
|
||
using RutasDBUS.Servicios.Planificacion;
|
||
using RutasDBUS.Servicios.RedBus;
|
||
|
||
namespace RutasDBUS.Components.Pages;
|
||
|
||
public partial class PlanificadorRutas : ComponentBase
|
||
{
|
||
// =========================================================
|
||
// UI / Estado
|
||
// =========================================================
|
||
private int _segundosPorParadaBus = 30;
|
||
private int _segundosPorParadaBusManual = 30;
|
||
|
||
private bool _paradasPintadas = false;
|
||
private bool _pintandoParadas = false;
|
||
private Task? _tareaCargaRed;
|
||
|
||
private int _versionCalculo = 0;
|
||
private int _versionPintadoRuta = 0;
|
||
private CancellationTokenSource? _ctsCalculo;
|
||
private (double Latitud, double Longitud)? _coordsMouse;
|
||
private const double UmbralCosturaVisualMetros = 12.0;
|
||
private const double UmbralUnionPeatonalAutomaticaMetros = 250.0;
|
||
private const string DashArrayBusVuelta = "10 10";
|
||
private const string DashArrayPiePunteado = "1 7";
|
||
|
||
private bool _mostrarConfigAvanzada = false;
|
||
/// <summary>
|
||
/// Alterna la configuracion panel.
|
||
/// </summary>
|
||
private void ToggleConfigPanel() => _mostrarConfigAvanzada = !_mostrarConfigAvanzada;
|
||
|
||
private int _nivelBrilloMapa = 0;
|
||
|
||
// CONFIG
|
||
private double _velocidadPieKmH = 3.6;
|
||
private double _factorPenalizacionDistanciaPie = 1.6;
|
||
private double _parametroPenalizacionDistanciaPieMetros = 100.0;
|
||
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;
|
||
|
||
/// <summary>
|
||
/// Expone el factor de penalizacion peatonal con dos decimales para la UI.
|
||
/// </summary>
|
||
private string FactorPenalizacionDistanciaPieTexto
|
||
{
|
||
get => _factorPenalizacionDistanciaPieTextoEdicion;
|
||
set
|
||
{
|
||
var texto = (value ?? "").Replace(',', '.');
|
||
_factorPenalizacionDistanciaPieTextoEdicion = texto;
|
||
|
||
if (!double.TryParse(texto, NumberStyles.Float, CultureInfo.InvariantCulture, out var valor))
|
||
return;
|
||
|
||
if (double.IsFinite(valor))
|
||
_factorPenalizacionDistanciaPie = valor;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Expone el parametro base de metros de la penalizacion peatonal para la UI.
|
||
/// </summary>
|
||
private string ParametroPenalizacionDistanciaPieTexto
|
||
{
|
||
get => _parametroPenalizacionDistanciaPieTextoEdicion;
|
||
set
|
||
{
|
||
var texto = (value ?? "").Replace(',', '.');
|
||
_parametroPenalizacionDistanciaPieTextoEdicion = texto;
|
||
|
||
if (!double.TryParse(texto, NumberStyles.Float, CultureInfo.InvariantCulture, out var valor))
|
||
return;
|
||
|
||
if (double.IsFinite(valor))
|
||
_parametroPenalizacionDistanciaPieMetros = valor;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Expone el coeficiente usado para elevar el esfuerzo por pendiente.
|
||
/// </summary>
|
||
private string CoeficienteEsfuerzoAlturaTexto
|
||
{
|
||
get => _coeficienteEsfuerzoAlturaTextoEdicion;
|
||
set
|
||
{
|
||
var texto = (value ?? "").Replace(',', '.');
|
||
_coeficienteEsfuerzoAlturaTextoEdicion = texto;
|
||
|
||
if (!double.TryParse(texto, NumberStyles.Float, CultureInfo.InvariantCulture, out var valor))
|
||
return;
|
||
|
||
if (double.IsFinite(valor))
|
||
_coeficienteEsfuerzoAltura = valor;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Sincroniza los textos editables de coeficiente y parámetro con sus valores numéricos.
|
||
/// </summary>
|
||
/// <summary>
|
||
/// Expone el peso de transbordo usado solo para ordenar alternativas.
|
||
/// </summary>
|
||
private string PesoTransbordoRankingTexto
|
||
{
|
||
get => _pesoTransbordoRankingTextoEdicion;
|
||
set
|
||
{
|
||
var texto = value ?? "";
|
||
_pesoTransbordoRankingTextoEdicion = texto;
|
||
|
||
if (!TryParseDuracionMinutos(texto, out var valor))
|
||
return;
|
||
|
||
if (double.IsFinite(valor))
|
||
_pesoTransbordoRankingMinutos = Math.Max(0, valor);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Expone el factor que convierte metros andando a metros equivalentes de bus en el modo equilibrado.
|
||
/// </summary>
|
||
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;
|
||
texto = (texto ?? "").Trim().Replace(',', '.');
|
||
if (string.IsNullOrWhiteSpace(texto))
|
||
return false;
|
||
|
||
var partes = texto.Split(':', StringSplitOptions.TrimEntries);
|
||
if (partes.Length == 1)
|
||
return double.TryParse(partes[0], NumberStyles.Float, CultureInfo.InvariantCulture, out minutos);
|
||
|
||
if (partes.Length is < 2 or > 3)
|
||
return false;
|
||
|
||
if (!double.TryParse(partes[^1], NumberStyles.Float, CultureInfo.InvariantCulture, out var segundos))
|
||
return false;
|
||
if (!int.TryParse(partes[^2], NumberStyles.Integer, CultureInfo.InvariantCulture, out var minutosParte))
|
||
return false;
|
||
|
||
var horas = 0;
|
||
if (partes.Length == 3 &&
|
||
!int.TryParse(partes[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out horas))
|
||
{
|
||
return false;
|
||
}
|
||
|
||
minutos = Math.Max(0, (horas * 60.0) + minutosParte + (segundos / 60.0));
|
||
return true;
|
||
}
|
||
|
||
private static string FormatearDuracionMinutos(double minutos)
|
||
{
|
||
if (!double.IsFinite(minutos) || minutos <= 0)
|
||
return "0,0";
|
||
|
||
return minutos.ToString("0.0", CultureInfo.InvariantCulture).Replace('.', ',');
|
||
}
|
||
|
||
private string FormatearEsfuerzoElevacion(double? distanciaMetros, double? pendientePorcentaje)
|
||
{
|
||
var esfuerzo = CalcularEsfuerzoElevacion(distanciaMetros, pendientePorcentaje);
|
||
return esfuerzo is null
|
||
? "-"
|
||
: esfuerzo.Value.ToString("0.00", CultureInfo.InvariantCulture);
|
||
}
|
||
|
||
private double? CalcularEsfuerzoElevacion(double? distanciaMetros, double? pendientePorcentaje)
|
||
{
|
||
if (distanciaMetros is null ||
|
||
pendientePorcentaje is null ||
|
||
!double.IsFinite(distanciaMetros.Value) ||
|
||
!double.IsFinite(pendientePorcentaje.Value) ||
|
||
distanciaMetros.Value <= 0)
|
||
{
|
||
return null;
|
||
}
|
||
|
||
var coeficiente = double.IsFinite(_coeficienteEsfuerzoAltura)
|
||
? _coeficienteEsfuerzoAltura
|
||
: CoeficienteEsfuerzoAlturaPorDefecto;
|
||
|
||
var pendiente = pendientePorcentaje.Value / 100.0;
|
||
if (pendiente <= 0)
|
||
return distanciaMetros.Value;
|
||
|
||
var factorConPotencia = Math.Pow(1.0 + pendiente, coeficiente);
|
||
var esfuerzo = distanciaMetros.Value * factorConPotencia;
|
||
return double.IsFinite(esfuerzo)
|
||
? esfuerzo
|
||
: null;
|
||
}
|
||
|
||
private void SincronizarTextosPenalizacionPie()
|
||
{
|
||
_factorPenalizacionDistanciaPieTextoEdicion = _factorPenalizacionDistanciaPie.ToString("0.00", CultureInfo.InvariantCulture);
|
||
_parametroPenalizacionDistanciaPieTextoEdicion = _parametroPenalizacionDistanciaPieMetros.ToString("0", CultureInfo.InvariantCulture);
|
||
_coeficienteEsfuerzoAlturaTextoEdicion = _coeficienteEsfuerzoAltura.ToString("0.00", CultureInfo.InvariantCulture);
|
||
_pesoTransbordoRankingTextoEdicion = FormatearDuracionMinutos(_pesoTransbordoRankingMinutos);
|
||
_factorEquilibradoAndarBusTextoEdicion = _factorEquilibradoAndarBus.ToString("0.##", CultureInfo.InvariantCulture);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Normaliza el texto del coeficiente al salir del campo.
|
||
/// </summary>
|
||
private async Task OnBlurFactorPenalizacionDistanciaPie()
|
||
{
|
||
SincronizarTextosPenalizacionPie();
|
||
await PersistirConfiguracionAsync();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Normaliza el texto del parámetro base al salir del campo.
|
||
/// </summary>
|
||
private async Task OnBlurParametroPenalizacionDistanciaPie()
|
||
{
|
||
SincronizarTextosPenalizacionPie();
|
||
await PersistirConfiguracionAsync();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Normaliza el coeficiente de esfuerzo de altura al salir del campo.
|
||
/// </summary>
|
||
private async Task OnBlurCoeficienteEsfuerzoAltura()
|
||
{
|
||
SincronizarTextosPenalizacionPie();
|
||
await PersistirConfiguracionAsync();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Normaliza el peso de transbordos al salir del campo.
|
||
/// </summary>
|
||
private async Task OnBlurPesoTransbordoRanking()
|
||
{
|
||
SincronizarTextosPenalizacionPie();
|
||
await PersistirConfiguracionAsync();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Normaliza el factor equilibrado al salir del campo.
|
||
/// </summary>
|
||
private async Task OnBlurFactorEquilibradoAndarBus()
|
||
{
|
||
SincronizarTextosPenalizacionPie();
|
||
await PersistirConfiguracionAsync();
|
||
await RecalcularRutaEquilibradaTrasCambioFactorAsync();
|
||
}
|
||
|
||
private int _radioCandidatasMetros = 1200;
|
||
private int _maxCandidatasPorLado = 12;
|
||
private bool _usarOsrmParaRanking = false;
|
||
|
||
private bool _activarRangoItinerarios = true;
|
||
private int _itinerarioMin = 1;
|
||
private int _itinerarioMax = 99;
|
||
|
||
// Estado
|
||
private string _textoBusquedaOrigen = string.Empty;
|
||
private string _textoBusquedaDestino = string.Empty;
|
||
|
||
private RealTimeMap? _mapa;
|
||
|
||
private readonly RealTimeMap.LoadParameters _parametrosMapa = new()
|
||
{
|
||
location = new RealTimeMap.Location { latitude = 43.307251, longitude = -1.979497 },
|
||
zoomLevel = 14,
|
||
basemap = new RealTimeMap.Basemap
|
||
{
|
||
basemapLayers = new()
|
||
{
|
||
new RealTimeMap.BasemapLayer
|
||
{
|
||
url = "https://osm.tecnosis.net/osm_tiles/{z}/{x}/{y}.png",
|
||
attribution = "© OpenStreetMap",
|
||
title = "",
|
||
detectRetina = true
|
||
}
|
||
}
|
||
}
|
||
};
|
||
|
||
private double[]? _coordenadasOrigen;
|
||
private double[]? _coordenadasDestino;
|
||
|
||
private string _mensajeRuta = string.Empty;
|
||
private bool _hayRutaValida = false;
|
||
|
||
private Parada? _paradaOrigenSeleccionada;
|
||
private Parada? _paradaDestinoSeleccionada;
|
||
private Parada? _primerTransbordoseleccionado;
|
||
private int _numeroTransbordos = 0;
|
||
|
||
private double _distanciaAPieInicio = 0;
|
||
private double _distanciaAPieFin = 0;
|
||
private double _distanciaEnBus = 0;
|
||
|
||
private Parada? _paradaClicada;
|
||
|
||
private readonly List<DetalleTramoRuta> _tramosRuta = new();
|
||
private double _duracionTotalSegundos = 0;
|
||
private double? _altitudOrigenResumenRuta = null;
|
||
private double? _altitudDestinoResumenRuta = null;
|
||
private string? _callejeroOrigenResumenRuta = null;
|
||
private string? _callejeroDestinoResumenRuta = null;
|
||
|
||
private readonly List<AlternativaRuta> _opcionesRuta = new();
|
||
private int _indiceAlternativaSeleccionada = 0;
|
||
private DateTime? _ultimaHoraReferenciaRuta = null;
|
||
private TimeSpan? _tiempoUltimoCalculoRuta = null;
|
||
private bool _mostrarElevacionesRuta = false;
|
||
private bool _pintandoElevacionesRuta = false;
|
||
private bool _panelRutasMovilContraido = false;
|
||
private double _umbralPendienteSubidaRuta = 3.0;
|
||
private double _umbralPendienteBajadaRuta = 3.0;
|
||
private readonly Dictionary<string, List<Parada>> _paradasItinerarioVisiblesPorId = new(StringComparer.OrdinalIgnoreCase);
|
||
private readonly Dictionary<string, List<Parada>> _paradasRutaVisiblesPorId = new(StringComparer.OrdinalIgnoreCase);
|
||
|
||
private const string CriterioRutaTiempo = "Tiempo";
|
||
private const string CriterioRutaEquilibrado = "Equilibrado";
|
||
private const string CriterioRutaAndar = "Andar";
|
||
private const string PreferenciaRutaTiempo = "M\u00e1s r\u00e1pida";
|
||
private const string PreferenciaRutaAndar = "Menos Andar";
|
||
private string _criterioPrioritarioRuta = CriterioRutaEquilibrado;
|
||
private string? _preferenciaEtiquetaRuta = null;
|
||
|
||
private bool CriterioPrioritarioTiempoActivo =>
|
||
string.Equals(_criterioPrioritarioRuta, CriterioRutaTiempo, StringComparison.OrdinalIgnoreCase);
|
||
|
||
private bool CriterioPrioritarioEquilibradoActivo =>
|
||
string.Equals(_criterioPrioritarioRuta, CriterioRutaEquilibrado, StringComparison.OrdinalIgnoreCase);
|
||
|
||
private bool CriterioPrioritarioAndarActivo =>
|
||
string.Equals(_criterioPrioritarioRuta, CriterioRutaAndar, StringComparison.OrdinalIgnoreCase);
|
||
|
||
private void SincronizarPreferenciaDesdeCriterioRuta()
|
||
{
|
||
_preferenciaEtiquetaRuta = CriterioPrioritarioTiempoActivo
|
||
? PreferenciaRutaTiempo
|
||
: CriterioPrioritarioAndarActivo
|
||
? PreferenciaRutaAndar
|
||
: null;
|
||
}
|
||
|
||
private static string NormalizarCriterioRuta(string? criterio, string? preferenciaLegada = null)
|
||
{
|
||
if (string.Equals(criterio, CriterioRutaTiempo, StringComparison.OrdinalIgnoreCase))
|
||
return CriterioRutaTiempo;
|
||
if (string.Equals(criterio, CriterioRutaAndar, StringComparison.OrdinalIgnoreCase))
|
||
return CriterioRutaAndar;
|
||
if (string.Equals(criterio, CriterioRutaEquilibrado, StringComparison.OrdinalIgnoreCase))
|
||
return CriterioRutaEquilibrado;
|
||
|
||
if (string.Equals(preferenciaLegada, PreferenciaRutaTiempo, StringComparison.OrdinalIgnoreCase))
|
||
return CriterioRutaTiempo;
|
||
if (string.Equals(preferenciaLegada, PreferenciaRutaAndar, StringComparison.OrdinalIgnoreCase))
|
||
return CriterioRutaAndar;
|
||
|
||
return CriterioRutaEquilibrado;
|
||
}
|
||
|
||
// =========================================================
|
||
// Helpers (texto/formato)
|
||
// =========================================================
|
||
|
||
/// <summary>
|
||
/// Gestiona css desde variante.
|
||
/// </summary>
|
||
private string ColorCssDesdeVariante(InfoVariante v)
|
||
{
|
||
// Color real de la línea desde el JSON
|
||
var css = RedBusServicio.ConvertirColorArgbACss(v.Linea.ColorArgb);
|
||
|
||
// Fallback por seguridad si viniera vacío / inválido
|
||
if (string.IsNullOrWhiteSpace(css))
|
||
css = "#2563eb";
|
||
|
||
return css;
|
||
}
|
||
|
||
private sealed class ColaDoble<T>
|
||
{
|
||
private readonly LinkedList<T> _list = new();
|
||
public int Count => _list.Count;
|
||
/// <summary>
|
||
/// Gestiona front.
|
||
/// </summary>
|
||
public void PushFront(T item) => _list.AddFirst(item);
|
||
/// <summary>
|
||
/// Gestiona back.
|
||
/// </summary>
|
||
public void PushBack(T item) => _list.AddLast(item);
|
||
/// <summary>
|
||
/// Gestiona front.
|
||
/// </summary>
|
||
public T PopFront()
|
||
{
|
||
var v = _list.First!.Value;
|
||
_list.RemoveFirst();
|
||
return v;
|
||
}
|
||
}
|
||
|
||
|
||
/// <summary>
|
||
/// Construye paradas for variante.
|
||
/// </summary>
|
||
private static List<object> BuildStopsDesdeParadas(IEnumerable<Parada> paradas)
|
||
{
|
||
var stops = new List<object>();
|
||
foreach (var parada in paradas)
|
||
stops.Add(new { lat = parada.Latitud, lon = parada.Longitud, code = parada.Codigo });
|
||
|
||
return stops;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Construye las paradas visibles recorridas por una variante completa.
|
||
/// </summary>
|
||
private List<Parada> BuildParadasForVariante(InfoVariante v)
|
||
{
|
||
var stops = new List<Parada>(v?.CodigosParadas?.Count ?? 0);
|
||
if (v?.CodigosParadas is null) return stops;
|
||
|
||
var codigosVistos = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||
foreach (var cod in v.CodigosParadas)
|
||
{
|
||
if (!codigosVistos.Add(cod))
|
||
continue;
|
||
|
||
if (_paradaByCode.TryGetValue(cod, out var parada))
|
||
stops.Add(parada);
|
||
}
|
||
|
||
return stops;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Construye el payload JS de paradas de una variante.
|
||
/// </summary>
|
||
private List<object> BuildStopsForVariante(InfoVariante v)
|
||
=> BuildStopsDesdeParadas(BuildParadasForVariante(v));
|
||
|
||
/// <summary>
|
||
/// Construye las paradas visibles recorridas por un tramo concreto de bus.
|
||
/// </summary>
|
||
private List<Parada> BuildParadasForTramoBus(TramoBus tramo)
|
||
{
|
||
var stops = new List<Parada>();
|
||
|
||
if (tramo?.Variante?.CodigosParadas is null || tramo.Variante.CodigosParadas.Count == 0)
|
||
return stops;
|
||
|
||
var inicio = Math.Clamp(Math.Min(tramo.IndiceInicio, tramo.IndiceFin), 0, tramo.Variante.CodigosParadas.Count - 1);
|
||
var fin = Math.Clamp(Math.Max(tramo.IndiceInicio, tramo.IndiceFin), 0, tramo.Variante.CodigosParadas.Count - 1);
|
||
var codigosVistos = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||
|
||
for (var indice = inicio; indice <= fin; indice++)
|
||
{
|
||
var codigo = tramo.Variante.CodigosParadas[indice];
|
||
if (!codigosVistos.Add(codigo))
|
||
continue;
|
||
|
||
if (_paradaByCode.TryGetValue(codigo, out var parada))
|
||
stops.Add(parada);
|
||
}
|
||
|
||
return stops;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Elimina el registro de paradas visibles de un itinerario activo.
|
||
/// </summary>
|
||
private void QuitarParadasItinerarioVisibles(string id)
|
||
{
|
||
_paradasItinerarioVisiblesPorId.Remove(id);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Limpia todas las paradas visibles de itinerarios activos.
|
||
/// </summary>
|
||
private void LimpiarParadasItinerarioVisibles()
|
||
{
|
||
_paradasItinerarioVisiblesPorId.Clear();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Registra paradas visibles de la solución seleccionada.
|
||
/// </summary>
|
||
private void RegistrarParadasRutaVisibles(string id, IEnumerable<Parada> paradas)
|
||
{
|
||
_paradasRutaVisiblesPorId[id] = paradas
|
||
.GroupBy(p => p.Codigo, StringComparer.OrdinalIgnoreCase)
|
||
.Select(g => g.First())
|
||
.ToList();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Limpia todas las paradas visibles de la solución seleccionada.
|
||
/// </summary>
|
||
private void LimpiarParadasRutaVisibles()
|
||
{
|
||
_paradasRutaVisiblesPorId.Clear();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Devuelve las paradas destacadas actualmente visibles cuando la capa general está oculta.
|
||
/// </summary>
|
||
private List<Parada> ObtenerParadasDestacadasVisibles()
|
||
{
|
||
var resultado = new List<Parada>();
|
||
var codigos = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||
|
||
foreach (var parada in _paradasRutaVisiblesPorId.Values.SelectMany(x => x))
|
||
{
|
||
if (codigos.Add(parada.Codigo))
|
||
resultado.Add(parada);
|
||
}
|
||
|
||
foreach (var parada in _paradasItinerarioVisiblesPorId.Values.SelectMany(x => x))
|
||
{
|
||
if (codigos.Add(parada.Codigo))
|
||
resultado.Add(parada);
|
||
}
|
||
|
||
return resultado;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Detecta si el click cae sobre una parada destacada de itinerario o solución cuando la capa general está oculta.
|
||
/// </summary>
|
||
private async Task<Parada?> DetectarParadaDestacadaHitAsync(double clickLat, double clickLon)
|
||
{
|
||
var parada = ObtenerParadasDestacadasVisibles()
|
||
.Select(p => new
|
||
{
|
||
Parada = p,
|
||
Distancia = CalcularDistanciaMetros(clickLat, clickLon, p.Latitud, p.Longitud)
|
||
})
|
||
.Where(x => x.Distancia <= 40)
|
||
.OrderBy(x => x.Distancia)
|
||
.Select(x => x.Parada)
|
||
.FirstOrDefault();
|
||
|
||
if (parada is null)
|
||
return null;
|
||
|
||
var hit = await JS.InvokeAsync<bool>(
|
||
"rt.isClickInsideCirclePx",
|
||
clickLat, clickLon,
|
||
parada.Latitud, parada.Longitud,
|
||
10);
|
||
|
||
return hit ? parada : null;
|
||
}
|
||
|
||
|
||
|
||
/// <summary>
|
||
/// Gestiona por pixel.
|
||
/// </summary>
|
||
private static double MetrosPorPixel(double lat, int zoom)
|
||
{
|
||
// WebMercator: m/px = 156543.03392 * cos(lat) / 2^zoom
|
||
return 156543.03392 * Math.Cos(lat * Math.PI / 180.0) / Math.Pow(2, zoom);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Gestiona clic parada metros.
|
||
/// </summary>
|
||
private async Task<double> RadioClickParadaMetrosAsync(double lat)
|
||
{
|
||
int zoom = 14;
|
||
try { zoom = await JS.InvokeAsync<int>("rt.getZoom"); } catch { }
|
||
|
||
const double TOL_PX = 18; // 🎯 16-22 suele perfecto
|
||
return TOL_PX * MetrosPorPixel(lat, zoom);
|
||
}
|
||
|
||
|
||
// ✅ Rango de líneas
|
||
private bool _activarRangoLineas = false;
|
||
private int _lineaMin = 1;
|
||
private int _lineaMax = 200;
|
||
private readonly HashSet<int> _lineasPermitidas = new();
|
||
|
||
|
||
|
||
/// <summary>
|
||
/// Alterna linea completa.
|
||
/// </summary>
|
||
private async Task ToggleLineaCompletaAsync(DetalleTramoRuta tramo)
|
||
{
|
||
if (tramo?.VarianteBus is null) return;
|
||
|
||
var id = $"full_{tramo.VarianteBus.Linea.Codigo}_{tramo.VarianteBus.CodigoItinerario}_{tramo.VarianteBus.Sentido?.Codigo}_{tramo.VarianteBus.Variante.Codigo}";
|
||
|
||
// Si está activa => QUITAR
|
||
if (_previewLineasActivas.Contains(id))
|
||
{
|
||
await JS.InvokeVoidAsync("rt.drawPreviewLine", id, new List<double[]>(), new { });
|
||
await JS.InvokeVoidAsync("rt.toggleItStops", id, new List<object>(), new { });
|
||
_previewLineasActivas.Remove(id);
|
||
return;
|
||
|
||
}
|
||
|
||
// Si no está activa => PINTAR
|
||
var full = ConstruirGeometriaCompletaVariante(tramo.VarianteBus);
|
||
if (full is null) return;
|
||
|
||
var color = ColorCssDesdeVariante(tramo.VarianteBus);
|
||
var dash = string.Equals(
|
||
NormalizarSentidoCodigo(tramo.VarianteBus.Sentido?.Codigo),
|
||
"V",
|
||
StringComparison.OrdinalIgnoreCase)
|
||
? DashArrayBusVuelta
|
||
: null;
|
||
|
||
await JS.InvokeVoidAsync("rt.drawPreviewLine", id, LineaToLatLng(full), new
|
||
{
|
||
color,
|
||
weight = 7,
|
||
opacity = 0.95,
|
||
dashArray = dash,
|
||
arrowEnd = true,
|
||
arrowBackPx = 26
|
||
});
|
||
|
||
await JS.InvokeVoidAsync("rt.toggleItStops", id, BuildStopsForVariante(tramo.VarianteBus), new
|
||
{
|
||
radius = 7,
|
||
color = "#0f172a",
|
||
fillColor = color,
|
||
weight = 2,
|
||
opacity = 1.0,
|
||
fillOpacity = 1.0
|
||
});
|
||
|
||
_previewLineasActivas.Add(id);
|
||
|
||
}
|
||
|
||
|
||
|
||
/// <summary>
|
||
/// Cuenta paradas recorridas.
|
||
/// </summary>
|
||
private int ContarParadasRecorridas(TramoBus tramo)
|
||
{
|
||
// Nº de paradas “pasadas” excluyendo la de inicio.
|
||
// Ej: inicio=2 fin=5 => 3 paradas (3,4,5)
|
||
return Math.Max(0, Math.Abs(tramo.IndiceFin - tramo.IndiceInicio));
|
||
}
|
||
|
||
|
||
/// <summary>
|
||
/// Gestiona sin decimal.
|
||
/// </summary>
|
||
private static string CodigoSinDecimal(string? codigo)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(codigo)) return "";
|
||
var i = codigo.IndexOf('.');
|
||
return (i > 0) ? codigo.Substring(0, i) : codigo;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Gestiona parada formateado.
|
||
/// </summary>
|
||
private static string CodigoParadaFormateado(string? codigo)
|
||
{
|
||
var c = CodigoSinDecimal(codigo);
|
||
if (string.IsNullOrWhiteSpace(c)) return "";
|
||
return $"P-{c}";
|
||
}
|
||
|
||
/// <summary>
|
||
/// Normaliza codigo parada.
|
||
/// </summary>
|
||
private static string NormalizarCodigoParada(string? s)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(s)) return "";
|
||
s = s.Trim();
|
||
s = s.Replace("P-", "", StringComparison.OrdinalIgnoreCase);
|
||
return CodigoSinDecimal(s);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Gestiona palabras clave.
|
||
/// </summary>
|
||
private static string CapitalizarPalabrasClave(string s)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(s)) return s;
|
||
|
||
return s
|
||
.Replace("parada", "Parada", StringComparison.OrdinalIgnoreCase)
|
||
.Replace("paradas", "Paradas", StringComparison.OrdinalIgnoreCase)
|
||
.Replace("línea", "Línea", StringComparison.OrdinalIgnoreCase)
|
||
.Replace("linea", "Línea", StringComparison.OrdinalIgnoreCase)
|
||
.Replace("líneas", "Líneas", StringComparison.OrdinalIgnoreCase)
|
||
.Replace("lineas", "Líneas", StringComparison.OrdinalIgnoreCase);
|
||
}
|
||
|
||
/// <summary>
|
||
///
|
||
/// </summary>
|
||
private static string Q(double v) => Math.Round(v, 5).ToString("0.00000", CultureInfo.InvariantCulture);
|
||
|
||
/// <summary>
|
||
/// Gestiona clave.
|
||
/// </summary>
|
||
private static string WalkKey(string provider, double lat1, double lon1, double lat2, double lon2)
|
||
=> $"{provider}|{Q(lat1)},{Q(lon1)}|{Q(lat2)},{Q(lon2)}";
|
||
|
||
/// <summary>
|
||
/// Gestiona to lat lng.
|
||
/// </summary>
|
||
private static List<double[]> LineaToLatLng(double[,] linea)
|
||
{
|
||
var res = new List<double[]>(linea.GetLength(0));
|
||
for (int i = 0; i < linea.GetLength(0); i++)
|
||
res.Add(new[] { linea[i, 0], linea[i, 1] });
|
||
return res;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Devuelve el primer punto de una linea.
|
||
/// </summary>
|
||
private static double[]? ObtenerPrimerPunto(double[,]? linea)
|
||
{
|
||
if (linea is null || linea.GetLength(0) == 0)
|
||
return null;
|
||
|
||
return new[] { linea[0, 0], linea[0, 1] };
|
||
}
|
||
|
||
/// <summary>
|
||
/// Devuelve el ultimo punto de una linea.
|
||
/// </summary>
|
||
private static double[]? ObtenerUltimoPunto(double[,]? linea)
|
||
{
|
||
if (linea is null || linea.GetLength(0) == 0)
|
||
return null;
|
||
|
||
var ultimo = linea.GetLength(0) - 1;
|
||
return new[] { linea[ultimo, 0], linea[ultimo, 1] };
|
||
}
|
||
|
||
/// <summary>
|
||
/// Sobrescribe el primer punto de una linea.
|
||
/// </summary>
|
||
private static void EstablecerPrimerPunto(double[,]? linea, double[]? punto)
|
||
{
|
||
if (linea is null || punto is null || linea.GetLength(0) == 0)
|
||
return;
|
||
|
||
linea[0, 0] = punto[0];
|
||
linea[0, 1] = punto[1];
|
||
}
|
||
|
||
/// <summary>
|
||
/// Obtiene la distancia entre dos puntos geograficos.
|
||
/// </summary>
|
||
private static double CalcularDistanciaEntrePuntos(double[]? origen, double[]? destino)
|
||
{
|
||
if (origen is null || destino is null || origen.Length < 2 || destino.Length < 2)
|
||
return 0;
|
||
|
||
return CalcularDistanciaMetros(origen[0], origen[1], destino[0], destino[1]);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Invalida cualquier pintado de ruta anterior y devuelve la nueva versión activa.
|
||
/// </summary>
|
||
private int IniciarNuevaVersionPintadoRuta()
|
||
=> Interlocked.Increment(ref _versionPintadoRuta);
|
||
|
||
/// <summary>
|
||
/// Lanza cancelación si el pintado ya no es el activo.
|
||
/// </summary>
|
||
private void ThrowIfPintadoObsoleto(int versionPintado)
|
||
{
|
||
if (versionPintado != Volatile.Read(ref _versionPintadoRuta))
|
||
throw new OperationCanceledException();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Resuelve la parada fisica mas coherente para un codigo y una referencia espacial.
|
||
/// </summary>
|
||
private Parada ResolverParadaFisica(
|
||
string? codigoParada,
|
||
double[]? puntoReferencia = null,
|
||
Parada? paradaPreferida = null)
|
||
{
|
||
if (!string.IsNullOrWhiteSpace(codigoParada))
|
||
{
|
||
var candidatas = RedBusServicio.Paradas
|
||
.Where(parada => parada.Codigo.Equals(codigoParada, StringComparison.OrdinalIgnoreCase))
|
||
.ToList();
|
||
|
||
if (candidatas.Count == 1)
|
||
return candidatas[0];
|
||
|
||
if (candidatas.Count > 1)
|
||
{
|
||
if (paradaPreferida is not null &&
|
||
paradaPreferida.Codigo.Equals(codigoParada, StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
var coincidenciaExacta = candidatas.FirstOrDefault(parada =>
|
||
Math.Abs(parada.Latitud - paradaPreferida.Latitud) < 1e-9 &&
|
||
Math.Abs(parada.Longitud - paradaPreferida.Longitud) < 1e-9);
|
||
|
||
if (coincidenciaExacta is not null)
|
||
return coincidenciaExacta;
|
||
}
|
||
|
||
if (puntoReferencia is not null)
|
||
{
|
||
return candidatas
|
||
.OrderBy(parada => CalcularDistanciaMetros(
|
||
parada.Latitud,
|
||
parada.Longitud,
|
||
puntoReferencia[0],
|
||
puntoReferencia[1]))
|
||
.First();
|
||
}
|
||
|
||
if (paradaPreferida is not null &&
|
||
paradaPreferida.Codigo.Equals(codigoParada, StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
return paradaPreferida;
|
||
}
|
||
|
||
return candidatas[0];
|
||
}
|
||
}
|
||
|
||
return paradaPreferida ?? new Parada();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Crea una linea minima entre dos puntos.
|
||
/// </summary>
|
||
private static double[,] ConstruirLineaDirecta(double[] origen, double[] destino)
|
||
{
|
||
return new[,]
|
||
{
|
||
{ origen[0], origen[1] },
|
||
{ destino[0], destino[1] }
|
||
};
|
||
}
|
||
|
||
/// <summary>
|
||
/// Limpia rutas js.
|
||
/// </summary>
|
||
private async Task ClearRoutesJsAsync()
|
||
{
|
||
LimpiarParadasRutaVisibles();
|
||
try { await JS.InvokeVoidAsync("rt.clearRoutes"); } catch { }
|
||
}
|
||
|
||
/// <summary>
|
||
/// Limpia las paradas destacadas de la solución seleccionada.
|
||
/// </summary>
|
||
private async Task ClearRouteStopsJsAsync()
|
||
{
|
||
try { await JS.InvokeVoidAsync("rt.clearRouteStops"); } catch { }
|
||
}
|
||
|
||
/// <summary>
|
||
/// Sincroniza los marcadores arrastrables de origen y destino con el estado actual.
|
||
/// </summary>
|
||
private async Task SincronizarMarcadoresOrigenDestinoJsAsync()
|
||
{
|
||
if (_coordenadasOrigen is not null)
|
||
{
|
||
try
|
||
{
|
||
await JS.InvokeVoidAsync("rt.setEndpointMarker", "origin", _coordenadasOrigen[0], _coordenadasOrigen[1], new
|
||
{
|
||
color = "#22c55e",
|
||
title = "Origen",
|
||
size = 18
|
||
});
|
||
}
|
||
catch { }
|
||
}
|
||
else
|
||
{
|
||
try { await JS.InvokeVoidAsync("rt.clearEndpointMarkers", "origin"); } catch { }
|
||
}
|
||
|
||
if (_coordenadasDestino is not null)
|
||
{
|
||
try
|
||
{
|
||
await JS.InvokeVoidAsync("rt.setEndpointMarker", "destination", _coordenadasDestino[0], _coordenadasDestino[1], new
|
||
{
|
||
color = "#ef4444",
|
||
title = "Destino",
|
||
size = 18
|
||
});
|
||
}
|
||
catch { }
|
||
}
|
||
else
|
||
{
|
||
try { await JS.InvokeVoidAsync("rt.clearEndpointMarkers", "destination"); } catch { }
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Limpia los marcadores arrastrables de origen y destino.
|
||
/// </summary>
|
||
private async Task ClearEndpointMarkersJsAsync()
|
||
{
|
||
try { await JS.InvokeVoidAsync("rt.clearEndpointMarkers"); } catch { }
|
||
}
|
||
|
||
/// <summary>
|
||
/// Dibuja ruta js.
|
||
/// </summary>
|
||
private async Task DrawRouteJsAsync(
|
||
string id,
|
||
double[,] linea,
|
||
string color,
|
||
int weight,
|
||
double opacity,
|
||
string? dashArray = null,
|
||
bool arrowEnd = false,
|
||
string? lineCap = null,
|
||
IReadOnlyList<object>? elevationSegments = null)
|
||
{
|
||
try
|
||
{
|
||
await JS.InvokeVoidAsync(
|
||
"rt.drawRoute",
|
||
id,
|
||
LineaToLatLng(linea),
|
||
new { color, weight, opacity, dashArray, arrowEnd, lineCap, elevationSegments }
|
||
);
|
||
}
|
||
catch { }
|
||
}
|
||
|
||
/// <summary>
|
||
/// Ajusta rutas js.
|
||
/// </summary>
|
||
private async Task FitRoutesJsAsync()
|
||
{
|
||
try { await JS.InvokeVoidAsync("rt.fitRoutes"); } catch { }
|
||
}
|
||
|
||
/// <summary>
|
||
/// Dibuja las paradas destacadas de un tramo de la solución seleccionada.
|
||
/// </summary>
|
||
private async Task DrawRouteStopsJsAsync(
|
||
string id,
|
||
IReadOnlyList<object> stops,
|
||
string fillColor,
|
||
double radius = 8,
|
||
string color = "#0f172a",
|
||
int weight = 2,
|
||
double opacity = 1.0,
|
||
double fillOpacity = 1.0)
|
||
{
|
||
try
|
||
{
|
||
await JS.InvokeVoidAsync(
|
||
"rt.drawRouteStops",
|
||
id,
|
||
stops,
|
||
new { radius, color, fillColor, weight, opacity, fillOpacity });
|
||
}
|
||
catch { }
|
||
}
|
||
|
||
/// <summary>
|
||
/// Formatea distancia.
|
||
/// </summary>
|
||
private string FormatearDistancia(double d)
|
||
{
|
||
if (d <= 0) return "-";
|
||
if (d < 950) return $"{d:0.00} m";
|
||
return $"{(d / 1000.0):0.00} km";
|
||
}
|
||
|
||
/// <summary>
|
||
/// Formatea distancias para las tarjetas de alternativas con km a 3 decimales y metros truncados.
|
||
/// </summary>
|
||
private string FormatearDistanciaTarjeta(double d)
|
||
{
|
||
if (d <= 0) return "-";
|
||
if (d < 1000) return $"{Math.Truncate(d):0} m";
|
||
return $"{(d / 1000.0):0.000} km";
|
||
}
|
||
|
||
/// <summary>
|
||
/// Formatea distancias para la ventana de detalle de ruta truncando los metros visibles.
|
||
/// </summary>
|
||
private string FormatearDistanciaDetalleRuta(double d)
|
||
{
|
||
if (d <= 0) return "-";
|
||
if (d < 1000) return $"{Math.Truncate(d):0} m";
|
||
return $"{(d / 1000.0):0.00} km";
|
||
}
|
||
|
||
/// <summary>
|
||
/// Construye la lÃnea técnica XYZ para el origen.
|
||
/// </summary>
|
||
private string FormatearResumenXYZOrigen()
|
||
{
|
||
if (_coordenadasOrigen is null)
|
||
return string.Empty;
|
||
|
||
var lon = _coordenadasOrigen[1].ToString("0.000000", CultureInfo.InvariantCulture);
|
||
var lat = _coordenadasOrigen[0].ToString("0.000000", CultureInfo.InvariantCulture);
|
||
var alt = _altitudOrigenResumenRuta is null
|
||
? "-"
|
||
: $"{_altitudOrigenResumenRuta.Value.ToString("0.0", CultureInfo.InvariantCulture)} m";
|
||
|
||
return $"O: Y.Lat = {lat} X.Lon = {lon} Z = {alt}";
|
||
}
|
||
|
||
/// <summary>
|
||
/// Construye la lÃnea técnica XYZ para el destino.
|
||
/// </summary>
|
||
private string FormatearResumenXYZDestino()
|
||
{
|
||
if (_coordenadasDestino is null)
|
||
return string.Empty;
|
||
|
||
var lon = _coordenadasDestino[1].ToString("0.000000", CultureInfo.InvariantCulture);
|
||
var lat = _coordenadasDestino[0].ToString("0.000000", CultureInfo.InvariantCulture);
|
||
var alt = _altitudDestinoResumenRuta is null
|
||
? "-"
|
||
: $"{_altitudDestinoResumenRuta.Value.ToString("0.0", CultureInfo.InvariantCulture)} m";
|
||
|
||
return $"D: Y.Lat = {lat} X.Lon = {lon} Z = {alt}";
|
||
}
|
||
|
||
private string FormatearResumenCallejeroOrigen()
|
||
{
|
||
if (string.IsNullOrWhiteSpace(_callejeroOrigenResumenRuta))
|
||
return string.Empty;
|
||
|
||
return $"O: {_callejeroOrigenResumenRuta}";
|
||
}
|
||
|
||
private string FormatearResumenCallejeroDestino()
|
||
{
|
||
if (string.IsNullOrWhiteSpace(_callejeroDestinoResumenRuta))
|
||
return string.Empty;
|
||
|
||
return $"D: {_callejeroDestinoResumenRuta}";
|
||
}
|
||
|
||
private static string FormatearLugarCallejeroResumen(LugarGeocodificadoCercano? cercano)
|
||
{
|
||
if (cercano is null)
|
||
return string.Empty;
|
||
|
||
var nombre = LimpiarTextoCallejeroResumen(cercano.Lugar.Nombre);
|
||
if (string.IsNullOrWhiteSpace(nombre))
|
||
nombre = LimpiarTextoCallejeroResumen(cercano.Lugar.Subtitulo);
|
||
|
||
if (string.IsNullOrWhiteSpace(nombre))
|
||
return string.Empty;
|
||
|
||
var distancia = FormatearDistanciaCallejeroResumen(cercano.DistanciaMetros);
|
||
if (string.IsNullOrWhiteSpace(distancia))
|
||
return nombre;
|
||
|
||
return $"{nombre} ({distancia})";
|
||
}
|
||
|
||
private static string LimpiarTextoCallejeroResumen(string texto)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(texto))
|
||
return string.Empty;
|
||
|
||
texto = texto
|
||
.Replace(" · ", " - ", StringComparison.Ordinal)
|
||
.Replace("·", "-", StringComparison.Ordinal)
|
||
.Replace("·", "-", StringComparison.Ordinal);
|
||
|
||
return Regex.Replace(texto, @"\s+", " ").Trim();
|
||
}
|
||
|
||
private static string FormatearDistanciaCallejeroResumen(double distanciaMetros)
|
||
{
|
||
if (!double.IsFinite(distanciaMetros) || distanciaMetros < 0)
|
||
return string.Empty;
|
||
|
||
if (distanciaMetros < 1000)
|
||
return $"{Math.Round(distanciaMetros):0} m";
|
||
|
||
return $"{(distanciaMetros / 1000.0).ToString("0.000", CultureInfo.InvariantCulture)} km";
|
||
}
|
||
|
||
/// <summary>
|
||
/// Formatea duracion.
|
||
/// </summary>
|
||
private string FormatearDuracion(double segundos)
|
||
{
|
||
if (segundos <= 0) return "-";
|
||
|
||
int total = (int)Math.Round(segundos);
|
||
var ts = TimeSpan.FromSeconds(total);
|
||
|
||
if (ts.TotalHours >= 1)
|
||
return $"{(int)ts.TotalHours}:{ts.Minutes:D2}:{ts.Seconds:D2}";
|
||
|
||
return $"{ts.Minutes:D2}:{ts.Seconds:D2}";
|
||
}
|
||
|
||
private string FormatearTiempoCalculoRuta()
|
||
{
|
||
if (_tiempoUltimoCalculoRuta is not { } tiempo)
|
||
return string.Empty;
|
||
|
||
return $"{Math.Max(0, tiempo.TotalSeconds).ToString("0.00", CultureInfo.InvariantCulture)} s";
|
||
}
|
||
|
||
/// <summary>
|
||
/// Construye los parametros actuales que afectan a la penalizacion peatonal.
|
||
/// </summary>
|
||
private ParametrosPlanificadorRuta CrearParametrosPenalizacionActuales()
|
||
{
|
||
return new ParametrosPlanificadorRuta
|
||
{
|
||
VelocidadPieKmH = _velocidadPieKmH,
|
||
FactorPenalizacionDistanciaPie = _factorPenalizacionDistanciaPie,
|
||
ParametroPenalizacionDistanciaPieMetros = _parametroPenalizacionDistanciaPieMetros
|
||
};
|
||
}
|
||
|
||
/// <summary>
|
||
/// Calcula la distancia peatonal corregida total de una alternativa.
|
||
/// </summary>
|
||
private double CalcularDistanciaCorregidaAlternativa(AlternativaRuta? alternativa)
|
||
{
|
||
if (alternativa is null)
|
||
return 0;
|
||
|
||
var parametros = CrearParametrosPenalizacionActuales();
|
||
|
||
if (alternativa.EsSoloAPie)
|
||
return UtilidadesTransitoDbus.CalcularDistanciaPieCorregidaMetros(alternativa.DistanciaSoloAPie, parametros);
|
||
|
||
return UtilidadesTransitoDbus.CalcularDistanciaPieCorregidaMetros(alternativa.DistanciaPieInicioAprox, parametros)
|
||
+ UtilidadesTransitoDbus.CalcularDistanciaPieCorregidaMetros(alternativa.DistanciaPieTransbordosAprox, parametros)
|
||
+ UtilidadesTransitoDbus.CalcularDistanciaPieCorregidaMetros(alternativa.DistanciaPieFinAprox, parametros);
|
||
}
|
||
|
||
private double CalcularCosteAndarEquilibradoSegundos(AlternativaRuta? alternativa)
|
||
{
|
||
if (alternativa is null)
|
||
return double.PositiveInfinity;
|
||
|
||
var distanciaCorregida = CalcularDistanciaCorregidaAlternativa(alternativa);
|
||
if (!double.IsFinite(distanciaCorregida) || distanciaCorregida < 0)
|
||
return double.PositiveInfinity;
|
||
|
||
return EstimarDuracionPieSegundos(distanciaCorregida);
|
||
}
|
||
|
||
private double CalcularScoreEquilibradoFactor(AlternativaRuta? alternativa)
|
||
{
|
||
if (alternativa is null || alternativa.EsSoloAPie || alternativa.AlternativaBus is null)
|
||
return double.PositiveInfinity;
|
||
|
||
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 string FormatearScoreEquilibradoAlternativa(AlternativaRuta? alternativa)
|
||
{
|
||
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)
|
||
{
|
||
_omitirRegistroHistorialEnSiguienteCalculo = true;
|
||
await CalcularRutaCompletaAsync();
|
||
return;
|
||
}
|
||
|
||
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();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Devuelve el coste penalizado interno de una alternativa en segundos.
|
||
/// </summary>
|
||
private double ObtenerCostePenalizadoAlternativa(AlternativaRuta? alternativa)
|
||
{
|
||
if (alternativa is null)
|
||
return 0;
|
||
|
||
if (alternativa.PuntuacionCoste > 0)
|
||
return alternativa.PuntuacionCoste;
|
||
|
||
var parametros = CrearParametrosPenalizacionActuales();
|
||
var duracionReal = alternativa.DuracionTotalAproxSegundos;
|
||
|
||
if (alternativa.EsSoloAPie)
|
||
{
|
||
return UtilidadesTransitoDbus.CalcularCosteConPenalizacionPieSegundos(
|
||
duracionReal,
|
||
parametros,
|
||
alternativa.DistanciaSoloAPie);
|
||
}
|
||
|
||
return UtilidadesTransitoDbus.CalcularCosteConPenalizacionPieSegundos(
|
||
duracionReal,
|
||
parametros,
|
||
alternativa.DistanciaPieInicioAprox,
|
||
alternativa.DistanciaPieTransbordosAprox,
|
||
alternativa.DistanciaPieFinAprox);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Devuelve un icono visual para el tipo de tramo.
|
||
/// </summary>
|
||
private static string IconoTramoRuta(string? tipo)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(tipo))
|
||
return "\u2022";
|
||
|
||
if (tipo.StartsWith("Bus", StringComparison.OrdinalIgnoreCase))
|
||
return "\U0001F68C";
|
||
|
||
if (tipo.StartsWith("Espera en bus", StringComparison.OrdinalIgnoreCase) ||
|
||
tipo.StartsWith("Continuidad en bus", StringComparison.OrdinalIgnoreCase))
|
||
return "\u21BB";
|
||
|
||
if (tipo.StartsWith("Espera", StringComparison.OrdinalIgnoreCase))
|
||
return "\u231B";
|
||
|
||
if (tipo.StartsWith("A pie", StringComparison.OrdinalIgnoreCase))
|
||
return "\U0001F6B6";
|
||
|
||
return "\U0001F4CD";
|
||
}
|
||
|
||
/// <summary>
|
||
/// Devuelve la etiqueta visible del tramo dentro del detalle, agrupando los elementos que pertenecen al mismo transbordo.
|
||
/// </summary>
|
||
private static string ObtenerTipoVisibleDetalleTramo(IReadOnlyList<DetalleTramoRuta> tramos, int indice)
|
||
{
|
||
if (indice < 0 || indice >= tramos.Count)
|
||
return string.Empty;
|
||
|
||
var tramo = tramos[indice];
|
||
if (EsTramoContinuidadEnBus(tramo))
|
||
return "Continuidad en bus";
|
||
if (EsTramoEsperaEnBus(tramo))
|
||
return "Espera en bus";
|
||
|
||
var transbordosPrevios = ContarTransbordosRealesDetalle(tramos, indice);
|
||
|
||
if (EsTramoBus(tramo))
|
||
return transbordosPrevios > 0 ? $"Bus (Transb.{transbordosPrevios})" : "Bus";
|
||
|
||
var numeroTransbordo = EsTramoEntreBusesConTransbordoReal(tramos, indice)
|
||
? transbordosPrevios + 1
|
||
: transbordosPrevios;
|
||
if (numeroTransbordo <= 0 || !HayBusPosterior(tramos, indice))
|
||
return tramo.Tipo;
|
||
|
||
if (EsTramoEspera(tramo))
|
||
return $"Espera (Transb.{numeroTransbordo})";
|
||
|
||
if (EsTramoPie(tramo))
|
||
return $"A pie (Transb.{numeroTransbordo})";
|
||
|
||
return tramo.Tipo;
|
||
}
|
||
|
||
private static int ContarTransbordosRealesDetalle(IReadOnlyList<DetalleTramoRuta> tramos, int hastaIndiceExclusivo)
|
||
{
|
||
var total = 0;
|
||
InfoVariante? busAnterior = null;
|
||
|
||
for (var i = 0; i < hastaIndiceExclusivo && i < tramos.Count; i++)
|
||
{
|
||
if (!EsTramoBus(tramos[i]) || tramos[i].VarianteBus is null)
|
||
continue;
|
||
|
||
if (busAnterior is not null &&
|
||
UtilidadesTransitoDbus.EsTransbordoReal(busAnterior, tramos[i].VarianteBus))
|
||
{
|
||
total++;
|
||
}
|
||
|
||
busAnterior = tramos[i].VarianteBus;
|
||
}
|
||
|
||
return total;
|
||
}
|
||
|
||
private static bool EsTramoEntreBusesConTransbordoReal(IReadOnlyList<DetalleTramoRuta> tramos, int indice)
|
||
{
|
||
var anterior = BuscarVarianteBusAnterior(tramos, indice);
|
||
var siguiente = BuscarVarianteBusPosterior(tramos, indice);
|
||
return UtilidadesTransitoDbus.EsTransbordoReal(anterior, siguiente);
|
||
}
|
||
|
||
private static InfoVariante? BuscarVarianteBusAnterior(IReadOnlyList<DetalleTramoRuta> tramos, int indice)
|
||
{
|
||
for (var i = indice - 1; i >= 0; i--)
|
||
{
|
||
if (EsTramoBus(tramos[i]))
|
||
return tramos[i].VarianteBus;
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
private static InfoVariante? BuscarVarianteBusPosterior(IReadOnlyList<DetalleTramoRuta> tramos, int indice)
|
||
{
|
||
for (var i = indice + 1; i < tramos.Count; i++)
|
||
{
|
||
if (EsTramoBus(tramos[i]))
|
||
return tramos[i].VarianteBus;
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Indica si el tramo representa un trayecto en bus.
|
||
/// </summary>
|
||
private static bool EsTramoBus(DetalleTramoRuta tramo)
|
||
=> tramo.Tipo.Equals("Bus", StringComparison.OrdinalIgnoreCase);
|
||
|
||
/// <summary>
|
||
/// Indica si el tramo representa una espera.
|
||
/// </summary>
|
||
private static bool EsTramoEspera(DetalleTramoRuta tramo)
|
||
=> tramo.Tipo.StartsWith("Espera", StringComparison.OrdinalIgnoreCase);
|
||
|
||
/// <summary>
|
||
/// Indica si el tramo representa una espera dentro del bus por continuidad circular.
|
||
/// </summary>
|
||
private static bool EsTramoEsperaEnBus(DetalleTramoRuta tramo)
|
||
=> tramo.Tipo.StartsWith("Espera en bus", StringComparison.OrdinalIgnoreCase);
|
||
|
||
private static bool EsTramoContinuidadEnBus(DetalleTramoRuta tramo)
|
||
=> tramo.Tipo.StartsWith("Continuidad en bus", StringComparison.OrdinalIgnoreCase);
|
||
|
||
/// <summary>
|
||
/// Indica si el tramo representa una caminata.
|
||
/// </summary>
|
||
private static bool EsTramoPie(DetalleTramoRuta tramo)
|
||
=> tramo.Tipo.StartsWith("A pie", StringComparison.OrdinalIgnoreCase);
|
||
|
||
/// <summary>
|
||
/// Comprueba si después del tramo actual todavía queda otro bus por coger.
|
||
/// </summary>
|
||
private static bool HayBusPosterior(IReadOnlyList<DetalleTramoRuta> tramos, int indiceActual)
|
||
{
|
||
for (var i = indiceActual + 1; i < tramos.Count; i++)
|
||
{
|
||
if (EsTramoBus(tramos[i]))
|
||
return true;
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
private string FormatearDuracionBloqueBusDetalle(IReadOnlyList<DetalleTramoRuta> tramos)
|
||
{
|
||
var duracion = CalcularDuracionBloqueBusSegundos(tramos);
|
||
return duracion is > 0 ? FormatearDuracionHorasMinutosReloj(duracion.Value) : "-";
|
||
}
|
||
|
||
private string FormatearDuracionBloqueBusResumen(IReadOnlyList<DetalleTramoRuta> tramos)
|
||
{
|
||
var duracion = CalcularDuracionBloqueBusSegundos(tramos);
|
||
return duracion is > 0 ? FormatearDuracionHorasMinutosTexto(duracion.Value) : "-";
|
||
}
|
||
|
||
private static string FormatearDuracionHorasMinutosReloj(double segundos)
|
||
{
|
||
if (!double.IsFinite(segundos) || segundos <= 0)
|
||
return "-";
|
||
|
||
var totalMinutos = Math.Max(1, (int)Math.Round(segundos / 60.0));
|
||
var horas = totalMinutos / 60;
|
||
var minutos = totalMinutos % 60;
|
||
|
||
if (horas <= 0)
|
||
return $"00:{minutos:D2}";
|
||
|
||
return minutos > 0
|
||
? $"{horas}:{minutos:D2}"
|
||
: $"{horas}:00";
|
||
}
|
||
|
||
private static string FormatearDuracionHorasMinutosTexto(double segundos)
|
||
{
|
||
if (!double.IsFinite(segundos) || segundos <= 0)
|
||
return "-";
|
||
|
||
var totalMinutos = Math.Max(1, (int)Math.Round(segundos / 60.0));
|
||
var horas = totalMinutos / 60;
|
||
var minutos = totalMinutos % 60;
|
||
|
||
if (horas <= 0)
|
||
return $"{minutos} min";
|
||
|
||
return minutos > 0
|
||
? $"{horas} h : {minutos:D2} m"
|
||
: $"{horas} h";
|
||
}
|
||
|
||
private static double? CalcularDuracionBloqueBusSegundos(IReadOnlyList<DetalleTramoRuta> tramos)
|
||
{
|
||
var primerBus = -1;
|
||
var ultimoBus = -1;
|
||
|
||
for (var i = 0; i < tramos.Count; i++)
|
||
{
|
||
if (!EsTramoBus(tramos[i]))
|
||
continue;
|
||
|
||
if (primerBus < 0)
|
||
primerBus = i;
|
||
|
||
ultimoBus = i;
|
||
}
|
||
|
||
if (primerBus < 0 || ultimoBus < primerBus)
|
||
return null;
|
||
|
||
double total = 0;
|
||
for (var i = primerBus; i <= ultimoBus; i++)
|
||
{
|
||
if (tramos[i].DuracionSegundos is not { } segundos ||
|
||
!double.IsFinite(segundos) ||
|
||
segundos <= 0)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
total += segundos;
|
||
}
|
||
|
||
return total;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Formatea la etiqueta visible de una alternativa.
|
||
/// </summary>
|
||
private string FormatearEtiquetaAlternativaTarjeta(AlternativaRuta alternativa, int indice)
|
||
{
|
||
return !string.IsNullOrWhiteSpace(alternativa.Etiqueta)
|
||
? alternativa.Etiqueta
|
||
: $"Opcion {indice + 1}";
|
||
}
|
||
|
||
|
||
/// <summary>
|
||
/// Estima duracion pie segundos.
|
||
/// </summary>
|
||
private double EstimarDuracionPieSegundos(double distanciaMetros)
|
||
{
|
||
double v = _velocidadPieKmH * 1000.0 / 3600.0;
|
||
return distanciaMetros / Math.Max(0.1, v);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Estima duracion bus segundos.
|
||
/// </summary>
|
||
private double EstimarDuracionBusSegundos(double distanciaMetros)
|
||
{
|
||
double v = _velocidadBusKmH * 1000.0 / 3600.0;
|
||
return distanciaMetros / Math.Max(0.1, v);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Restablece estado ruta.
|
||
/// </summary>
|
||
private void ResetEstadoRuta(bool limpiarOpciones = false, bool limpiarMensaje = true)
|
||
{
|
||
if (limpiarMensaje)
|
||
_mensajeRuta = string.Empty;
|
||
_hayRutaValida = false;
|
||
_tramosRuta.Clear();
|
||
_duracionTotalSegundos = 0;
|
||
_altitudOrigenResumenRuta = null;
|
||
_altitudDestinoResumenRuta = null;
|
||
_callejeroOrigenResumenRuta = null;
|
||
_callejeroDestinoResumenRuta = null;
|
||
|
||
_paradaOrigenSeleccionada = null;
|
||
_paradaDestinoSeleccionada = null;
|
||
_primerTransbordoseleccionado = null;
|
||
_numeroTransbordos = 0;
|
||
_distanciaAPieInicio = _distanciaAPieFin = _distanciaEnBus = 0;
|
||
_paradaClicada = null;
|
||
|
||
if (limpiarOpciones)
|
||
{
|
||
_opcionesRuta.Clear();
|
||
_indiceAlternativaSeleccionada = 0;
|
||
_tiempoUltimoCalculoRuta = null;
|
||
}
|
||
}
|
||
|
||
// =========================================================
|
||
// MODELOS internos
|
||
// =========================================================
|
||
private ElementoItinerario? _itinerarioClicado;
|
||
private string? _itinerarioClicadoSubId; // id exacto pulsado (por si es subId)
|
||
|
||
private sealed class CandidataParada
|
||
{
|
||
public Parada Parada { get; set; } = default!;
|
||
public double DistEucl { get; set; }
|
||
public double DistWalk { get; set; }
|
||
public double TiempoWalk { get; set; }
|
||
}
|
||
private bool _zoomLimitsAplicados = false;
|
||
|
||
// =========================================================
|
||
// Ciclo de vida
|
||
// =========================================================
|
||
}
|