1509 lines
52 KiB
C#
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;
|
|
}
|
|
}
|