Files
RutasDBUS/RutasDBUS/Components/Pages/PlanificadorRutas.Itinerarios.cs
Pedro 6145fb635a - andar equilibraod separado del calculo
- comienzo de penalizacoin en 200m
- centrado de origen
2026-07-29 14:45:59 +02:00

1509 lines
52 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.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
{
private enum ProveedorCaminata
{
Osrm = 1, // v1
Valhalla = 2 // v2
}
private ProveedorCaminata _walkProvider = ProveedorCaminata.Valhalla; // por defecto v2
private bool _mostrarBuscadorMapa = false;
/// <summary>
/// Alterna buscador mapa.
/// </summary>
private void ToggleBuscadorMapa()
{
_mostrarBuscadorMapa = !_mostrarBuscadorMapa;
_panelRutasMovilContraido = _mostrarBuscadorMapa;
}
/// <summary>
/// Quita todos itinerarios.
/// </summary>
private async Task QuitarTodosItinerarios()
{
await CerrarTodasLasPreviewsAsync();
// cerrar popup de itinerario al ocultar todo
_itinerarioClicado = null;
_itinerarioClicadoSubId = null;
StateHasChanged();
}
/// <summary>
/// Actualiza colores itinerarios activos.
/// </summary>
private async Task RefrescarColoresItinerariosActivosAsync()
{
// Claves activas (26.12-I, 26.12-V, ...)
var clavesActivas = _itPreviewIdsPorClave
.Where(kvp => kvp.Value is not null && kvp.Value.Count > 0)
.Select(kvp => kvp.Key)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
// Quitamos todos los previews de itinerarios
await QuitarTodosItinerarios();
// Repintamos exactamente los sentidos que estaban activos
foreach (var it in _itItems)
{
if (it?.Variantes is null || it.Variantes.Count == 0) continue;
foreach (var v in it.Variantes)
{
var clave = ItClave(v);
if (!clavesActivas.Contains(clave)) continue;
// Pintar SOLO este sentido (como item temporal de 1 variante)
var temp = new ElementoItinerario
{
Id = it.Id,
Text = it.Text,
Variantes = new List<InfoVariante> { v }
};
await PintarItItemAsync(temp);
}
}
StateHasChanged();
}
/// <summary>
/// Gestiona cambio modo color itinerarios.
/// </summary>
private async Task OnCambioModoColorItinerarios(ChangeEventArgs e)
{
_usarColorRealLineaEnItinerarios = e?.Value is bool b && b;
await RefrescarColoresItinerariosActivosAsync();
await PersistirConfiguracionAsync();
}
// true = usar color real del JSON (comportamiento actual)
// false = usar color secuencial por itinerario
private bool _usarColorRealLineaEnItinerarios = false;
/// <summary>
/// Gestiona color itinerario vista previa.
/// </summary>
private string ColorItinerarioPreview(InfoVariante v, string? itItemId = null)
{
// ✅ Modo actual: color real desde JSON
if (_usarColorRealLineaEnItinerarios)
return ColorCssDesdeVariante(v);
// ✅ Modo alternativo: color "secuencial/estable" desde paleta
// Clave estable por itinerario (línea + itinerario + sentido)
// así cada itinerario mantiene su color aunque repintes
var key = itItemId;
if (string.IsNullOrWhiteSpace(key))
{
var linea = v.Linea?.Codigo?.ToString() ?? "?";
var itin = v.CodigoItinerario.ToString(CultureInfo.InvariantCulture);
var sentido = NormalizarSentidoCodigo(v.Sentido?.Codigo);
key = $"{linea}.{itin}-{sentido}";
}
return ColorForKey(key);
}
private int _selectItKey = 0;
/// <summary>
/// Obtiene variante real clicada.
/// </summary>
private InfoVariante? GetVarianteRealClicada()
{
if (_itinerarioClicado is null || _itinerarioClicado.Variantes is null || _itinerarioClicado.Variantes.Count == 0)
return null;
// Si solo hay una variante, es esa
if (_itinerarioClicado.Variantes.Count == 1)
return _itinerarioClicado.Variantes[0];
// Si hay varias (caso IV), mirar el subId real pulsado (..._I / ..._V)
var subId = _itinerarioClicadoSubId ?? "";
string? sentidoBuscado = null;
if (subId.EndsWith("_I", StringComparison.OrdinalIgnoreCase))
sentidoBuscado = "I";
else if (subId.EndsWith("_V", StringComparison.OrdinalIgnoreCase))
sentidoBuscado = "V";
if (sentidoBuscado is not null)
{
var v = _itinerarioClicado.Variantes.FirstOrDefault(x =>
string.Equals(NormalizarSentidoCodigo(x.Sentido?.Codigo), sentidoBuscado, StringComparison.OrdinalIgnoreCase));
if (v is not null)
return v;
}
// Fallback defensivo (no debería hacer falta normalmente)
return _itinerarioClicado.Variantes.FirstOrDefault();
}
/// <summary>
/// Calcula distancia variante metros.
/// </summary>
private double CalcularDistanciaVarianteMetros(InfoVariante v)
{
if (v is null) return 0;
var linea = ConstruirGeometriaCompletaVariante(v);
if (linea is null) return 0;
double total = 0;
int n = linea.GetLength(0);
for (int i = 1; i < n; i++)
{
total += CalcularDistanciaMetros(
linea[i - 1, 0], linea[i - 1, 1],
linea[i, 0], linea[i, 1]
);
}
return total;
}
/// <summary>
/// Formatea distancia variante.
/// </summary>
private string FormatearDistanciaVariante(InfoVariante? v)
{
if (v is null) return "-";
var d = CalcularDistanciaVarianteMetros(v);
if (d <= 0) return "-";
return FormatearDistancia(d); // reutiliza tu helper actual
}
/// <summary>
/// Gestiona sincronizar seleccion itinerario con filtro.
/// </summary>
private void SincronizarSeleccionItinerarioConFiltro()
{
var filtrados = _itItemsFiltrados.ToList();
if (filtrados.Count == 0)
{
_itSeleccionadoId = null;
return;
}
// Si el seleccionado actual sigue visible, lo mantenemos
if (!string.IsNullOrWhiteSpace(_itSeleccionadoId) &&
filtrados.Any(x => x.Id.Equals(_itSeleccionadoId, StringComparison.OrdinalIgnoreCase)))
{
return;
}
// Si ya no está en el filtro, seleccionar el primero visible
_itSeleccionadoId = filtrados[0].Id;
}
/// <summary>
/// Pinta it filtro.
/// </summary>
private async Task PintarItFiltro()
{
if (!PuedePintarItinerariosFiltrados)
return;
foreach (var it in _itItemsFiltrados)
{
if (it is null) continue;
await PintarItItemAsync(it); // ✅ pintará solo I/V que falten
}
}
/// <summary>
/// Pinta it elemento.
/// </summary>
private async Task PintarItItemAsync(ElementoItinerario it)
{
if (it is null) return;
for (int i = 0; i < it.Variantes.Count; i++)
{
var v = it.Variantes[i];
if (v is null) continue;
var clave = ItClave(v);
// ✅ Si ESTE sentido ya está pintado, saltar solo este, no todo el item
if (_itPreviewIdsPorClave.TryGetValue(clave, out var set) && set.Count > 0)
continue;
var full = ConstruirGeometriaCompletaVariante(v);
if (full is null) continue;
var sentido = NormalizarSentidoCodigo(v.Sentido?.Codigo); // "I" o "V"
var subId = (it.Variantes.Count == 1) ? it.Id : $"{it.Id}_{sentido}";
var color = ColorItinerarioPreview(v, it.Id);
var dash = (sentido == "V") ? "10 10" : null;
await JS.InvokeVoidAsync("rt.drawPreviewLine", subId, LineaToLatLng(full), new
{
color,
weight = 4,
opacity = 0.90,
dashArray = dash,
arrowEnd = true,
arrowBackPx = 26
});
await JS.InvokeVoidAsync("rt.toggleItStops", subId, BuildStopsForVariante(v), new
{
radius = 7,
color = "#0f172a",
fillColor = color,
weight = 2,
opacity = 1.0,
fillOpacity = 1.0
});
RegistrarPreview(clave, subId);
}
}
/// <summary>
/// Reúne la geometría de todos los sentidos incluidos en un elemento de itinerario.
/// </summary>
private List<double[]> ConstruirCoordenadasItinerario(ElementoItinerario it)
{
var coordenadas = new List<double[]>();
foreach (var variante in it.Variantes)
{
var geometria = ConstruirGeometriaCompletaVariante(variante);
if (geometria is not null)
coordenadas.AddRange(LineaToLatLng(geometria));
}
return coordenadas;
}
private Parada? _paradaPrimerClickPendiente;
// Detecta si el click cae realmente sobre el círculo de una parada (en píxeles)
/// <summary>
/// Gestiona detectar parada hit.
/// </summary>
private async Task<Parada?> DetectarParadaHitAsync(double clickLat, double clickLon)
{
// ✅ Si las paradas están ocultas, no hay hit ni popup
if (!_mostrarParadas)
return null;
var maxDist = await RadioClickParadaMetrosAsync(clickLat);
// Candidatea por distancia (rápido)
var parada = RedBusServicio.BuscarParadaCercanaEnRango(clickLat, clickLon, maxDistMetros: 40);
if (parada is null) return null;
// Validación final: dentro del círculo en pantalla
var hit = await JS.InvokeAsync<bool>(
"rt.isClickInsideCirclePx",
clickLat, clickLon,
parada.Latitud, parada.Longitud,
7 // radio (5) + margen (2)
);
return hit ? parada : null;
}
// Tu popup actual, pero en método reutilizable (click simple)
/// <summary>
/// Muestra popup parada.
/// </summary>
private async Task MostrarPopupParadaAsync(Parada paradaDetectada)
{
var myToken = Interlocked.Increment(ref _paradaPopupToken);
_paradaClicada = null;
_paradaElevG = null;
// limpiar horarios del popup
_nextDeps.Clear();
StateHasChanged();
double? elevG = null;
try
{
var kIdee = ClaveElevacionIdee(paradaDetectada.Latitud, paradaDetectada.Longitud);
if (_elevIdeeCache.TryGetValue(kIdee, out var g))
{
elevG = g;
}
else
{
await ObtenerElevacionesIdeePorLoteAsync(
new List<(double lat, double lon)> { (paradaDetectada.Latitud, paradaDetectada.Longitud) },
CancellationToken.None);
elevG = _elevIdeeCache.TryGetValue(
ClaveElevacionIdee(paradaDetectada.Latitud, paradaDetectada.Longitud),
out g) ? g : (double?)null;
}
}
catch
{
elevG = null;
}
if (myToken != _paradaPopupToken) return;
_paradaElevG = elevG;
_paradaClicada = paradaDetectada;
// ==========================
// HORARIOS (ZIP) -> usando tu AhoraLocal() (real o simulada)
// ==========================
if (_usarHorariosTeoricos)
{
await EnsureHorariosLoadedAsync();
if (_horariosOk)
{
try
{
var now = AhoraLocal();
var fecha = DateOnly.FromDateTime(now);
var hora = TimeOnly.FromDateTime(now);
// tu parada usa Codigo (ej "34" o "P-34"). El servicio normaliza.
var deps = Horarios.GetNextDeparturesByCodigo(
paradaDetectada.Codigo,
fecha,
hora,
take: null,
includePast: true);
_nextDeps = ConstruirListaHorariosConActual(deps, hora);
_scrollHorariosPendiente = true;
}
catch (Exception ex)
{
_horariosOk = false;
_horariosError = "Error leyendo horarios: " + ex.Message;
}
}
}
StateHasChanged();
}
/// <summary>
/// Gestiona toggle horarios teoricos.
/// </summary>
private async Task OnToggleHorariosTeoricos(ChangeEventArgs e)
{
var usabaHorarios = _usarHorariosTeoricos;
_usarHorariosTeoricos = e?.Value is bool b && b;
if (_usarHorariosTeoricos && !usabaHorarios)
_segundosPorParadaBusManual = Math.Max(0, _segundosPorParadaBus);
SincronizarSegundosPorParadaConHorarios();
if (_usarHorariosTeoricos)
{
await EnsureHorariosLoadedAsync();
// si hay popup de parada abierto, refrescar inmediatamente
if (_paradaClicada is not null && _horariosOk)
{
try
{
var now = AhoraLocal();
var fecha = DateOnly.FromDateTime(now);
var hora = TimeOnly.FromDateTime(now);
var deps = Horarios.GetNextDeparturesByCodigo(
_paradaClicada.Codigo,
fecha,
hora,
take: null,
includePast: true);
_nextDeps = ConstruirListaHorariosConActual(deps, hora);
_scrollHorariosPendiente = true;
}
catch (Exception ex)
{
_horariosOk = false;
_horariosError = "Error leyendo horarios: " + ex.Message;
}
}
await PersistirConfiguracionAsync();
}
else
{
_nextDeps.Clear();
}
StateHasChanged();
}
/// <summary>
/// Asegura que horarios la carga este disponible.
/// </summary>
private async Task EnsureHorariosLoadedAsync()
{
if (_horariosOk) return;
_horariosError = null;
try
{
// Carga el ZIP de importación resuelto por la capa compartida de importación.
await Horarios.AsegurarCargaAsync(Env.ContentRootPath);
_horariosOk = true;
}
catch (Exception ex)
{
_horariosOk = false;
_horariosError = ex.Message;
}
}
private bool _mostrarParadas = true;
/// <summary>
/// Alterna paradas.
/// </summary>
private async Task ToggleParadasAsync()
{
_mostrarParadas = !_mostrarParadas;
if (_mapa is null) { StateHasChanged(); return; }
if (_mostrarParadas)
{
// vuelve a pintar paradas (y también origen/destino si están)
await DibujarParadasAsync();
}
else
{
// oculta paradas (pero deja rutas, previews, etc.)
await OcultarParadasAsync();
}
await PersistirConfiguracionAsync();
StateHasChanged();
}
/// <summary>
/// Oculta paradas.
/// </summary>
private async Task OcultarParadasAsync()
{
if (_mapa is null) return;
// Esto borra TODOS los puntos pintados con DisplayPointsFromArray,
// así que luego reponemos origen/destino si existen.
await _mapa.Geometric.DisplayPointsFromArray.deleteAllPoints();
}
private int OpacidadMapaPorcentaje => _nivelBrilloMapa switch
{
0 => 0,
1 => 25,
2 => 50,
3 => 75,
4 => 90,
_ => 0
};
// ✅ Límite de transbordos
// ✅ Transbordos: máximo y rango (mín..máx)
private bool _activarRangoTransbordos = true;
private int _minTransbordosPermitidos = 0;
private int _maxTransbordosPermitidos = 3;
/// <summary>
/// Indica si esta popup puntos abierto.
/// </summary>
private bool EstaPopupPuntosAbierto(DetalleTramoRuta tramo)
{
// Abierto si el popup está mostrando ESTE tramo y no está minimizado
return _tramoElevDetalle == tramo && !_elevPopupMinimizado;
}
/// <summary>
/// Alterna puntos.
/// </summary>
private async Task TogglePuntosAsync(DetalleTramoRuta tramo)
{
if (tramo is null) return;
// Si ya está abierto para este tramo => cerrar (igual que "- Iti")
if (_tramoElevDetalle == tramo && !_elevPopupMinimizado)
{
await CerrarPopupElevAsync();
return;
}
// Si estaba abierto para otro tramo, limpiamos puntos antes de cambiar
if (_tramoElevDetalle is not null && _tramoElevDetalle != tramo)
{
try { await JS.InvokeVoidAsync("rt.clearElevPoints", "elev"); } catch { }
}
// Abrir para este tramo
_elevPopupMinimizado = false;
_tramoElevDetalle = tramo;
await EnsureElevacionTramoAsync(tramo);
await PintarPuntosElevacionAsync(tramo);
StateHasChanged();
}
private static readonly string[] _palette =
{
"#2563eb", "#16a34a", "#dc2626", "#7c3aed", "#ea580c",
"#0891b2", "#db2777", "#65a30d", "#0f766e", "#b45309",
"#4f46e5", "#be123c", "#15803d", "#c026d3", "#0284c7",
"#ca8a04"
};
/// <summary>
/// Gestiona stable hash.
/// </summary>
private static int StableHash(string s)
{
unchecked
{
int h = 23;
foreach (var ch in s)
h = (h * 31) + ch;
return h;
}
}
/// <summary>
/// Gestiona color for clave.
/// </summary>
private static string ColorForKey(string key)
{
var idx = Math.Abs(StableHash(key)) % _palette.Length;
return _palette[idx];
}
private string _itFiltro = "";
private bool PuedePintarItinerariosFiltrados => !string.IsNullOrWhiteSpace(_itFiltro);
private string ItFiltro
{
get => _itFiltro;
set
{
value ??= "";
if (string.Equals(_itFiltro, value, StringComparison.Ordinal))
return;
var filtroAnterior = _itFiltro;
_itFiltro = value;
// Si se limpia el filtro (X nativa o borrar), selecciona el primero visible
if (!string.IsNullOrWhiteSpace(filtroAnterior) && string.IsNullOrWhiteSpace(_itFiltro))
{
_itSeleccionadoId = _itItemsFiltrados.FirstOrDefault()?.Id;
}
else
{
SincronizarSeleccionItinerarioConFiltro();
}
// Fuerza reconstrucción visual del select
_selectItKey++;
}
}
private IEnumerable<ElementoItinerario> _itItemsFiltrados =>
string.IsNullOrWhiteSpace(_itFiltro)
? _itItems
: _itItems.Where(x => MatchFiltro(x, _itFiltro));
/// <summary>
/// Indica si match filtro.
/// </summary>
private static bool MatchFiltro(ElementoItinerario? item, string? filtro)
{
var text = item?.TextoBusqueda;
if (string.IsNullOrWhiteSpace(filtro)) return true;
if (string.IsNullOrWhiteSpace(text)) return false;
var haystack = NormalizeForSearch(text);
// varios patrones separados por ; o ,
var parts = filtro.Split(new[] { ';', ',' }, StringSplitOptions.RemoveEmptyEntries);
foreach (var raw in parts)
{
var f = raw.Trim();
if (f.Length == 0) continue;
// 1) Regex explícita: re:...
if (f.StartsWith("re:", StringComparison.OrdinalIgnoreCase))
{
var rx = f.Substring(3).Trim();
if (rx.Length == 0) continue;
try
{
if (Regex.IsMatch(haystack, rx, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant))
return true;
}
catch
{
// regex inválida -> ignorar patrón
}
continue;
}
// Definición de "token"
const string TOKEN_CHARS = @"[\p{L}\p{N}.]";
string leftBoundary = $@"(?<!{TOKEN_CHARS})";
// 2) Wildcards: * y ?
if (f.Contains('*') || f.Contains('?'))
{
var pat = NormalizeForSearch(f);
// Presuponer SIEMPRE * final si no está
if (!pat.EndsWith("*", StringComparison.Ordinal))
pat += "*";
bool leadStar = pat.StartsWith("*", StringComparison.Ordinal);
bool tailStar = pat.EndsWith("*", StringComparison.Ordinal);
// ✅ Importante: quitar extremos en el patrón ORIGINAL,
// no en el regex ya escapado (evita romper cosas como ".*")
var rawInner = pat.Trim('*');
// Si el patrón era solo "*" / "**" / "***", coincide con todo
// (si llevaba ?, no entra aquí porque rawInner no quedará vacío)
if (rawInner.Length == 0)
return true;
// Convertir wildcard -> regex
var inner = Regex.Escape(rawInner)
.Replace(@"\*", ".*")
.Replace(@"\?", ".");
var rx =
(leadStar ? "" : leftBoundary) +
inner +
(tailStar ? "" : $@"(?!{TOKEN_CHARS})");
try
{
if (Regex.IsMatch(haystack, rx, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant))
return true;
}
catch
{
// patrón inválido -> ignorar
}
continue;
}
// 3) SIN wildcard: por defecto "empieza por" (equivale a añadir * al final)
var needle = NormalizeForSearch(f);
// --- caso A: solo dígitos -> token numérico que EMPIEZA por needle ---
// Se interpreta como búsqueda de línea lógica:
// - líneas numéricas normales por prefijo
// - taxibús TB6/TB7 por su número real 6/7
// - líneas B por su identificador 100 exacto (B1 -> 101)
if (Regex.IsMatch(needle, @"^\d+$"))
{
if (CoincideFiltroNumericoItinerario(item, needle))
return true;
continue;
}
// --- caso B: ".5" fracción -> mantener lógica ---
if (Regex.IsMatch(needle, @"^\.\d+$"))
{
var rxFrac = $@"(?<=\p{{N}}){Regex.Escape(needle)}(?!\p{{N}})";
if (Regex.IsMatch(haystack, rxFrac, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant))
return true;
continue;
}
// --- caso C: token general que EMPIEZA por needle ---
var rxTokenPrefix = $@"{leftBoundary}{Regex.Escape(needle)}{TOKEN_CHARS}*";
if (Regex.IsMatch(haystack, rxTokenPrefix, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant))
return true;
}
return false;
}
/// <summary>
/// Evalúa búsquedas numéricas sobre la línea lógica del itinerario.
/// </summary>
private static bool CoincideFiltroNumericoItinerario(ElementoItinerario? item, string needle)
{
if (item?.Variantes is null || item.Variantes.Count == 0)
return false;
foreach (var variante in item.Variantes)
{
var codigoLinea = variante?.Linea?.Codigo?.ToString()?.Trim() ?? "";
if (string.IsNullOrWhiteSpace(codigoLinea))
continue;
var codigoNormalizado = UtilidadesTransitoDbus.CodigoSinDecimal(codigoLinea);
if (Regex.IsMatch(codigoNormalizado, @"^\d+$") &&
codigoNormalizado.StartsWith(needle, StringComparison.OrdinalIgnoreCase))
{
return true;
}
var matchTb = Regex.Match(codigoNormalizado, @"^TB(\d+)$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
if (matchTb.Success &&
matchTb.Groups[1].Value.StartsWith(needle, StringComparison.OrdinalIgnoreCase))
{
return true;
}
var esBuho = Regex.IsMatch(codigoNormalizado, @"^B\d+$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
if (esBuho &&
int.TryParse(variante?.Linea?.IdRuta, NumberStyles.Integer, CultureInfo.InvariantCulture, out var idRutaBuho) &&
idRutaBuho.ToString(CultureInfo.InvariantCulture) == needle)
{
return true;
}
}
return false;
}
// Normaliza: minúsculas + sin tildes (para búsquedas “humanas”)
/// <summary>
/// Gestiona normalize for busqueda.
/// </summary>
private static string NormalizeForSearch(string s)
{
s = s.Trim().ToLowerInvariant();
const char marcadorEnye = '\uE000';
s = s.Replace('\u00f1', marcadorEnye);
// quitar diacríticos
var formD = s.Normalize(NormalizationForm.FormD);
var sb = new System.Text.StringBuilder(formD.Length);
foreach (var ch in formD)
{
var uc = CharUnicodeInfo.GetUnicodeCategory(ch);
if (uc != UnicodeCategory.NonSpacingMark)
sb.Append(ch);
}
return sb
.ToString()
.Normalize(NormalizationForm.FormC)
.Replace(marcadorEnye, '\u00f1');
}
private sealed class ElementoItinerario
{
public string Id { get; set; } = "";
public string Text { get; set; } = "";
public List<string> SearchAliases { get; set; } = new();
public string TextoBusqueda =>
SearchAliases.Count == 0
? Text
: $"{Text} {string.Join(" ", SearchAliases)}";
// ✅ Puede contener 1 (solo Ida o solo Vuelta) o 2 (Ida+Vuelta)
public List<InfoVariante> Variantes { get; set; } = new();
}
/// <summary>
/// Gestiona codigo linea as int.
/// </summary>
private static int CodigoLineaAsInt(InfoVariante v)
{
// v.Linea.Codigo puede ser int/decimal/string según tu modelo.
// Lo pasamos a string y extraemos la parte numérica inicial.
var numero = CodigoLineaAsInt(v?.Linea);
return numero;
}
private static int CodigoLineaAsInt(LineaBus? linea)
{
if (linea is null)
return int.MaxValue;
if (int.TryParse(linea.IdRuta, NumberStyles.Integer, CultureInfo.InvariantCulture, out var idRuta))
return idRuta;
var numero = UtilidadesTransitoDbus.ExtraerNumeroLinea(linea.Codigo);
return numero ?? int.MaxValue;
}
/// <summary>
/// Obtiene el identificador numerico real de la ruta para ordenar el filtro.
/// </summary>
private static int IdRutaAsInt(InfoVariante? variante)
{
if (variante?.Linea is null)
return int.MaxValue;
return int.TryParse(variante.Linea.IdRuta, out var idRuta)
? idRuta
: CodigoLineaAsInt(variante);
}
/// <summary>
/// Construye un texto de trayecto usando la primera y la ultima parada de la variante.
/// </summary>
private string ConstruirTextoRecorridoVariante(InfoVariante? variante)
{
if (variante?.CodigosParadas is null || variante.CodigosParadas.Count == 0)
return "";
string NombreParada(string codigo)
{
if (_paradaByCode.TryGetValue(codigo, out var parada) && !string.IsNullOrWhiteSpace(parada.Nombre))
return parada.Nombre.Trim();
return CodigoParadaFormateado(codigo);
}
var origen = NombreParada(variante.CodigosParadas[0]);
var destino = NombreParada(variante.CodigosParadas[^1]);
if (string.IsNullOrWhiteSpace(origen))
return destino;
if (string.IsNullOrWhiteSpace(destino))
return origen;
return string.Equals(origen, destino, StringComparison.OrdinalIgnoreCase)
? origen
: $"{origen} -> {destino}";
}
/// <summary>
/// Obtiene nombres alternativos de expediciones para que el buscador encuentre el itinerario por destino.
/// </summary>
private List<string> ObtenerAliasesBusquedaItinerario(
InfoVariante? variante,
Dictionary<string, List<string>> cache)
{
if (variante is null)
return new List<string>();
var sentido = NormalizarSentidoCodigo(variante.Sentido?.Codigo);
var key = string.Join("|",
variante.Linea.IdRuta ?? "",
variante.Linea.Codigo ?? "",
variante.CodigoItinerario.ToString(CultureInfo.InvariantCulture),
sentido);
if (cache.TryGetValue(key, out var cached))
return cached;
try
{
cached = Horarios
.GetAliasBusquedaItinerario(
variante.Linea.IdRuta,
variante.Linea.Codigo,
variante.CodigoItinerario,
sentido)
.Where(x => !string.IsNullOrWhiteSpace(x))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
}
catch
{
cached = new List<string>();
}
cache[key] = cached;
return cached;
}
/// <summary>
/// Construye itinerarios dropdown.
/// </summary>
private void BuildItinerariosDropdown()
{
EnsureBusIndexBuilt();
var grupos = _infoVariantes
.GroupBy(v => new
{
Linea = v.Linea.Codigo?.ToString() ?? "?",
Itin = v.CodigoItinerario
})
.OrderBy(g =>
{
var any = g.FirstOrDefault();
return any is null ? int.MaxValue : IdRutaAsInt(any);
})
.ThenBy(g => g.Key.Linea, StringComparer.OrdinalIgnoreCase)
.ThenBy(g => g.Key.Itin)
.ToList();
var list = new List<ElementoItinerario>(grupos.Count * 2);
var aliasesCache = new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase);
foreach (var g in grupos)
{
var linea = g.Key.Linea;
var itin = g.Key.Itin;
// 1) Elegimos UNA variante por sentido (determinista)
InfoVariante? ida = g
.Where(v => NormalizarSentidoCodigo(v.Sentido?.Codigo) == "I")
.OrderBy(v => v.Variante.Codigo)
.FirstOrDefault();
InfoVariante? vuelta = g
.Where(v => NormalizarSentidoCodigo(v.Sentido?.Codigo) == "V")
.OrderBy(v => v.Variante.Codigo)
.FirstOrDefault();
var nombreLinea = (ida?.Linea.Nombre ?? vuelta?.Linea.Nombre ?? "");
// 2) ✅ Si existe Ida: añadir item I
if (ida is not null)
{
var varCod = ida.Variante.Codigo.ToString() ?? "?";
var id = $"it_{linea}_{itin}_I_{varCod}";
var trayectoIda = ConstruirTextoRecorridoVariante(ida);
var textVisible = string.IsNullOrWhiteSpace(trayectoIda)
? $"{linea}.{itin}-I — {ida.Linea.Nombre}"
: $"{linea}.{itin}-I — {trayectoIda}";
var text = $"{linea}.{itin}-I — {ida.Linea.Nombre}";
text = textVisible;
list.Add(new ElementoItinerario
{
Id = id,
Text = text,
SearchAliases = ObtenerAliasesBusquedaItinerario(ida, aliasesCache),
Variantes = new List<InfoVariante> { ida }
});
}
// 3) ✅ Si existe Vuelta: añadir item V
if (vuelta is not null)
{
var varCod = vuelta.Variante.Codigo.ToString() ?? "?";
var id = $"it_{linea}_{itin}_V_{varCod}";
var trayectoVuelta = ConstruirTextoRecorridoVariante(vuelta);
var textVisible = string.IsNullOrWhiteSpace(trayectoVuelta)
? $"{linea}.{itin}-V — {vuelta.Linea.Nombre}"
: $"{linea}.{itin}-V — {trayectoVuelta}";
var text = $"{linea}.{itin}-V — {vuelta.Linea.Nombre}";
text = textVisible;
list.Add(new ElementoItinerario
{
Id = id,
Text = text,
SearchAliases = ObtenerAliasesBusquedaItinerario(vuelta, aliasesCache),
Variantes = new List<InfoVariante> { vuelta }
});
}
// 4) ✅ Si existen ambos: añadir item combinado IV
if (ida is not null && vuelta is not null)
{
var id = $"it_{linea}_{itin}_IV";
var text = $"{linea}.{itin} — {nombreLinea} (I+V)";
list.Add(new ElementoItinerario
{
Id = id,
Text = text,
SearchAliases = new[] { ida, vuelta }
.SelectMany(v => ObtenerAliasesBusquedaItinerario(v, aliasesCache))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList(),
Variantes = new List<InfoVariante> { ida, vuelta }
});
}
}
_itItems = list;
_itSeleccionadoId ??= _itItems.FirstOrDefault()?.Id;
}
private List<ElementoItinerario> _itItems = new();
private string? _itSeleccionadoId;
private int _paradaPopupToken = 0;
/// <summary>
/// Gestiona lineas de alternativa.
/// </summary>
private string LineasDeAlternativa(AlternativaRuta alt)
{
if (alt is null || alt.EsSoloAPie || alt.AlternativaBus?.Tramos is null)
return "";
return TextoLineasDeTramos(alt.AlternativaBus.Tramos);
}
private static string TextoLineasDeTramos(IReadOnlyList<TramoBus>? tramos)
{
if (tramos is null || tramos.Count == 0)
return "";
var sb = new StringBuilder();
string? ultimoCodigoMostrado = null;
TramoBus? tramoAnteriorEnSecuencia = null;
foreach (var tramo in tramos)
{
var codigo = CodigoLineaItinerarioSentido(tramo);
if (string.IsNullOrWhiteSpace(codigo))
continue;
var repetidoConsecutivo = string.Equals(codigo, ultimoCodigoMostrado, StringComparison.OrdinalIgnoreCase);
if (!repetidoConsecutivo)
{
if (sb.Length > 0)
{
var separador = tramoAnteriorEnSecuencia is not null &&
!UtilidadesTransitoDbus.EsTransbordoReal(tramoAnteriorEnSecuencia, tramo)
? " + "
: ", ";
sb.Append(separador);
}
sb.Append(codigo);
ultimoCodigoMostrado = codigo;
}
tramoAnteriorEnSecuencia = tramo;
}
return sb.ToString();
}
private static List<string> CompactarCodigosLineaConsecutivos(IEnumerable<string?> codigos)
{
var resultado = new List<string>();
string? ultimoCodigoMostrado = null;
foreach (var codigo in codigos)
{
if (string.IsNullOrWhiteSpace(codigo))
continue;
if (string.Equals(codigo, ultimoCodigoMostrado, StringComparison.OrdinalIgnoreCase))
continue;
resultado.Add(codigo);
ultimoCodigoMostrado = codigo;
}
return resultado;
}
private HashSet<string> _previewLineasActivas = new();
/// <summary>
/// Indica si esta vista previa activa.
/// </summary>
private bool EstaPreviewActiva(string id)
{
return _previewLineasActivas.Contains(id);
}
// --- “doble click” por software ---
private CancellationTokenSource? _ctsDobleClick;
private (double lat, double lon)? _primerClickPendiente;
/// <summary>
///
/// </summary>
private bool Cerca(double lat1, double lon1, double lat2, double lon2, double maxMetros)
=> CalcularDistanciaMetros(lat1, lon1, lat2, lon2) <= maxMetros;
// ajusta a gusto
private const int DBLCLICK_MS = 320; // ventana de doble click
private const double DBLCLICK_MAX_METROS = 18; // tolerancia espacial (para no exigir pixel-perfect)
private bool _elevPopupMinimizado = false;
private const string _versionApp = "(v-20260728a)";
private double? _paradaElevG;
private DetalleTramoRuta? _tramoElevDetalle;
/// <summary>
/// Gestiona cerrar popups.
/// </summary>
private async Task CerrarPopupsAsync()
{
_itinerarioClicado = null;
_paradaClicada = null;
await CerrarInspectorAsync();
if (_tramoElevDetalle is not null)
{
_tramoElevDetalle = null;
try { await JS.InvokeVoidAsync("rt.clearElevPoints", "elev"); } catch { }
}
}
/// <summary>
/// Pinta puntos elevacion.
/// </summary>
private async Task PintarPuntosElevacionAsync(DetalleTramoRuta tramo)
{
if (tramo?.MuestrasElevacion is null || tramo.MuestrasElevacion.Count == 0)
{
try { await JS.InvokeVoidAsync("rt.clearElevPoints", "elev"); } catch { }
return;
}
var pts = tramo.MuestrasElevacion.Select((p, i) => new
{
lat = p.Latitud,
lon = p.Longitud,
// etiqueta: Ini/Fin o índice
label = (i == 0) ? "Ini" : (i == tramo.MuestrasElevacion.Count - 1) ? "Fin" : p.Indice.ToString()
}).ToList();
try
{
await JS.InvokeVoidAsync("rt.drawElevPoints", "elev", pts, new
{
radius = 3,
opacity = 0.9,
fillOpacity = 0.9,
showLabels = true,
labelEvery = 1
});
}
catch { }
}
// =========================================================
// CACHES (walking + bus filtrado)
// =========================================================
private static readonly ConcurrentDictionary<string, (double[,]? line, double dist, double dur)> _walkCache =
new(StringComparer.OrdinalIgnoreCase);
// Bus cache (filtrado por rango): key = o|d|min|max|maxAlts
private static readonly ConcurrentDictionary<string, IReadOnlyList<RutaBusAlternativa>> _busFilteredCache =
new(StringComparer.OrdinalIgnoreCase);
// =========================================================
// INDICES “BUS GRAPH” (construidos desde Lineas/Trayectos)
// =========================================================
private static readonly object _busIndexLock = new();
private static bool _busIndexBuilt = false;
private static List<InfoVariante> _infoVariantes = new();
private static Dictionary<string, List<AparicionParada>> _indiceParadas = new(StringComparer.OrdinalIgnoreCase);
private static Dictionary<string, List<string>> _paradasCercanas = new(StringComparer.OrdinalIgnoreCase);
private static Dictionary<string, Parada> _paradaByCode = new(StringComparer.OrdinalIgnoreCase);
// Grid de paradas (para “paradas cerca del punto”)
private static Dictionary<(int cx, int cy), List<Parada>> _gridParadas = new();
private static int _gridScale = 400; // ~ 250-300m en lat
/// <summary>
///
/// </summary>
private static (int cx, int cy) Cell(double lat, double lon, int scale)
=> ((int)Math.Floor(lat * scale), (int)Math.Floor(lon * scale));
private static void InvalidarBusIndex()
{
lock (_busIndexLock)
{
_busIndexBuilt = false;
_infoVariantes = new List<InfoVariante>();
_indiceParadas = new Dictionary<string, List<AparicionParada>>(StringComparer.OrdinalIgnoreCase);
_paradasCercanas = new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase);
_paradaByCode = new Dictionary<string, Parada>(StringComparer.OrdinalIgnoreCase);
_gridParadas = new Dictionary<(int cx, int cy), List<Parada>>();
}
}
/// <summary>
/// Gestiona cerrar popup elev.
/// </summary>
private async Task CerrarPopupElevAsync()
{
_elevPopupMinimizado = false;
_tramoElevDetalle = null;
try { await JS.InvokeVoidAsync("rt.clearElevPoints", "elev"); } catch { }
}
/// <summary>
/// Obtiene texto itinerario clicado.
/// </summary>
private string GetTextoItinerarioClicado()
{
if (_itinerarioClicado is null)
return "";
var vReal = GetVarianteRealClicada();
if (vReal is null)
return _itinerarioClicado.Text;
var linea = vReal.Linea?.Codigo?.ToString() ?? "?";
var itin = vReal.CodigoItinerario;
var sentido = NormalizarSentidoCodigo(vReal.Sentido?.Codigo); // I / V
var nombre = vReal.Linea?.Nombre ?? "";
var trayecto = ConstruirTextoRecorridoVariante(vReal);
if (!string.IsNullOrWhiteSpace(trayecto))
return $"{linea}.{itin}-{sentido} — {trayecto}";
// ✅ Siempre devuelve el texto del sentido pinchado, no "I+V"
return $"{linea}.{itin}-{sentido} — {nombre}";
}
/// <summary>
/// Asegura que bus indice built este disponible.
/// </summary>
private void EnsureBusIndexBuilt()
{
if (_busIndexBuilt) return;
lock (_busIndexLock)
{
if (_busIndexBuilt) return;
// 1) map parada por código
_paradaByCode = RedBusServicio.Paradas
.GroupBy(p => p.Codigo, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
// 2) construir InfoVariante + indiceParadas
_infoVariantes = new List<InfoVariante>(4096);
_indiceParadas = new Dictionary<string, List<AparicionParada>>(StringComparer.OrdinalIgnoreCase);
foreach (var linea in RedBusServicio.Lineas)
{
foreach (var itinerario in linea.Itinerarios)
{
foreach (var sentido in itinerario.Sentidos)
{
foreach (var variante in sentido.Variantes)
{
if (variante.IdsTrayectos.Count == 0) continue;
if (!RedBusServicio.Trayectos.TryGetValue(variante.IdsTrayectos[0], out var t0))
continue;
var info = new InfoVariante
{
Linea = linea,
Sentido = sentido,
Variante = variante,
CodigoItinerario = itinerario.Codigo
};
info.CodigosParadas.Add(t0.CodigoParadaOrigen);
foreach (var idTr in variante.IdsTrayectos)
{
if (RedBusServicio.Trayectos.TryGetValue(idTr, out var t))
info.CodigosParadas.Add(t.CodigoParadaDestino);
}
_infoVariantes.Add(info);
for (int i = 0; i < info.CodigosParadas.Count; i++)
{
var cod = info.CodigosParadas[i];
if (!_indiceParadas.TryGetValue(cod, out var lista))
{
lista = new List<AparicionParada>(4);
_indiceParadas[cod] = lista;
}
lista.Add(new AparicionParada { Variante = info, IndiceParada = i });
}
}
}
}
}
// 3) grid de paradas (para query en radio)
var dict = new Dictionary<(int, int), List<Parada>>();
foreach (var p in RedBusServicio.Paradas)
{
var c = Cell(p.Latitud, p.Longitud, _gridScale);
if (!dict.TryGetValue(c, out var list))
{
list = new List<Parada>(16);
dict[c] = list;
}
list.Add(p);
}
_gridParadas = dict;
// 4) paradas cercanas para transbordo andando (sin O(n^2))
_paradasCercanas = new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase);
const double MAX_TRANSBORDO = 250;
double deg = MAX_TRANSBORDO / 111_000.0;
int step = (int)Math.Ceiling(deg * _gridScale);
if (step < 1) step = 1;
foreach (var p in RedBusServicio.Paradas)
{
var c0 = Cell(p.Latitud, p.Longitud, _gridScale);
for (int dx = -step; dx <= step; dx++)
{
for (int dy = -step; dy <= step; dy++)
{
var c = (c0.cx + dx, c0.cy + dy);
if (!_gridParadas.TryGetValue(c, out var list)) continue;
foreach (var q in list)
{
if (ReferenceEquals(p, q)) continue;
var d = CalcularDistanciaMetros(p.Latitud, p.Longitud, q.Latitud, q.Longitud);
if (d <= MAX_TRANSBORDO)
{
AddCercana(p.Codigo, q.Codigo);
}
}
}
}
}
void AddCercana(string a, string b)
{
if (!_paradasCercanas.TryGetValue(a, out var l))
{
l = new List<string>(8);
_paradasCercanas[a] = l;
}
if (!l.Contains(b, StringComparer.OrdinalIgnoreCase))
l.Add(b);
}
_busIndexBuilt = true;
}
}
/// <summary>
/// Consulta paradas en radio.
/// </summary>
private List<Parada> QueryParadasEnRadio(double lat, double lon, int radioMetros)
{
double deg = Math.Max(0.0005, radioMetros / 111_000.0);
int step = (int)Math.Ceiling(deg * _gridScale);
if (step < 1) step = 1;
var c0 = Cell(lat, lon, _gridScale);
var res = new List<Parada>(128);
for (int dx = -step; dx <= step; dx++)
{
for (int dy = -step; dy <= step; dy++)
{
var c = (c0.cx + dx, c0.cy + dy);
if (_gridParadas.TryGetValue(c, out var list))
res.AddRange(list);
}
}
return res;
}
}