Files
RutasDBUS/RutasDBUS/Components/Pages/PlanificadorRutas.Estado.cs

1614 lines
58 KiB
C#
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
// =========================================================
}