diff --git a/RutasDBUS/Components/Pages/PlanificadorRutas.CicloVidaYMapa.cs b/RutasDBUS/Components/Pages/PlanificadorRutas.CicloVidaYMapa.cs index 6b83e65..58b0e52 100644 --- a/RutasDBUS/Components/Pages/PlanificadorRutas.CicloVidaYMapa.cs +++ b/RutasDBUS/Components/Pages/PlanificadorRutas.CicloVidaYMapa.cs @@ -238,8 +238,7 @@ public partial class PlanificadorRutas : ComponentBase, IDisposable if (!string.IsNullOrWhiteSpace(_itinerarioClicadoSubId) && string.Equals(_itinerarioClicadoSubId, id, StringComparison.OrdinalIgnoreCase)) { - _itinerarioClicado = null; - _itinerarioClicadoSubId = null; + await CerrarPopupItinerarioAsync(); } } @@ -253,6 +252,7 @@ public partial class PlanificadorRutas : ComponentBase, IDisposable /// private async Task CerrarTodasLasPreviewsAsync() { + await LimpiarResaltadoTramoHorarioAsync(); try { await JS.InvokeVoidAsync("rt.clearPreview"); } catch { } _itPreviewActivos.Clear(); @@ -356,7 +356,8 @@ public partial class PlanificadorRutas : ComponentBase, IDisposable } _itinerarioClicado = encontrado; - + await CargarTramosHorariosItinerarioAsync(GetVarianteRealClicada()); + await InvokeAsync(StateHasChanged); } catch diff --git a/RutasDBUS/Components/Pages/PlanificadorRutas.Estado.cs b/RutasDBUS/Components/Pages/PlanificadorRutas.Estado.cs index 366471c..d93dd05 100644 --- a/RutasDBUS/Components/Pages/PlanificadorRutas.Estado.cs +++ b/RutasDBUS/Components/Pages/PlanificadorRutas.Estado.cs @@ -328,7 +328,7 @@ public partial class PlanificadorRutas : ComponentBase { new RealTimeMap.BasemapLayer { - url = "http://192.168.41.12/osm_tiles/{z}/{x}/{y}.png", + url = "https://osm.tecnosis.net/osm_tiles/{z}/{x}/{y}.png", attribution = "© OpenStreetMap", title = "", detectRetina = true diff --git a/RutasDBUS/Components/Pages/PlanificadorRutas.Historial.cs b/RutasDBUS/Components/Pages/PlanificadorRutas.Historial.cs index d258a1b..cfb8634 100644 --- a/RutasDBUS/Components/Pages/PlanificadorRutas.Historial.cs +++ b/RutasDBUS/Components/Pages/PlanificadorRutas.Historial.cs @@ -51,8 +51,6 @@ public partial class PlanificadorRutas private ModoConfiguracionHistorial _modoConfiguracionHistorial = ModoConfiguracionHistorial.Actual; private bool _cambiandoParametrosHistorial = false; private string? _historialCompartidoPendienteEliminarId; - private ConfiguracionHistorialRuta? _configuracionTemporalPreviaHistorial; - private ConfiguracionHistorialRuta? _configuracionParametrosActualesHistorial; private Task? _tareaCargaHistorialLocal; private bool _historialLocalCargado = false; private bool _historialLocalCargando = false; @@ -150,8 +148,6 @@ public partial class PlanificadorRutas _mostrarInformacionDerivacionHistorial = false; _historialLocalCargando = false; StopRefrescoHistorialCompartido(); - // Cerrar el panel no debe separar la configuracion visible de la ruta calculada. - // La configuracion previa solo se recupera al elegir expresamente "Actual". return; } @@ -918,22 +914,9 @@ public partial class PlanificadorRutas { var itemSeleccionado = ObtenerRegistroHistorialSeleccionado(); - if (_modoConfiguracionHistorial == ModoConfiguracionHistorial.Actual && - modo != ModoConfiguracionHistorial.Actual) - { - CapturarConfiguracionTemporalAntesDeHistorial(); - _configuracionParametrosActualesHistorial = CrearConfiguracionLocal(); - } - _modoConfiguracionHistorial = modo; await PersistirModoConfiguracionHistorialAsync(); - if (modo == ModoConfiguracionHistorial.Actual && - _configuracionParametrosActualesHistorial is not null) - { - AplicarConfiguracionLocal(_configuracionParametrosActualesHistorial); - } - if (itemSeleccionado is null) return; @@ -1072,7 +1055,8 @@ public partial class PlanificadorRutas private string TextoReferenciaHistorialDerivado() { - if (!_rutaActualDerivadaDeHistorial) + var originalDerivacionActivo = EsOriginalDerivacionActivo; + if (!_rutaActualDerivadaDeHistorial && !originalDerivacionActivo) return string.Empty; var (nombre, indice) = _tipoReferenciaHistorial switch @@ -1087,6 +1071,9 @@ public partial class PlanificadorRutas if (indice < 0) return string.Empty; + if (originalDerivacionActivo) + return $"Ruta original del Historial {nombre} n.º {indice + 1} cargada."; + var numeroDerivacion = _tipoReferenciaHistorial == TipoReferenciaHistorial.Compartido && _indicePasoDerivacionActivo >= 0 && _indicePasoDerivacionActivo < _pasosDerivacionHistorial.Count @@ -1096,11 +1083,18 @@ public partial class PlanificadorRutas } private bool PuedeMostrarInformacionDerivacionHistorial => - _rutaActualDerivadaDeHistorial && + (_rutaActualDerivadaDeHistorial || EsOriginalDerivacionActivo) && _tipoReferenciaHistorial == TipoReferenciaHistorial.Compartido && _escenarioBaseDerivacionHistorial is not null && _pasosDerivacionHistorial.Count > 0; + private bool EsOriginalDerivacionActivo => + !_rutaActualDerivadaDeHistorial && + _indicePasoDerivacionActivo == -1 && + _pasosDerivacionHistorial.Count > 0 && + _escenarioBaseDerivacionHistorial is not null && + ExisteReferenciaHistorialActual(); + private int IndiceHistorialCompartidoReferencia => _historialCompartido.FindIndex(item => string.Equals( item.Id, @@ -1113,14 +1107,21 @@ public partial class PlanificadorRutas ? _pasosDerivacionHistorial[_indicePasoDerivacionSeleccionado] : null; + private RegistroHistorialRuta? EscenarioDerivacionSeleccionado => + _indicePasoDerivacionSeleccionado == -1 + ? _escenarioBaseDerivacionHistorial + : PasoDerivacionSeleccionado?.Escenario; + private void AbrirInformacionDerivacionHistorial() { if (!PuedeMostrarInformacionDerivacionHistorial) return; - _indicePasoDerivacionSeleccionado = _indicePasoDerivacionActivo >= 0 - ? _indicePasoDerivacionActivo - : _pasosDerivacionHistorial.Count - 1; + _indicePasoDerivacionSeleccionado = EsOriginalDerivacionActivo + ? -1 + : _indicePasoDerivacionActivo >= 0 + ? _indicePasoDerivacionActivo + : _pasosDerivacionHistorial.Count - 1; _mostrarInformacionDerivacionHistorial = true; } @@ -1131,7 +1132,7 @@ public partial class PlanificadorRutas private void SeleccionarPasoDerivacionHistorial(int indice) { - if (indice < 0 || indice >= _pasosDerivacionHistorial.Count) + if (indice < -1 || indice >= _pasosDerivacionHistorial.Count) return; _indicePasoDerivacionSeleccionado = indice; @@ -1140,28 +1141,46 @@ public partial class PlanificadorRutas private async Task CargarPasoDerivacionHistorialAsync() { if (_cargandoPasoDerivacionHistorial || - _indicePasoDerivacionSeleccionado < 0 || + _indicePasoDerivacionSeleccionado < -1 || _indicePasoDerivacionSeleccionado >= _pasosDerivacionHistorial.Count) { return; } var indice = _indicePasoDerivacionSeleccionado; - var paso = _pasosDerivacionHistorial[indice]; + var esOriginal = indice == -1; + var paso = esOriginal ? null : _pasosDerivacionHistorial[indice]; + var escenario = esOriginal ? _escenarioBaseDerivacionHistorial : paso?.Escenario; + if (escenario is null) + return; + _cargandoPasoDerivacionHistorial = true; try { - await AplicarRegistroHistorialAsync(ClonarEscenarioHistorial(paso.Escenario)); + await AplicarRegistroHistorialAsync(ClonarEscenarioHistorial(escenario)); _indicePasoDerivacionActivo = indice; _indicePasoDerivacionSeleccionado = indice; - _rutaActualDerivadaDeHistorial = ExisteReferenciaHistorialActual(); + _rutaActualDerivadaDeHistorial = !esOriginal && ExisteReferenciaHistorialActual(); + if (esOriginal) + { + _historialLocalSeleccionadoId = string.Empty; + _historialCompartidoSeleccionadoId = _historialReferenciaId; + } + else + { + LimpiarSeleccionHistorial(); + } _mostrarInformacionDerivacionHistorial = false; - MostrarMensajeHistorial($"Derivación n.º {paso.Numero} cargada."); + MostrarMensajeHistorial(esOriginal + ? "Ruta original cargada." + : $"Derivación n.º {paso!.Numero} cargada."); } catch { MostrarMensajeHistorial( - $"No he podido cargar la Derivación n.º {paso.Numero}.", + esOriginal + ? "No he podido cargar la ruta original." + : $"No he podido cargar la Derivación n.º {paso!.Numero}.", esError: true); } finally @@ -1558,7 +1577,6 @@ public partial class PlanificadorRutas _aplicandoRegistroHistorial = true; try { - CapturarConfiguracionTemporalAntesDeHistorial(); var mostrarParadasAntes = _mostrarParadas; _omitirRegistroHistorialEnSiguienteCalculo = true; @@ -1626,9 +1644,7 @@ public partial class PlanificadorRutas // Los JSON anteriores no incluían una instantánea completa. Conservamos los // parámetros actuales para lo desconocido y superponemos todos los que sí viajaban. - var configuracion = (_configuracionParametrosActualesHistorial ?? - _configuracionTemporalPreviaHistorial ?? - CrearConfiguracionLocal()).Clonar(); + var configuracion = CrearConfiguracionLocal(); configuracion.UsarHorariosTeoricos = item.UsarHorariosTeoricos; configuracion.UsarHoraSimulada = item.UsarHoraSimulada; @@ -1646,15 +1662,6 @@ public partial class PlanificadorRutas private ConfiguracionHistorialRuta CrearConfiguracionPredeterminadaParaHistorial() => CrearConfiguracionPredeterminadaHistorial(); - private void CapturarConfiguracionTemporalAntesDeHistorial() - { - if (_configuracionTemporalPreviaHistorial is not null) - return; - - _configuracionTemporalPreviaHistorial = CrearConfiguracionLocal(); - _configuracionParametrosActualesHistorial = CrearConfiguracionLocal(); - } - private RegistroHistorialRuta? ConstruirRegistroHistorialActual() { if (_coordenadasOrigen is null || _coordenadasDestino is null) diff --git a/RutasDBUS/Components/Pages/PlanificadorRutas.Itinerarios.cs b/RutasDBUS/Components/Pages/PlanificadorRutas.Itinerarios.cs index dc38d11..6e42b68 100644 --- a/RutasDBUS/Components/Pages/PlanificadorRutas.Itinerarios.cs +++ b/RutasDBUS/Components/Pages/PlanificadorRutas.Itinerarios.cs @@ -43,11 +43,8 @@ public partial class PlanificadorRutas : ComponentBase private async Task QuitarTodosItinerarios() { await CerrarTodasLasPreviewsAsync(); - - // cerrar popup de itinerario al ocultar todo - _itinerarioClicado = null; - _itinerarioClicadoSubId = null; - + await CerrarPopupItinerarioAsync(); + StateHasChanged(); } @@ -1506,7 +1503,7 @@ public partial class PlanificadorRutas : ComponentBase private bool _elevPopupMinimizado = false; - private const string _versionApp = "(v-20260917a)"; + private const string _versionApp = "(v-20260918a)"; private double? _paradaElevIdee; @@ -1524,7 +1521,7 @@ public partial class PlanificadorRutas : ComponentBase /// private async Task CerrarPopupsAsync() { - _itinerarioClicado = null; + await CerrarPopupItinerarioAsync(); await CerrarPopupParadaAsync(); await CerrarInspectorAsync(); diff --git a/RutasDBUS/Components/Pages/PlanificadorRutas.TiempoYHorarios.cs b/RutasDBUS/Components/Pages/PlanificadorRutas.TiempoYHorarios.cs index 8a111a6..9197be1 100644 --- a/RutasDBUS/Components/Pages/PlanificadorRutas.TiempoYHorarios.cs +++ b/RutasDBUS/Components/Pages/PlanificadorRutas.TiempoYHorarios.cs @@ -219,14 +219,13 @@ public partial class PlanificadorRutas : ComponentBase private List _nextDeps = new(); - private bool HorariosTaxibusSimuladosVisibles - => _nextDeps.Any(salida => TaxibusDbus.EsExpedicionSimulada(salida.ExpId)); + private bool HorariosTaxibusVisibles + => _nextDeps.Any(salida => + TaxibusDbus.EsLinea(salida.RutaId) || + TaxibusDbus.EsLinea(salida.LineaTxt)); - private string MensajeHorarioTaxibusSimuladoVisible - => TaxibusDbus.ObtenerMensajeHorarioSimulado( - _nextDeps - .Where(salida => TaxibusDbus.EsExpedicionSimulada(salida.ExpId)) - .SelectMany(salida => new[] { salida.RutaId, salida.LineaTxt })); + private string MensajeHorarioTaxibusVisible + => TaxibusDbus.MensajeReserva; private static string ObtenerAvisoTaxibus(AlternativaRuta alternativa) { @@ -239,14 +238,7 @@ public partial class PlanificadorRutas : ComponentBase if (lineasTaxibus is not { Count: > 0 }) return string.Empty; - var lineasSimuladas = lineasTaxibus - .Where(linea => linea.HorarioSimulado) - .Select(linea => linea.Codigo) - .ToList(); - - return lineasSimuladas.Count > 0 - ? TaxibusDbus.ObtenerMensajeHorarioSimulado(lineasSimuladas) - : TaxibusDbus.MensajeReserva; + return TaxibusDbus.MensajeReserva; } /// diff --git a/RutasDBUS/Components/Pages/PlanificadorRutas.TramosHorarios.cs b/RutasDBUS/Components/Pages/PlanificadorRutas.TramosHorarios.cs new file mode 100644 index 0000000..2a30791 --- /dev/null +++ b/RutasDBUS/Components/Pages/PlanificadorRutas.TramosHorarios.cs @@ -0,0 +1,185 @@ +using System.Globalization; +using Microsoft.JSInterop; +using RutasDBUS.Modelos.RedBus; +using RutasDBUS.Servicios.Horarios; + +namespace RutasDBUS.Components.Pages; + +public partial class PlanificadorRutas +{ + private IReadOnlyList _tramosHorariosItinerario = + Array.Empty(); + private bool _tramosHorariosItinerarioCargando; + private string _tramosHorariosItinerarioMensaje = ""; + private DateOnly? _tramosHorariosItinerarioFecha; + private int _expedicionesTramosHorarios; + private int? _indiceTramoHorarioResaltado; + private int _versionCargaTramosHorarios; + + private async Task CargarTramosHorariosItinerarioAsync(InfoVariante? variante) + { + var version = ++_versionCargaTramosHorarios; + _tramosHorariosItinerario = Array.Empty(); + _tramosHorariosItinerarioMensaje = ""; + _tramosHorariosItinerarioFecha = null; + _expedicionesTramosHorarios = 0; + _tramosHorariosItinerarioCargando = variante is not null; + await LimpiarResaltadoTramoHorarioAsync(); + + if (variante is null) + return; + + await InvokeAsync(StateHasChanged); + + try + { + await Horarios.AsegurarCargaAsync(Env.ContentRootPath); + var fecha = DateOnly.FromDateTime(AhoraLocal()); + var analisis = Horarios.AnalizarTramosItinerario( + variante.Linea.IdRuta, + variante.Linea.Codigo, + variante.CodigoItinerario, + NormalizarSentidoCodigo(variante.Sentido?.Codigo), + variante.Variante.Codigo, + fecha, + toleranciaSegundos: 60); + + if (version != _versionCargaTramosHorarios) + return; + + _tramosHorariosItinerario = analisis.Tramos; + _tramosHorariosItinerarioFecha = analisis.Fecha; + _expedicionesTramosHorarios = analisis.ExpedicionesAnalizadas; + if (analisis.ExpedicionesAnalizadas == 0) + { + _tramosHorariosItinerarioMensaje = "No hay expediciones activas para esta fecha."; + } + else if (analisis.Tramos.Count == 0) + { + _tramosHorariosItinerarioMensaje = + "No se han detectado tiempos repetidos entre paradas con el margen de 1 minuto."; + } + } + catch (Exception ex) + { + if (version == _versionCargaTramosHorarios) + _tramosHorariosItinerarioMensaje = "No se han podido analizar los tramos: " + ex.Message; + } + finally + { + if (version == _versionCargaTramosHorarios) + { + _tramosHorariosItinerarioCargando = false; + await InvokeAsync(StateHasChanged); + } + } + } + + private async Task AlternarResaltadoTramoHorarioAsync(ResumenTramoHorarioDbus tramo) + { + if (_indiceTramoHorarioResaltado == tramo.IndiceTramo) + { + await LimpiarResaltadoTramoHorarioAsync(); + return; + } + + var variante = GetVarianteRealClicada(); + var indiceTrayecto = BuscarIndiceTrayecto(variante, tramo); + if (variante is null || + indiceTrayecto < 0 || + indiceTrayecto >= variante.Variante.IdsTrayectos.Count || + !RedBusServicio.Trayectos.TryGetValue( + variante.Variante.IdsTrayectos[indiceTrayecto], + out var trayecto) || + trayecto.Posiciones.Count < 2) + { + return; + } + + try + { + await JS.InvokeVoidAsync( + "rt.showItinerarySegmentHighlight", + trayecto.Posiciones, + new { color = "#facc15", haloColor = "#111827" }); + _indiceTramoHorarioResaltado = tramo.IndiceTramo; + } + catch + { + _indiceTramoHorarioResaltado = null; + } + } + + private static int BuscarIndiceTrayecto( + InfoVariante? variante, + ResumenTramoHorarioDbus tramo) + { + if (variante?.CodigosParadas is null || variante.CodigosParadas.Count < 2) + return -1; + + bool Coincide(int indice) => + indice >= 0 && + indice + 1 < variante.CodigosParadas.Count && + string.Equals( + NormalizarCodigoParada(variante.CodigosParadas[indice]), + NormalizarCodigoParada(tramo.CodigoParadaOrigen), + StringComparison.OrdinalIgnoreCase) && + string.Equals( + NormalizarCodigoParada(variante.CodigosParadas[indice + 1]), + NormalizarCodigoParada(tramo.CodigoParadaDestino), + StringComparison.OrdinalIgnoreCase); + + if (Coincide(tramo.IndiceTramo)) + return tramo.IndiceTramo; + + for (var indice = 0; indice < variante.CodigosParadas.Count - 1; indice++) + { + if (Coincide(indice)) + return indice; + } + + return -1; + } + + private async Task LimpiarResaltadoTramoHorarioAsync() + { + _indiceTramoHorarioResaltado = null; + try + { + await JS.InvokeVoidAsync("rt.clearItinerarySegmentHighlight"); + } + catch + { + // El mapa puede no estar inicializado todavía. + } + } + + private async Task CerrarPopupItinerarioAsync() + { + _versionCargaTramosHorarios++; + await LimpiarResaltadoTramoHorarioAsync(); + _itinerarioClicado = null; + _itinerarioClicadoSubId = null; + _tramosHorariosItinerario = Array.Empty(); + _tramosHorariosItinerarioMensaje = ""; + _tramosHorariosItinerarioCargando = false; + } + + private static string FormatearTiempoTramoHorario(int segundos) + { + var tiempo = TimeSpan.FromSeconds(Math.Max(0, segundos)); + return tiempo.TotalHours >= 1 + ? $"{(int)tiempo.TotalHours}:{tiempo.Minutes:00}:{tiempo.Seconds:00}" + : $"{tiempo.Minutes:00}:{tiempo.Seconds:00}"; + } + + private static string FormatearCodigoParadaTramoHorario(string? codigo) + { + var normalizado = NormalizarCodigoParada(codigo); + return string.IsNullOrWhiteSpace(normalizado) ? "-" : $"P-{normalizado}"; + } + + private static string FormatearFrecuenciaTramoHorario(ResumenTramoHorarioDbus tramo) => + $"{tramo.Repeticiones}/{tramo.ExpedicionesAnalizadas} " + + $"({tramo.FrecuenciaPorcentaje.ToString("0.#", CultureInfo.CurrentCulture)} %)"; +} diff --git a/RutasDBUS/Components/Pages/PlanificadorRutas.razor b/RutasDBUS/Components/Pages/PlanificadorRutas.razor index 9dc1b3a..24b6dbf 100644 --- a/RutasDBUS/Components/Pages/PlanificadorRutas.razor +++ b/RutasDBUS/Components/Pages/PlanificadorRutas.razor @@ -304,7 +304,7 @@ aria-pressed="@todosItinerariosParadaPintados" disabled="@_procesandoTodosItinerariosParada" @onclick="ToggleTodosItinerariosParadaAsync"> - + @TextoBotonTodosItinerariosParada @@ -317,10 +317,10 @@ - @if (HorariosTaxibusSimuladosVisibles) + @if (HorariosTaxibusVisibles) {
- Taxibús: @MensajeHorarioTaxibusSimuladoVisible + Taxibús: @MensajeHorarioTaxibusVisible
} @@ -348,7 +348,7 @@ aria-label="@(itinerarioPintado ? "Ocultar itinerario" : "Ver itinerario")" aria-pressed="@itinerarioPintado" @onclick="() => ToggleItinerarioSalidaParadaAsync(x)"> - + } @@ -385,10 +385,66 @@
Recorrido: @ConstruirTextoRecorridoVariante(vReal, incluirCodigosParada: true)
Longitud: @FormatearDistanciaVariante(vReal)
+ +
+
+ Tramos detectados + ± 1 min +
+ + @if (_tramosHorariosItinerarioCargando) + { +
Analizando horarios...
+ } + else if (!string.IsNullOrWhiteSpace(_tramosHorariosItinerarioMensaje)) + { +
@_tramosHorariosItinerarioMensaje
+ } + else + { +
+ @_tramosHorariosItinerario.Count tramo(s) · @_expedicionesTramosHorarios expedición(es) + @if (_tramosHorariosItinerarioFecha is { } fechaTramos) + { + · @fechaTramos.ToString("dd/MM/yyyy", CultureInfo.CurrentCulture) + } +
+
    + @foreach (var tramoHorario in _tramosHorariosItinerario) + { + var tramoHorarioActivo = _indiceTramoHorarioResaltado == tramoHorario.IndiceTramo; + var tituloTiempoTramo = tramoHorario.DuracionMinimaSegundos == tramoHorario.DuracionMaximaSegundos + ? $"Tiempo repetido: {FormatearTiempoTramoHorario(tramoHorario.DuracionHabitualSegundos)}" + : $"Intervalo agrupado: {FormatearTiempoTramoHorario(tramoHorario.DuracionMinimaSegundos)} - {FormatearTiempoTramoHorario(tramoHorario.DuracionMaximaSegundos)}"; +
  1. + + + @tramoHorario.NombreParadaOrigen (@FormatearCodigoParadaTramoHorario(tramoHorario.CodigoParadaOrigen)) + + @tramoHorario.NombreParadaDestino (@FormatearCodigoParadaTramoHorario(tramoHorario.CodigoParadaDestino)) + + + Tiempo: @FormatearTiempoTramoHorario(tramoHorario.DuracionHabitualSegundos) + Frecuencia: @FormatearFrecuenciaTramoHorario(tramoHorario) + + + +
  2. + } +
+ } +
} - @@ -1378,16 +1434,20 @@ @if (_mostrarInformacionDerivacionHistorial && _escenarioBaseDerivacionHistorial is not null && - PasoDerivacionSeleccionado is { } pasoDerivacion) + EscenarioDerivacionSeleccionado is { } escenarioDerivacionSeleccionado) { + var esOriginalDerivacion = _indicePasoDerivacionSeleccionado == -1; + var pasoDerivacion = PasoDerivacionSeleccionado; var cambiosRespectoOriginal = ObtenerCambiosDerivacion( _escenarioBaseDerivacionHistorial, - pasoDerivacion.Escenario); - var escenarioAnterior = ObtenerEscenarioAnteriorDerivacion(pasoDerivacion); - var cambiosRespectoAnterior = ObtenerCambiosDerivacion( - escenarioAnterior, - pasoDerivacion.Escenario); - var textoComparacionAnterior = pasoDerivacion.NumeroAnterior is null + escenarioDerivacionSeleccionado); + var escenarioAnterior = pasoDerivacion is null + ? null + : ObtenerEscenarioAnteriorDerivacion(pasoDerivacion); + var cambiosRespectoAnterior = pasoDerivacion is null + ? Array.Empty() + : ObtenerCambiosDerivacion(escenarioAnterior, pasoDerivacion.Escenario); + var textoComparacionAnterior = pasoDerivacion?.NumeroAnterior is null ? "Respecto al Historial Compartido original" : $"Respecto a la Derivación n.º {pasoDerivacion.NumeroAnterior}"; @@ -1412,6 +1472,12 @@
+ @foreach (var paso in _pasosDerivacionHistorial) { var indicePaso = paso.Numero - 1; @@ -1426,8 +1492,16 @@
- Derivación n.º @pasoDerivacion.Numero - @pasoDerivacion.FechaUtc.ToLocalTime().ToString("dd/MM/yyyy HH:mm:ss") + @if (esOriginalDerivacion) + { + Ruta original + @_escenarioBaseDerivacionHistorial.FechaCreacionUtc.ToLocalTime().ToString("dd/MM/yyyy HH:mm:ss") + } + else if (pasoDerivacion is not null) + { + Derivación n.º @pasoDerivacion.Numero + @pasoDerivacion.FechaUtc.ToLocalTime().ToString("dd/MM/yyyy HH:mm:ss") + }
-
-
Respecto al Historial Compartido original
- @if (cambiosRespectoOriginal.Count == 0) - { -
Sin cambios registrados.
- } - else - { - @foreach (var cambio in cambiosRespectoOriginal) + @if (esOriginalDerivacion) + { +
+
Escenario original
+
+ Punto de partida de todas las derivaciones registradas. +
+
+ } + else + { +
+
Respecto al Historial Compartido original
+ @if (cambiosRespectoOriginal.Count == 0) { -
-
- @cambio.Campo - @if (!string.IsNullOrWhiteSpace(cambio.Detalle)) - { - @cambio.Detalle - } -
-
- Antes -
@cambio.ValorAnterior
-
-
- Ahora -
@cambio.ValorActual
-
-
+
Sin cambios registrados.
} - } -
+ else + { + @foreach (var cambio in cambiosRespectoOriginal) + { +
+
+ @cambio.Campo + @if (!string.IsNullOrWhiteSpace(cambio.Detalle)) + { + @cambio.Detalle + } +
+
+ Antes +
@cambio.ValorAnterior
+
+
+ Ahora +
@cambio.ValorActual
+
+
+ } + } +
+ } - @if (pasoDerivacion.NumeroAnterior is not null) + @if (pasoDerivacion?.NumeroAnterior is not null) {
@textoComparacionAnterior
diff --git a/RutasDBUS/Servicios/Horarios/ServicioHorariosDbus.cs b/RutasDBUS/Servicios/Horarios/ServicioHorariosDbus.cs index 8d082b3..c2aba4b 100644 --- a/RutasDBUS/Servicios/Horarios/ServicioHorariosDbus.cs +++ b/RutasDBUS/Servicios/Horarios/ServicioHorariosDbus.cs @@ -5,6 +5,28 @@ using RutasDBUS.Servicios.Importacion; namespace RutasDBUS.Servicios.Horarios { + public sealed record ResumenTramoHorarioDbus( + int IndiceTramo, + string CodigoParadaOrigen, + string NombreParadaOrigen, + string CodigoParadaDestino, + string NombreParadaDestino, + int DuracionHabitualSegundos, + int DuracionMinimaSegundos, + int DuracionMaximaSegundos, + int Repeticiones, + int ExpedicionesAnalizadas) + { + public double FrecuenciaPorcentaje => ExpedicionesAnalizadas <= 0 + ? 0 + : Repeticiones * 100d / ExpedicionesAnalizadas; + } + + public sealed record AnalisisTramosHorarioDbus( + DateOnly Fecha, + int ExpedicionesAnalizadas, + IReadOnlyList Tramos); + /// /// Lee el ZIP de importación de dBUS (CSV + opcional gtfs.zip) y expone: /// - Calendarios activos para una fecha @@ -280,6 +302,159 @@ namespace RutasDBUS.Servicios.Horarios return active; } + /// + /// Resume los tiempos repetidos entre paradas consecutivas de una variante para una fecha. + /// Dos duraciones pertenecen al mismo grupo cuando su diferencia total no supera la tolerancia. + /// + public AnalisisTramosHorarioDbus AnalizarTramosItinerario( + string? idRuta, + string? codigoLinea, + int codigoItinerario, + string? sentido, + int codigoVariante, + DateOnly fecha, + int toleranciaSegundos = 60) + { + EnsureLoadedOrThrow(); + + toleranciaSegundos = Math.Max(0, toleranciaSegundos); + var calendariosActivos = GetActiveCalendars(fecha); + var idRutaNormalizado = NormalizarLinea(idRuta); + var codigoLineaNormalizado = NormalizarLinea(codigoLinea); + var itinerarioBuscado = codigoItinerario.ToString(CultureInfo.InvariantCulture); + var sentidoBuscado = DireccionToSentidoBusqueda(sentido); + + var expediciones = _expById.Values + .Where(exp => exp.HSalida is not null) + .Where(exp => calendariosActivos.Contains(exp.IdCal)) + .Where(exp => CoincideLineaBusqueda(exp, idRutaNormalizado, codigoLineaNormalizado)) + .Where(exp => string.Equals( + ExtraerItinerarioDesdePatron(exp.IdRuta, exp.IdPatronExp), + itinerarioBuscado, + StringComparison.OrdinalIgnoreCase)) + .Where(exp => sentidoBuscado == "?" || string.Equals( + DireccionToSentido(exp.Direccion), + sentidoBuscado, + StringComparison.OrdinalIgnoreCase)) + .Where(exp => CoincideCodigoVariante(exp.IdPatronExp, codigoVariante)) + .Where(exp => _stopTimesByExp.TryGetValue(exp.IdExp, out var tiempos) && tiempos.Count >= 2) + .ToList(); + + var duracionesPorTramo = new Dictionary<(int Indice, int Origen, int Destino), List>(); + foreach (var expedicion in expediciones) + { + var tiempos = _stopTimesByExp[expedicion.IdExp]; + for (var indice = 0; indice < tiempos.Count - 1; indice++) + { + var actual = tiempos[indice]; + var siguiente = tiempos[indice + 1]; + var duracion = CalcularDuracionEntreParadas(actual.Salida, siguiente.Llegada); + var clave = (indice, actual.StopId, siguiente.StopId); + + if (!duracionesPorTramo.TryGetValue(clave, out var duraciones)) + duracionesPorTramo[clave] = duraciones = new List(); + + duraciones.Add(duracion); + } + } + + var tramos = new List(); + foreach (var entrada in duracionesPorTramo.OrderBy(x => x.Key.Indice)) + { + var grupo = ObtenerGrupoDuracionesDominante(entrada.Value, toleranciaSegundos); + if (grupo.Count < 2) + continue; + + var origen = ObtenerDatosParada(entrada.Key.Origen); + var destino = ObtenerDatosParada(entrada.Key.Destino); + tramos.Add(new ResumenTramoHorarioDbus( + entrada.Key.Indice, + origen.Codigo, + origen.Nombre, + destino.Codigo, + destino.Nombre, + grupo.DuracionHabitual, + grupo.Minimo, + grupo.Maximo, + grupo.Count, + entrada.Value.Count)); + } + + return new AnalisisTramosHorarioDbus(fecha, expediciones.Count, tramos); + } + + private static bool CoincideCodigoVariante(string? idPatronExp, int codigoVariante) + { + if (codigoVariante <= 0) + return true; + + if (int.TryParse(idPatronExp, NumberStyles.Integer, CultureInfo.InvariantCulture, out var codigoPatron)) + return codigoPatron == codigoVariante; + + var match = Regex.Match(idPatronExp ?? "", @"(\d+)$"); + return match.Success && + int.TryParse(match.Groups[1].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out codigoPatron) && + codigoPatron == codigoVariante; + } + + private static int CalcularDuracionEntreParadas(TimeOnly salida, TimeOnly llegada) + { + var segundos = (int)Math.Round((llegada.ToTimeSpan() - salida.ToTimeSpan()).TotalSeconds); + return segundos < 0 ? segundos + 24 * 60 * 60 : segundos; + } + + private (string Codigo, string Nombre) ObtenerDatosParada(int stopId) + { + if (!_stopById.TryGetValue(stopId, out var parada)) + { + var codigoDesconocido = stopId.ToString(CultureInfo.InvariantCulture); + return (codigoDesconocido, $"Parada {codigoDesconocido}"); + } + + var codigo = string.IsNullOrWhiteSpace(parada.CodigoSms) + ? parada.Id.ToString(CultureInfo.InvariantCulture) + : NormalizarCodigoParada(parada.CodigoSms); + var nombre = string.IsNullOrWhiteSpace(parada.Nombre) ? $"Parada {codigo}" : parada.Nombre.Trim(); + return (codigo, nombre); + } + + private static (int DuracionHabitual, int Minimo, int Maximo, int Count) + ObtenerGrupoDuracionesDominante(IReadOnlyCollection duraciones, int toleranciaSegundos) + { + if (duraciones.Count == 0) + return (0, 0, 0, 0); + + var ordenadas = duraciones.OrderBy(x => x).ToArray(); + var mejorInicio = 0; + var mejorFin = 0; + var inicio = 0; + + for (var fin = 0; fin < ordenadas.Length; fin++) + { + while (ordenadas[fin] - ordenadas[inicio] > toleranciaSegundos) + inicio++; + + var cantidadActual = fin - inicio + 1; + var cantidadMejor = mejorFin - mejorInicio + 1; + var amplitudActual = ordenadas[fin] - ordenadas[inicio]; + var amplitudMejor = ordenadas[mejorFin] - ordenadas[mejorInicio]; + if (cantidadActual > cantidadMejor || + (cantidadActual == cantidadMejor && amplitudActual < amplitudMejor)) + { + mejorInicio = inicio; + mejorFin = fin; + } + } + + var cantidad = mejorFin - mejorInicio + 1; + var indiceMediana = mejorInicio + (cantidad - 1) / 2; + return ( + ordenadas[indiceMediana], + ordenadas[mejorInicio], + ordenadas[mejorFin], + cantidad); + } + /// /// Próximas salidas desde una parada (por stopId interno). /// Devuelve: (horaSalidaEnParada, expId, rutaId) diff --git a/RutasDBUS/Servicios/Importacion/HorariosOficialesTaxibus.cs b/RutasDBUS/Servicios/Importacion/HorariosOficialesTaxibus.cs new file mode 100644 index 0000000..cddc761 --- /dev/null +++ b/RutasDBUS/Servicios/Importacion/HorariosOficialesTaxibus.cs @@ -0,0 +1,677 @@ +using RutasDBUS.Modelos.Importacion; + +namespace RutasDBUS.Servicios.Importacion; + +/// +/// Integra los horarios oficiales publicados por Dbus cuando el ZIP solo contiene +/// las plantillas de recorrido de TB6 y TB7. +/// Fuentes: https://dbus.eus/es/tb6-tb6-taxibus-ulia/ y +/// https://dbus.eus/es/tb7-taxibus-atotxaerreka-paseo-del-faro/ (consultadas el 17/09/2026). +/// +internal static class HorariosOficialesTaxibus +{ + private const string PrefijoCalendario = "TAXIBUS_OFICIAL_CAL_"; + private const string IdCalendarioTodosLosDias = PrefijoCalendario + "TODOS"; + private const string IdCalendarioLaborables = PrefijoCalendario + "LAB"; + private const string IdCalendarioSabados = PrefijoCalendario + "SAB"; + private const string IdCalendarioFestivos = PrefijoCalendario + "FEST"; + + private const int Activo = 2; + private const int Inactivo = 1; + + public static IReadOnlySet AplicarSiFaltanHorarios( + List calendarios, + List calendarioExcepciones, + List expediciones, + IReadOnlyList rutas, + List tiemposParada) + { + var rutasIntegradas = new HashSet(StringComparer.OrdinalIgnoreCase); + var calendariosOriginales = calendarios.ToList(); + var excepcionesOriginales = calendarioExcepciones.ToList(); + var rangoModelo = ObtenerRangoModelo(calendariosOriginales); + var calendariosVerano = ObtenerCalendariosVerano(rangoModelo); + var tiemposOriginalesPorExpedicion = tiemposParada + .GroupBy(tiempo => tiempo.IdExp, StringComparer.OrdinalIgnoreCase) + .ToDictionary( + grupo => grupo.Key, + grupo => grupo.OrderBy(tiempo => tiempo.SecPar).ToList(), + StringComparer.OrdinalIgnoreCase); + + foreach (var ruta in rutas.Where(EsRutaTaxibus)) + { + var expedicionesRuta = expediciones + .Where(expedicion => string.Equals( + expedicion.IdRuta, + ruta.Id, + StringComparison.OrdinalIgnoreCase)) + .OrderBy(expedicion => expedicion.Id, StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (expedicionesRuta.Count == 0 || + TieneHorarioOficial(expedicionesRuta, tiemposOriginalesPorExpedicion)) + { + continue; + } + + var plantillas = expedicionesRuta + .Select(expedicion => new PlantillaTaxibus( + expedicion, + tiemposOriginalesPorExpedicion.TryGetValue(expedicion.Id, out var tiempos) + ? tiempos + : new List())) + .Where(plantilla => plantilla.Tiempos.Count >= 2) + .ToList(); + + if (plantillas.Count == 0 || !SonPlantillasCompatibles(ruta, plantillas)) + continue; + + var idsPlantilla = expedicionesRuta + .Select(expedicion => expedicion.Id) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + expediciones.RemoveAll(expedicion => idsPlantilla.Contains(expedicion.Id)); + tiemposParada.RemoveAll(tiempo => idsPlantilla.Contains(tiempo.IdExp)); + + if (TaxibusDbus.EsLineaTb6(ruta.Abreviatura) || TaxibusDbus.EsLineaTb6(ruta.Id)) + { + CrearHorariosTb6( + ruta, + plantillas, + calendariosVerano, + expediciones, + tiemposParada); + } + else + { + CrearHorariosTb7( + ruta, + plantillas, + calendariosVerano, + expediciones, + tiemposParada); + } + + rutasIntegradas.Add(ruta.Id); + } + + if (rutasIntegradas.Count > 0) + { + ConfigurarCalendariosOficiales( + calendarios, + calendarioExcepciones, + calendariosOriginales, + excepcionesOriginales, + rangoModelo, + calendariosVerano); + } + + return rutasIntegradas; + } + + private static bool EsRutaTaxibus(FilaRutaCsv ruta) + => TaxibusDbus.EsLinea(ruta.Abreviatura) || TaxibusDbus.EsLinea(ruta.Id); + + private static bool SonPlantillasCompatibles( + FilaRutaCsv ruta, + IReadOnlyCollection plantillas) + { + var primerasParadas = plantillas + .Select(plantilla => plantilla.Tiempos[0].IdTiempoPar) + .ToHashSet(); + + if (TaxibusDbus.EsLineaTb6(ruta.Abreviatura) || TaxibusDbus.EsLineaTb6(ruta.Id)) + return primerasParadas.SetEquals(new[] { 372, 521 }); + + return primerasParadas.SetEquals(new[] { 240, 599, 42, 625 }); + } + + private static bool TieneHorarioOficial( + IReadOnlyList expediciones, + IReadOnlyDictionary> tiemposPorExpedicion) + { + foreach (var expedicion in expediciones) + { + if (!string.IsNullOrWhiteSpace(expedicion.IdCal) && + !expedicion.IdCal.Equals("0000", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + if (expedicion.HSalida is { } salida && salida != TimeOnly.MinValue) + return true; + + if (!tiemposPorExpedicion.TryGetValue(expedicion.Id, out var tiempos)) + continue; + + if (tiempos.Any(tiempo => + EsHoraReal(tiempo.HoraLlegada) || EsHoraReal(tiempo.HoraSalida))) + { + return true; + } + } + + return false; + } + + private static bool EsHoraReal(TimeOnly? hora) + => hora is { } valor && valor != TimeOnly.MinValue; + + private static void CrearHorariosTb6( + FilaRutaCsv ruta, + IReadOnlyList plantillas, + IReadOnlyDictionary calendariosVerano, + ICollection expediciones, + ICollection tiemposParada) + { + foreach (var plantilla in plantillas) + { + var primeraParada = plantilla.Tiempos[0].IdTiempoPar; + var plan = ObtenerPlanRegular(primeraParada)!; + + if (primeraParada == 372) + { + CrearSalidasCadaHora( + ruta, + plantilla, + plan, + 7 * 60, + 21 * 60, + IdCalendarioTodosLosDias, + "REG", + expediciones, + tiemposParada); + + foreach (var (anio, idCalendario) in calendariosVerano) + { + CrearExpedicion( + ruta, + plantilla, + plan, + 22 * 60, + idCalendario, + $"VER_{anio}", + expediciones, + tiemposParada); + } + } + else + { + CrearSalidasCadaHora( + ruta, + plantilla, + plan, + 7 * 60 + 40, + 21 * 60 + 40, + IdCalendarioTodosLosDias, + "REG", + expediciones, + tiemposParada); + } + } + } + + private static void CrearHorariosTb7( + FilaRutaCsv ruta, + IReadOnlyList plantillas, + IReadOnlyDictionary calendariosVerano, + ICollection expediciones, + ICollection tiemposParada) + { + foreach (var plantilla in plantillas) + { + var primeraParada = plantilla.Tiempos[0].IdTiempoPar; + var plan = ObtenerPlanRegular(primeraParada)!; + + switch (primeraParada) + { + case 240: + CrearSalidasPorTipoDia(ruta, plantilla, plan, 7, 8, 9, 21, 15, expediciones, tiemposParada); + foreach (var (anio, idCalendario) in calendariosVerano) + { + CrearExpedicion( + ruta, + plantilla, + ObtenerPlanTb7VeranoAtotxaerreka(), + 22 * 60 + 15, + idCalendario, + $"VER_{anio}", + expediciones, + tiemposParada); + } + break; + + case 599: + CrearSalidasPorTipoDia(ruta, plantilla, plan, 7, 8, 9, 21, 30, expediciones, tiemposParada); + break; + + case 42: + CrearSalidasPorTipoDia(ruta, plantilla, plan, 7, 8, 9, 20, 45, expediciones, tiemposParada); + foreach (var (anio, idCalendario) in calendariosVerano) + { + CrearExpedicion( + ruta, + plantilla, + plan, + 21 * 60 + 45, + idCalendario, + $"VER_{anio}", + expediciones, + tiemposParada); + } + break; + + case 625: + CrearSalidasPorTipoDia(ruta, plantilla, plan, 8, 9, 10, 21, 5, expediciones, tiemposParada); + break; + } + } + } + + private static void CrearSalidasPorTipoDia( + FilaRutaCsv ruta, + PlantillaTaxibus plantilla, + PlanHorario plan, + int primeraHoraLaborable, + int primeraHoraSabado, + int primeraHoraFestivo, + int ultimaHora, + int minuto, + ICollection expediciones, + ICollection tiemposParada) + { + CrearSalidasCadaHora( + ruta, + plantilla, + plan, + primeraHoraLaborable * 60 + minuto, + ultimaHora * 60 + minuto, + IdCalendarioLaborables, + "LAB", + expediciones, + tiemposParada); + CrearSalidasCadaHora( + ruta, + plantilla, + plan, + primeraHoraSabado * 60 + minuto, + ultimaHora * 60 + minuto, + IdCalendarioSabados, + "SAB", + expediciones, + tiemposParada); + CrearSalidasCadaHora( + ruta, + plantilla, + plan, + primeraHoraFestivo * 60 + minuto, + ultimaHora * 60 + minuto, + IdCalendarioFestivos, + "FEST", + expediciones, + tiemposParada); + } + + private static void CrearSalidasCadaHora( + FilaRutaCsv ruta, + PlantillaTaxibus plantilla, + PlanHorario plan, + int primerMinuto, + int ultimoMinuto, + string idCalendario, + string serie, + ICollection expediciones, + ICollection tiemposParada) + { + for (var minuto = primerMinuto; minuto <= ultimoMinuto; minuto += 60) + { + CrearExpedicion( + ruta, + plantilla, + plan, + minuto, + idCalendario, + serie, + expediciones, + tiemposParada); + } + } + + private static void CrearExpedicion( + FilaRutaCsv ruta, + PlantillaTaxibus plantilla, + PlanHorario plan, + int minutoInicio, + string idCalendario, + string serie, + ICollection expediciones, + ICollection tiemposParada) + { + var tiemposPlantilla = RecortarTiempos(plantilla.Tiempos, plan.IdUltimaParada); + var desplazamientos = CalcularDesplazamientos(tiemposPlantilla, plan); + var horaInicio = HoraDesdeSegundos(minutoInicio * 60); + var idExpedicion = + $"{TaxibusDbus.PrefijoExpedicionHorarioOficial}{ruta.Id}_{plan.IdPrimeraParada}_{serie}_{minutoInicio:0000}"; + + expediciones.Add(new FilaExpedicionCsv( + idExpedicion, + plantilla.Expedicion.IdRuta, + plan.Destino, + plantilla.Expedicion.Direccion, + plantilla.Expedicion.CambioExp, + plantilla.Expedicion.IdPatronExp, + horaInicio, + idCalendario)); + + for (var indice = 0; indice < tiemposPlantilla.Count; indice++) + { + var tiempoPlantilla = tiemposPlantilla[indice]; + var horaPaso = HoraDesdeSegundos(minutoInicio * 60 + desplazamientos[indice]); + + tiemposParada.Add(new FilaTiempoParadaCsv( + idExpedicion, + horaPaso, + horaPaso, + indice == tiemposPlantilla.Count - 1 + ? 0 + : tiempoPlantilla.DistanciaSiguienteParada, + tiempoPlantilla.IdTiempoPar, + tiempoPlantilla.SecPar, + tiempoPlantilla.TipoSubidaPar, + tiempoPlantilla.TipoBajadaPar, + tiempoPlantilla.NuevaDirDestinoPar)); + } + } + + private static List RecortarTiempos( + IReadOnlyList tiempos, + int idUltimaParada) + { + var indiceFinal = -1; + for (var indice = 0; indice < tiempos.Count; indice++) + { + if (tiempos[indice].IdTiempoPar == idUltimaParada) + { + indiceFinal = indice; + break; + } + } + + return indiceFinal >= 0 + ? tiempos.Take(indiceFinal + 1).ToList() + : tiempos.ToList(); + } + + private static int[] CalcularDesplazamientos( + IReadOnlyList tiempos, + PlanHorario plan) + { + var acumuladas = new double[tiempos.Count]; + for (var indice = 1; indice < tiempos.Count; indice++) + { + acumuladas[indice] = acumuladas[indice - 1] + + Math.Max(0, tiempos[indice - 1].DistanciaSiguienteParada); + } + + var anclas = new SortedDictionary(); + foreach (var (idParada, minuto) in plan.AnclasMinutos) + { + for (var indice = 0; indice < tiempos.Count; indice++) + { + if (tiempos[indice].IdTiempoPar == idParada) + { + anclas[indice] = minuto * 60; + break; + } + } + } + + anclas[0] = 0; + anclas[tiempos.Count - 1] = plan.DuracionMinutos * 60; + + var desplazamientos = new int[tiempos.Count]; + var pares = anclas.ToList(); + for (var indiceAncla = 0; indiceAncla < pares.Count - 1; indiceAncla++) + { + var inicio = pares[indiceAncla]; + var fin = pares[indiceAncla + 1]; + var distanciaTramo = acumuladas[fin.Key] - acumuladas[inicio.Key]; + var numeroPasos = Math.Max(1, fin.Key - inicio.Key); + + for (var indice = inicio.Key; indice <= fin.Key; indice++) + { + var proporcion = distanciaTramo > 0 + ? (acumuladas[indice] - acumuladas[inicio.Key]) / distanciaTramo + : (double)(indice - inicio.Key) / numeroPasos; + desplazamientos[indice] = inicio.Value + (int)Math.Round( + (fin.Value - inicio.Value) * proporcion, + MidpointRounding.AwayFromZero); + } + } + + return desplazamientos; + } + + private static PlanHorario? ObtenerPlanRegular(int primeraParada) + => primeraParada switch + { + 372 => new PlanHorario(372, 521, 20, "Albergue Ulia", new Dictionary + { + [372] = 0, + [346] = 10, + [521] = 20 + }), + 521 => new PlanHorario(521, 372, 20, "Colón 21", new Dictionary + { + [521] = 0, + [457] = 10, + [372] = 20 + }), + 240 => new PlanHorario(240, 599, 15, "Añorga", new Dictionary + { + [240] = 0, + [617] = 8, + [599] = 15 + }), + 599 => new PlanHorario(599, 42, 15, "Antiguo", new Dictionary + { + [599] = 0, + [343] = 2, + [42] = 15 + }), + 42 => new PlanHorario(42, 625, 20, "Paseo del Faro", new Dictionary + { + [42] = 0, + [625] = 20 + }), + 625 => new PlanHorario(625, 240, 10, "Antiguo", new Dictionary + { + [625] = 0, + [240] = 10 + }), + _ => null + }; + + private static PlanHorario ObtenerPlanTb7VeranoAtotxaerreka() + => new(240, 620, 11, "Atotxaerreka", new Dictionary + { + [240] = 0, + [617] = 8, + [620] = 11 + }); + + private static void ConfigurarCalendariosOficiales( + List calendarios, + List calendarioExcepciones, + IReadOnlyList calendariosOriginales, + IReadOnlyList excepcionesOriginales, + RangoFechas rangoModelo, + IReadOnlyDictionary calendariosVerano) + { + calendarios.RemoveAll(calendario => + calendario.Id.StartsWith(PrefijoCalendario, StringComparison.OrdinalIgnoreCase)); + calendarioExcepciones.RemoveAll(excepcion => + excepcion.Id.StartsWith(PrefijoCalendario, StringComparison.OrdinalIgnoreCase)); + + calendarios.Add(new FilaCalendarioCsv( + IdCalendarioTodosLosDias, + Activo, Activo, Activo, Activo, Activo, Activo, Activo, + rangoModelo.Inicio, + rangoModelo.Fin)); + calendarios.Add(new FilaCalendarioCsv( + IdCalendarioLaborables, + Activo, Activo, Activo, Activo, Activo, Inactivo, Inactivo, + rangoModelo.Inicio, + rangoModelo.Fin)); + calendarios.Add(new FilaCalendarioCsv( + IdCalendarioSabados, + Inactivo, Inactivo, Inactivo, Inactivo, Inactivo, Activo, Inactivo, + rangoModelo.Inicio, + rangoModelo.Fin)); + calendarios.Add(new FilaCalendarioCsv( + IdCalendarioFestivos, + Inactivo, Inactivo, Inactivo, Inactivo, Inactivo, Inactivo, Activo, + rangoModelo.Inicio, + rangoModelo.Fin)); + + foreach (var (anio, idCalendario) in calendariosVerano) + { + var inicio = Max(rangoModelo.Inicio, new DateOnly(anio, 6, 23)); + var fin = Min(rangoModelo.Fin, new DateOnly(anio, 8, 31)); + calendarios.Add(new FilaCalendarioCsv( + idCalendario, + Activo, Activo, Activo, Activo, Activo, Activo, Activo, + inicio, + fin)); + } + + AgregarExcepcionesFestivos( + calendarioExcepciones, + calendariosOriginales, + excepcionesOriginales, + rangoModelo); + } + + private static void AgregarExcepcionesFestivos( + ICollection destino, + IReadOnlyList calendariosOriginales, + IReadOnlyList excepcionesOriginales, + RangoFechas rangoModelo) + { + var calendariosPorId = calendariosOriginales + .GroupBy(calendario => calendario.Id, StringComparer.OrdinalIgnoreCase) + .ToDictionary(grupo => grupo.Key, grupo => grupo.First(), StringComparer.OrdinalIgnoreCase); + var existentes = destino + .Select(excepcion => (excepcion.Id, excepcion.FechaEx, excepcion.TipoExcepcion)) + .ToHashSet(); + + var festivosEntreSemana = excepcionesOriginales + .Where(excepcion => excepcion.TipoExcepcion == 1 && + excepcion.FechaEx >= rangoModelo.Inicio && + excepcion.FechaEx <= rangoModelo.Fin) + .GroupBy(excepcion => excepcion.FechaEx) + .Where(grupo => grupo.Key.DayOfWeek != DayOfWeek.Sunday && grupo.Any(excepcion => + calendariosPorId.TryGetValue(excepcion.Id, out var calendario) && + calendario.Domingo == Activo && + ObtenerFlagDia(calendario, grupo.Key.DayOfWeek) != Activo)) + .Select(grupo => grupo.Key) + .Distinct() + .ToList(); + + foreach (var fecha in festivosEntreSemana) + { + var calendarioBase = fecha.DayOfWeek == DayOfWeek.Saturday + ? IdCalendarioSabados + : IdCalendarioLaborables; + AgregarExcepcion(destino, existentes, calendarioBase, fecha, 2); + AgregarExcepcion(destino, existentes, IdCalendarioFestivos, fecha, 1); + } + } + + private static void AgregarExcepcion( + ICollection destino, + ISet<(string Id, DateOnly FechaEx, int TipoExcepcion)> existentes, + string idCalendario, + DateOnly fecha, + int tipo) + { + if (!existentes.Add((idCalendario, fecha, tipo))) + return; + + destino.Add(new FilaCalendarioExcepcionCsv(idCalendario, fecha, tipo)); + } + + private static int ObtenerFlagDia(FilaCalendarioCsv calendario, DayOfWeek dia) + => dia switch + { + DayOfWeek.Monday => calendario.Lunes, + DayOfWeek.Tuesday => calendario.Martes, + DayOfWeek.Wednesday => calendario.Miercoles, + DayOfWeek.Thursday => calendario.Jueves, + DayOfWeek.Friday => calendario.Viernes, + DayOfWeek.Saturday => calendario.Sabado, + DayOfWeek.Sunday => calendario.Domingo, + _ => Inactivo + }; + + private static RangoFechas ObtenerRangoModelo(IReadOnlyList calendarios) + { + var validos = calendarios + .Where(calendario => + !calendario.Id.Equals("0000", StringComparison.OrdinalIgnoreCase) && + calendario.FechaInicio != default && + calendario.FechaFin >= calendario.FechaInicio) + .ToList(); + + if (validos.Count == 0) + { + var anio = DateTime.Today.Year; + return new RangoFechas(new DateOnly(anio, 1, 1), new DateOnly(anio, 12, 31)); + } + + return new RangoFechas( + validos.Min(calendario => calendario.FechaInicio), + validos.Max(calendario => calendario.FechaFin)); + } + + private static IReadOnlyDictionary ObtenerCalendariosVerano(RangoFechas rango) + { + var resultado = new Dictionary(); + for (var anio = rango.Inicio.Year; anio <= rango.Fin.Year; anio++) + { + var inicioVerano = new DateOnly(anio, 6, 23); + var finVerano = new DateOnly(anio, 8, 31); + if (finVerano < rango.Inicio || inicioVerano > rango.Fin) + continue; + + resultado[anio] = $"{PrefijoCalendario}VERANO_{anio}"; + } + + return resultado; + } + + private static DateOnly Max(DateOnly izquierda, DateOnly derecha) + => izquierda >= derecha ? izquierda : derecha; + + private static DateOnly Min(DateOnly izquierda, DateOnly derecha) + => izquierda <= derecha ? izquierda : derecha; + + private static TimeOnly HoraDesdeSegundos(int segundos) + { + var segundosDia = ((segundos % (24 * 3600)) + (24 * 3600)) % (24 * 3600); + return TimeOnly.FromTimeSpan(TimeSpan.FromSeconds(segundosDia)); + } + + private sealed record PlantillaTaxibus( + FilaExpedicionCsv Expedicion, + IReadOnlyList Tiempos); + + private sealed record PlanHorario( + int IdPrimeraParada, + int IdUltimaParada, + int DuracionMinutos, + string Destino, + IReadOnlyDictionary AnclasMinutos); + + private sealed record RangoFechas(DateOnly Inicio, DateOnly Fin); +} diff --git a/RutasDBUS/Servicios/Importacion/ServicioArchivoImportacionDbus.cs b/RutasDBUS/Servicios/Importacion/ServicioArchivoImportacionDbus.cs index 102742c..a22055f 100644 --- a/RutasDBUS/Servicios/Importacion/ServicioArchivoImportacionDbus.cs +++ b/RutasDBUS/Servicios/Importacion/ServicioArchivoImportacionDbus.cs @@ -109,8 +109,9 @@ public sealed class ServicioArchivoImportacionDbus var tiemposReales = await ReadTiemposRealesAsync(FindEntry(zip, "tiempo_real.csv"), ct); var transbordos = await ReadTransbordosAsync(FindEntry(zip, "transbordos.csv"), ct); var vehiculos = await ReadVehiculosAsync(FindEntry(zip, "vehiculos.csv"), ct); - var rutasConHorarioSimulado = SimuladorHorariosTaxibus.AplicarSiFaltanHorarios( + HorariosOficialesTaxibus.AplicarSiFaltanHorarios( calendarios, + calendarioEx, expediciones, rutas, tiemposParada); @@ -131,7 +132,6 @@ public sealed class ServicioArchivoImportacionDbus TiemposReales = tiemposReales, Transbordos = transbordos, Vehiculos = vehiculos, - RutasConHorarioSimulado = rutasConHorarioSimulado, ParadasPorId = paradas.ToDictionary(x => x.Id), RutasPorId = rutas.ToDictionary(x => x.Id, StringComparer.OrdinalIgnoreCase), ExpedicionesPorId = expediciones.ToDictionary(x => x.Id, StringComparer.OrdinalIgnoreCase), diff --git a/RutasDBUS/Servicios/Importacion/SimuladorHorariosTaxibus.cs b/RutasDBUS/Servicios/Importacion/SimuladorHorariosTaxibus.cs deleted file mode 100644 index e35f60b..0000000 --- a/RutasDBUS/Servicios/Importacion/SimuladorHorariosTaxibus.cs +++ /dev/null @@ -1,212 +0,0 @@ -using RutasDBUS.Modelos.Importacion; - -namespace RutasDBUS.Servicios.Importacion; - -/// -/// Completa las plantillas sin horario de TB6 y TB7 con una frecuencia provisional. -/// -internal static class SimuladorHorariosTaxibus -{ - private const string IdCalendario = "TAXIBUS_SIM_TODOS_LOS_DIAS"; - private const int FrecuenciaMinutosTb6 = 10; - private const int FrecuenciaMinutosRestoTaxibus = 30; - private const int MinutoPrimeraSalida = 7 * 60; - private const int MinutoFinServicioExclusivoTb6 = 22 * 60; - private const int MinutoFinServicioExclusivoRestoTaxibus = 24 * 60; - private const double VelocidadMetrosSegundo = 16_000.0 / 3_600.0; - private const int SegundosMinimosEntreParadas = 30; - - public static IReadOnlySet AplicarSiFaltanHorarios( - List calendarios, - List expediciones, - IReadOnlyList rutas, - List tiemposParada) - { - var rutasSimuladas = new HashSet(StringComparer.OrdinalIgnoreCase); - var tiemposOriginalesPorExpedicion = tiemposParada - .GroupBy(tiempo => tiempo.IdExp, StringComparer.OrdinalIgnoreCase) - .ToDictionary( - grupo => grupo.Key, - grupo => grupo.OrderBy(tiempo => tiempo.SecPar).ToList(), - StringComparer.OrdinalIgnoreCase); - - foreach (var ruta in rutas.Where(EsRutaTaxibus)) - { - var expedicionesRuta = expediciones - .Where(expedicion => string.Equals( - expedicion.IdRuta, - ruta.Id, - StringComparison.OrdinalIgnoreCase)) - .OrderBy(expedicion => expedicion.Id, StringComparer.OrdinalIgnoreCase) - .ToList(); - - if (expedicionesRuta.Count == 0 || - TieneHorarioOficial(expedicionesRuta, tiemposOriginalesPorExpedicion)) - { - continue; - } - - var plantillas = expedicionesRuta - .Select(expedicion => new PlantillaTaxibus( - expedicion, - tiemposOriginalesPorExpedicion.TryGetValue(expedicion.Id, out var tiempos) - ? tiempos - : new List())) - .Where(plantilla => plantilla.Tiempos.Count >= 2) - .ToList(); - - if (plantillas.Count == 0) - continue; - - var idsPlantilla = expedicionesRuta - .Select(expedicion => expedicion.Id) - .ToHashSet(StringComparer.OrdinalIgnoreCase); - - expediciones.RemoveAll(expedicion => idsPlantilla.Contains(expedicion.Id)); - tiemposParada.RemoveAll(tiempo => idsPlantilla.Contains(tiempo.IdExp)); - - for (var indicePlantilla = 0; indicePlantilla < plantillas.Count; indicePlantilla++) - { - var esTb6 = TaxibusDbus.EsLineaTb6(ruta.Abreviatura) || - TaxibusDbus.EsLineaTb6(ruta.Id); - CrearExpedicionesSimuladas( - ruta, - plantillas[indicePlantilla], - indicePlantilla, - esTb6 ? FrecuenciaMinutosTb6 : FrecuenciaMinutosRestoTaxibus, - esTb6 ? MinutoFinServicioExclusivoTb6 : MinutoFinServicioExclusivoRestoTaxibus, - expediciones, - tiemposParada); - } - - rutasSimuladas.Add(ruta.Id); - } - - if (rutasSimuladas.Count > 0) - { - calendarios.RemoveAll(calendario => - calendario.Id.Equals(IdCalendario, StringComparison.OrdinalIgnoreCase)); - calendarios.Add(new FilaCalendarioCsv( - IdCalendario, - 2, - 2, - 2, - 2, - 2, - 2, - 2, - new DateOnly(2000, 1, 1), - new DateOnly(2099, 12, 31))); - } - - return rutasSimuladas; - } - - private static bool EsRutaTaxibus(FilaRutaCsv ruta) - => TaxibusDbus.EsLinea(ruta.Abreviatura) || TaxibusDbus.EsLinea(ruta.Id); - - private static bool TieneHorarioOficial( - IReadOnlyList expediciones, - IReadOnlyDictionary> tiemposPorExpedicion) - { - foreach (var expedicion in expediciones) - { - if (!string.IsNullOrWhiteSpace(expedicion.IdCal) && - !expedicion.IdCal.Equals("0000", StringComparison.OrdinalIgnoreCase)) - { - return true; - } - - if (expedicion.HSalida is { } salida && salida != TimeOnly.MinValue) - return true; - - if (!tiemposPorExpedicion.TryGetValue(expedicion.Id, out var tiempos)) - continue; - - if (tiempos.Any(tiempo => - EsHoraReal(tiempo.HoraLlegada) || EsHoraReal(tiempo.HoraSalida))) - { - return true; - } - } - - return false; - } - - private static bool EsHoraReal(TimeOnly? hora) - => hora is { } valor && valor != TimeOnly.MinValue; - - private static void CrearExpedicionesSimuladas( - FilaRutaCsv ruta, - PlantillaTaxibus plantilla, - int indicePlantilla, - int frecuenciaMinutos, - int minutoFinServicioExclusivo, - ICollection expediciones, - ICollection tiemposParada) - { - var indiceSalida = 0; - for (var minutoInicio = MinutoPrimeraSalida; - minutoInicio < minutoFinServicioExclusivo; - minutoInicio += frecuenciaMinutos, indiceSalida++) - { - var segundosInicio = minutoInicio * 60; - var horaInicio = HoraDesdeSegundos(segundosInicio); - var idExpedicion = - $"{TaxibusDbus.PrefijoExpedicionSimulada}{ruta.Id}_{indicePlantilla:00}_{indiceSalida:00}"; - - expediciones.Add(new FilaExpedicionCsv( - idExpedicion, - plantilla.Expedicion.IdRuta, - plantilla.Expedicion.DirDestino, - plantilla.Expedicion.Direccion, - plantilla.Expedicion.CambioExp, - plantilla.Expedicion.IdPatronExp, - horaInicio, - IdCalendario)); - - var segundosPaso = segundosInicio; - for (var indiceParada = 0; indiceParada < plantilla.Tiempos.Count; indiceParada++) - { - var tiempoPlantilla = plantilla.Tiempos[indiceParada]; - var horaPaso = HoraDesdeSegundos(segundosPaso); - - tiemposParada.Add(new FilaTiempoParadaCsv( - idExpedicion, - horaPaso, - horaPaso, - tiempoPlantilla.DistanciaSiguienteParada, - tiempoPlantilla.IdTiempoPar, - tiempoPlantilla.SecPar, - tiempoPlantilla.TipoSubidaPar, - tiempoPlantilla.TipoBajadaPar, - tiempoPlantilla.NuevaDirDestinoPar)); - - if (indiceParada < plantilla.Tiempos.Count - 1) - segundosPaso += CalcularSegundosEntreParadas(tiempoPlantilla.DistanciaSiguienteParada); - } - } - } - - private static int CalcularSegundosEntreParadas(double distanciaMetros) - { - if (!double.IsFinite(distanciaMetros) || distanciaMetros <= 0) - return SegundosMinimosEntreParadas; - - return Math.Max( - SegundosMinimosEntreParadas, - (int)Math.Round( - distanciaMetros / VelocidadMetrosSegundo, - MidpointRounding.AwayFromZero)); - } - - private static TimeOnly HoraDesdeSegundos(int segundos) - { - var segundosDia = ((segundos % (24 * 3600)) + (24 * 3600)) % (24 * 3600); - return TimeOnly.FromTimeSpan(TimeSpan.FromSeconds(segundosDia)); - } - - private sealed record PlantillaTaxibus( - FilaExpedicionCsv Expedicion, - IReadOnlyList Tiempos); -} diff --git a/RutasDBUS/Servicios/TaxibusDbus.cs b/RutasDBUS/Servicios/TaxibusDbus.cs index e29690c..216f7eb 100644 --- a/RutasDBUS/Servicios/TaxibusDbus.cs +++ b/RutasDBUS/Servicios/TaxibusDbus.cs @@ -5,12 +5,9 @@ namespace RutasDBUS.Servicios; /// public static class TaxibusDbus { - public const string PrefijoExpedicionSimulada = "TAXIBUS_SIM_"; - public const string MensajeReserva = "Esta l\u00ednea requiere solicitud de RESERVA."; - public const string MensajeHorarioSimuladoTb6 = - "Horario provisional simulado cada 10 min, de 07:00 a 22:00. Esta l\u00ednea requiere solicitud de RESERVA."; - public const string MensajeHorarioSimulado = - "Horario provisional simulado cada 30 min, de 07:00 a 00:00. Esta l\u00ednea requiere solicitud de RESERVA."; + public const string PrefijoExpedicionHorarioOficial = "TAXIBUS_OFICIAL_"; + public const string MensajeReserva = + "Horario oficial. Esta l\u00ednea requiere solicitud de RESERVA."; public static bool EsLinea(string? codigo) => EsLineaTb6(codigo) || EsLineaTb7(codigo); @@ -22,23 +19,7 @@ public static class TaxibusDbus normalizado.Equals("6", StringComparison.OrdinalIgnoreCase); } - public static string ObtenerMensajeHorarioSimulado(IEnumerable codigosLinea) - { - var codigos = codigosLinea.Where(codigo => !string.IsNullOrWhiteSpace(codigo)).ToList(); - var mensajes = new List(2); - - if (codigos.Any(EsLineaTb6)) - mensajes.Add(MensajeHorarioSimuladoTb6); - - if (codigos.Any(codigo => EsLineaTb7(codigo))) - mensajes.Add(MensajeHorarioSimulado); - - return mensajes.Count > 0 - ? string.Join(" ", mensajes) - : MensajeHorarioSimulado; - } - - private static bool EsLineaTb7(string? codigo) + public static bool EsLineaTb7(string? codigo) { var normalizado = NormalizarCodigoLinea(codigo); return normalizado.Equals("TB7", StringComparison.OrdinalIgnoreCase) || @@ -58,7 +39,7 @@ public static class TaxibusDbus return normalizado; } - public static bool EsExpedicionSimulada(string? idExpedicion) + public static bool EsExpedicionHorarioOficial(string? idExpedicion) => !string.IsNullOrWhiteSpace(idExpedicion) && - idExpedicion.StartsWith(PrefijoExpedicionSimulada, StringComparison.OrdinalIgnoreCase); + idExpedicion.StartsWith(PrefijoExpedicionHorarioOficial, StringComparison.OrdinalIgnoreCase); } diff --git a/RutasDBUS/wwwroot/app.css b/RutasDBUS/wwwroot/app.css index df7943e..a9ffaf2 100644 --- a/RutasDBUS/wwwroot/app.css +++ b/RutasDBUS/wwwroot/app.css @@ -775,7 +775,93 @@ html, body { /* Itinerario: bajar texto para que no choque con la X */ .popup-stack-left .popup-itinerario-body { padding-right: 2.2rem; + max-height: min(62vh, 620px); + overflow-y: auto; +} +.itinerary-segments { + margin-top: .65rem; + padding-top: .55rem; + border-top: 1px solid rgba(148, 163, 184, .28); +} + +.itinerary-segments__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: .5rem; + margin-bottom: .18rem; +} + +.itinerary-segments__tolerance { + flex: 0 0 auto; + padding: 2px 5px; + border: 1px solid rgba(56, 189, 248, .45); + border-radius: 5px; + color: #bae6fd; + font-size: .68rem; + line-height: 1; +} + +.itinerary-segments__summary, +.itinerary-segments__status { + margin-bottom: .35rem; + color: #94a3b8; + font-size: .7rem; + line-height: 1.3; +} + +.itinerary-segments__list { + display: grid; + gap: 3px; + margin: 0; + padding: 0; + list-style: none; +} + +.itinerary-segment-row { + display: grid; + grid-template-columns: minmax(0, 1fr) 26px; + align-items: center; + gap: 6px; + min-height: 38px; + padding: 4px 3px 4px 6px; + border-left: 2px solid transparent; + border-radius: 5px; + background: rgba(30, 41, 59, .52); +} + +.itinerary-segment-row.is-active { + border-left-color: #facc15; + background: rgba(250, 204, 21, .11); +} + +.itinerary-segment-row__content, +.itinerary-segment-row__stops, +.itinerary-segment-row__meta { + display: block; + min-width: 0; +} + +.itinerary-segment-row__stops { + color: #e2e8f0; + font-size: .72rem; + line-height: 1.25; + overflow-wrap: anywhere; +} + +.itinerary-segment-row__meta { + display: flex; + flex-wrap: wrap; + gap: .15rem .55rem; + margin-top: 2px; + color: #94a3b8; + font-size: .67rem; + line-height: 1.2; +} + +.itinerary-segment-row__eye { + align-self: center; } .popup-parada-header { font-weight: 600; diff --git a/RutasDBUS/wwwroot/app.js b/RutasDBUS/wwwroot/app.js index c5552a4..8febaf5 100644 --- a/RutasDBUS/wwwroot/app.js +++ b/RutasDBUS/wwwroot/app.js @@ -1149,6 +1149,7 @@ window.rt.clearPreview = function () { if (!window.rt.ensurePreviewLayer()) return; window.rt._previewLayer.clearLayers(); window.rt._previewLines = {}; + window.rt._itinerarySegmentHighlight = null; // ✅ también limpiar paradas destacadas if (window.rt._itStopsLayer) { @@ -1157,6 +1158,58 @@ window.rt.clearPreview = function () { } }; +window.rt.clearItinerarySegmentHighlight = function () { + if (!window.rt.ensurePreviewLayer()) return; + const highlight = window.rt._itinerarySegmentHighlight; + if (!highlight) return; + + try { window.rt._previewLayer.removeLayer(highlight); } catch { } + window.rt._itinerarySegmentHighlight = null; +}; + +window.rt.showItinerarySegmentHighlight = function (coords, options) { + if (!window.rt.ensurePreviewLayer()) return; + window.rt.clearItinerarySegmentHighlight(); + if (!Array.isArray(coords) || coords.length < 2) return; + + const color = (options && options.color) || "#facc15"; + const haloColor = (options && options.haloColor) || "#111827"; + const group = window.L.layerGroup(); + const halo = window.L.polyline(coords, { + color: haloColor, + weight: 12, + opacity: 0.92, + lineCap: "round", + lineJoin: "round", + interactive: false + }); + const line = window.L.polyline(coords, { + color, + weight: 6, + opacity: 1, + lineCap: "round", + lineJoin: "round", + interactive: false + }); + + halo.addTo(group); + line.addTo(group); + [coords[0], coords[coords.length - 1]].forEach(function (point) { + window.L.circleMarker(point, { + radius: 5, + color: haloColor, + weight: 2, + fillColor: color, + fillOpacity: 1, + interactive: false + }).addTo(group); + }); + + group.addTo(window.rt._previewLayer); + try { halo.bringToFront(); line.bringToFront(); } catch { } + window.rt._itinerarySegmentHighlight = group; +}; + window.rt.drawPreviewLine = function (id, coords, options) { if (!window.rt.ensurePreviewLayer()) return; diff --git a/RutasDBUS/wwwroot/service-worker.js b/RutasDBUS/wwwroot/service-worker.js index 7646903..dd5376b 100644 --- a/RutasDBUS/wwwroot/service-worker.js +++ b/RutasDBUS/wwwroot/service-worker.js @@ -1,5 +1,5 @@ const TILE_CACHE = 'tile-cache-v1'; -const TILE_PREFIX = 'http://192.168.41.12/osm_tiles/'; +const TILE_PREFIX = 'https://osm.tecnosis.net/osm_tiles/'; self.addEventListener('install', event => { self.skipWaiting(); diff --git a/tests/RutasDBUS.RegressionTests/Program.cs b/tests/RutasDBUS.RegressionTests/Program.cs index 2049e28..81a0a1a 100644 --- a/tests/RutasDBUS.RegressionTests/Program.cs +++ b/tests/RutasDBUS.RegressionTests/Program.cs @@ -5,9 +5,13 @@ using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging.Abstractions; using RutasDBUS.Components.Pages; using RutasDBUS.Modelos.Historial; +using RutasDBUS.Modelos.Importacion; using RutasDBUS.Modelos.Planificacion; +using RutasDBUS.Servicios; using RutasDBUS.Servicios.Caminata; using RutasDBUS.Servicios.Configuracion; +using RutasDBUS.Servicios.Horarios; +using RutasDBUS.Servicios.Importacion; using RutasDBUS.Servicios.Planificacion; static void Check(bool result, string message) @@ -20,6 +24,213 @@ var baseMethod = typeof(PlanificadorRutas).GetMethod("CrearConfiguracionBaseHist var defaults = (ConfiguracionHistorialRuta)baseMethod.Invoke(null, null)!; Check(defaults.PesoTransbordoRankingMinutos == 6, "Penalizacion inicial de 6 minutos"); +var calendariosTaxibus = new List +{ + new("LAB_BASE", 2, 2, 2, 2, 2, 1, 1, new DateOnly(2026, 6, 1), new DateOnly(2026, 12, 31)), + new("FEST_BASE", 1, 1, 1, 1, 1, 1, 2, new DateOnly(2026, 6, 1), new DateOnly(2026, 12, 31)) +}; +var excepcionesTaxibus = new List +{ + new("FEST_BASE", new DateOnly(2026, 10, 12), 1) +}; +var rutasTaxibus = new List +{ + new("6", "TB6", "Ulia", "Ulia", "4", "", "94AF5D"), + new("7", "TB7", "Atotxaerreka", "Atotxaerreka", "4", "", "63C600") +}; +var expedicionesTaxibus = new List +{ + new("TB6_I", "6", "N.A.", 1, "6", "60101", null, "0000"), + new("TB6_V", "6", "N.A.", 2, "6", "60102", null, "0000"), + new("TB7_701_I", "7", "N.A.", 1, "7", "70101", null, "0000"), + new("TB7_701_V", "7", "N.A.", 2, "7", "70102", null, "0000"), + new("TB7_702_I", "7", "N.A.", 1, "7", "70201", null, "0000"), + new("TB7_702_V", "7", "N.A.", 2, "7", "70202", null, "0000") +}; +var tiemposTaxibus = new List +{ + new("TB6_I", TimeOnly.MinValue, TimeOnly.MinValue, 1000, 372, 1, "", "", ""), + new("TB6_I", TimeOnly.MinValue, TimeOnly.MinValue, 1000, 346, 2, "", "", ""), + new("TB6_I", TimeOnly.MinValue, TimeOnly.MinValue, 0, 521, 3, "", "", ""), + new("TB6_V", TimeOnly.MinValue, TimeOnly.MinValue, 1000, 521, 1, "", "", ""), + new("TB6_V", TimeOnly.MinValue, TimeOnly.MinValue, 1000, 457, 2, "", "", ""), + new("TB6_V", TimeOnly.MinValue, TimeOnly.MinValue, 0, 372, 3, "", "", ""), + new("TB7_701_I", TimeOnly.MinValue, TimeOnly.MinValue, 800, 240, 1, "", "", ""), + new("TB7_701_I", TimeOnly.MinValue, TimeOnly.MinValue, 300, 617, 2, "", "", ""), + new("TB7_701_I", TimeOnly.MinValue, TimeOnly.MinValue, 400, 620, 3, "", "", ""), + new("TB7_701_I", TimeOnly.MinValue, TimeOnly.MinValue, 0, 599, 4, "", "", ""), + new("TB7_701_V", TimeOnly.MinValue, TimeOnly.MinValue, 200, 599, 1, "", "", ""), + new("TB7_701_V", TimeOnly.MinValue, TimeOnly.MinValue, 1300, 343, 2, "", "", ""), + new("TB7_701_V", TimeOnly.MinValue, TimeOnly.MinValue, 0, 42, 3, "", "", ""), + new("TB7_702_I", TimeOnly.MinValue, TimeOnly.MinValue, 2000, 42, 1, "", "", ""), + new("TB7_702_I", TimeOnly.MinValue, TimeOnly.MinValue, 0, 625, 2, "", "", ""), + new("TB7_702_V", TimeOnly.MinValue, TimeOnly.MinValue, 1500, 625, 1, "", "", ""), + new("TB7_702_V", TimeOnly.MinValue, TimeOnly.MinValue, 0, 240, 2, "", "", "") +}; +var integrarTaxibus = typeof(TaxibusDbus).Assembly + .GetType("RutasDBUS.Servicios.Importacion.HorariosOficialesTaxibus")! + .GetMethod("AplicarSiFaltanHorarios", BindingFlags.Static | BindingFlags.Public)!; +var rutasTaxibusIntegradas = (IReadOnlySet)integrarTaxibus.Invoke( + null, + new object[] + { + calendariosTaxibus, + excepcionesTaxibus, + expedicionesTaxibus, + rutasTaxibus, + tiemposTaxibus + })!; +Check(rutasTaxibusIntegradas.SetEquals(new[] { "6", "7" }), + "TB6 y TB7 reciben sus horarios oficiales cuando faltan en el ZIP"); +Check(expedicionesTaxibus.Any(expedicion => + expedicion.IdRuta == "6" && expedicion.HSalida == new TimeOnly(7, 0)) && + expedicionesTaxibus.Any(expedicion => + expedicion.IdRuta == "6" && expedicion.HSalida == new TimeOnly(7, 40)), + "TB6 sale a las horas oficiales desde Colon y Albergue"); +var salidaTb6Colon = expedicionesTaxibus.Single(expedicion => + expedicion.IdRuta == "6" && + expedicion.IdPatronExp == "60101" && + expedicion.HSalida == new TimeOnly(7, 0)); +Check(tiemposTaxibus.Any(tiempo => + tiempo.IdExp == salidaTb6Colon.Id && + tiempo.IdTiempoPar == 346 && + tiempo.HoraSalida == new TimeOnly(7, 10)) && + tiemposTaxibus.Any(tiempo => + tiempo.IdExp == salidaTb6Colon.Id && + tiempo.IdTiempoPar == 521 && + tiempo.HoraSalida == new TimeOnly(7, 20)) && + expedicionesTaxibus.Any(expedicion => + expedicion.IdPatronExp == "60101" && + expedicion.HSalida == new TimeOnly(22, 0)), + "TB6 respeta los tiempos publicados y la salida adicional de verano"); +var salidaTb7Laborable = expedicionesTaxibus.Single(expedicion => + expedicion.IdRuta == "7" && + expedicion.IdPatronExp == "70101" && + expedicion.IdCal.EndsWith("LAB", StringComparison.Ordinal) && + expedicion.HSalida == new TimeOnly(7, 15)); +Check(tiemposTaxibus.Any(tiempo => + tiempo.IdExp == salidaTb7Laborable.Id && + tiempo.IdTiempoPar == 617 && + tiempo.HoraSalida == new TimeOnly(7, 23)) && + tiemposTaxibus.Any(tiempo => + tiempo.IdExp == salidaTb7Laborable.Id && + tiempo.IdTiempoPar == 599 && + tiempo.HoraSalida == new TimeOnly(7, 30)), + "TB7 respeta las horas oficiales de Antiguo, Atotxaerreka y Anorga"); +var salidaTb7Anorga = expedicionesTaxibus.Single(expedicion => + expedicion.IdPatronExp == "70102" && + expedicion.IdCal.EndsWith("LAB", StringComparison.Ordinal) && + expedicion.HSalida == new TimeOnly(7, 30)); +var salidaTb7Faro = expedicionesTaxibus.Single(expedicion => + expedicion.IdPatronExp == "70201" && + expedicion.IdCal.EndsWith("LAB", StringComparison.Ordinal) && + expedicion.HSalida == new TimeOnly(7, 45)); +var vueltaTb7Faro = expedicionesTaxibus.Single(expedicion => + expedicion.IdPatronExp == "70202" && + expedicion.IdCal.EndsWith("LAB", StringComparison.Ordinal) && + expedicion.HSalida == new TimeOnly(8, 5)); +Check(tiemposTaxibus.Any(tiempo => + tiempo.IdExp == salidaTb7Anorga.Id && + tiempo.IdTiempoPar == 42 && + tiempo.HoraSalida == new TimeOnly(7, 45)) && + tiemposTaxibus.Any(tiempo => + tiempo.IdExp == salidaTb7Faro.Id && + tiempo.IdTiempoPar == 625 && + tiempo.HoraSalida == new TimeOnly(8, 5)) && + tiemposTaxibus.Any(tiempo => + tiempo.IdExp == vueltaTb7Faro.Id && + tiempo.IdTiempoPar == 240 && + tiempo.HoraSalida == new TimeOnly(8, 15)), + "TB7 mantiene encadenado el ciclo Anorga, Antiguo y Paseo del Faro"); +Check(expedicionesTaxibus.Any(expedicion => + expedicion.IdPatronExp == "70101" && + expedicion.IdCal.EndsWith("SAB", StringComparison.Ordinal) && + expedicion.HSalida == new TimeOnly(8, 15)) && + expedicionesTaxibus.Any(expedicion => + expedicion.IdPatronExp == "70101" && + expedicion.IdCal.EndsWith("FEST", StringComparison.Ordinal) && + expedicion.HSalida == new TimeOnly(9, 15)), + "TB7 diferencia laborables, sabados y festivos"); +Check(excepcionesTaxibus.Any(excepcion => + excepcion.Id.EndsWith("LAB", StringComparison.Ordinal) && + excepcion.FechaEx == new DateOnly(2026, 10, 12) && + excepcion.TipoExcepcion == 2) && + excepcionesTaxibus.Any(excepcion => + excepcion.Id.EndsWith("FEST", StringComparison.Ordinal) && + excepcion.FechaEx == new DateOnly(2026, 10, 12) && + excepcion.TipoExcepcion == 1), + "Los festivos entre semana usan el horario festivo de TB7"); +var salidaVeranoAtotxaerreka = expedicionesTaxibus.Single(expedicion => + expedicion.IdPatronExp == "70101" && + expedicion.HSalida == new TimeOnly(22, 15)); +Check(tiemposTaxibus.Where(tiempo => tiempo.IdExp == salidaVeranoAtotxaerreka.Id) + .OrderBy(tiempo => tiempo.SecPar) + .Last().IdTiempoPar == 620 && + expedicionesTaxibus.Any(expedicion => + expedicion.IdPatronExp == "70201" && + expedicion.HSalida == new TimeOnly(21, 45)), + "TB7 incluye las ampliaciones oficiales de verano sin prolongar el viaje de Atotxaerreka"); + +var expedicionesAnalisisTramos = new List +{ + new("E1", "28", "Amara", 1, "28", "280101", new TimeOnly(8, 0), "LAB"), + new("E2", "28", "Amara", 1, "28", "280101", new TimeOnly(9, 0), "LAB"), + new("E3", "28", "Amara", 1, "28", "280101", new TimeOnly(10, 0), "LAB"), + new("E4", "28", "Amara", 1, "28", "280101", new TimeOnly(11, 0), "LAB"), + new("PLANTILLA", "28", "Amara", 1, "28", "280101", null, "LAB"), + new("OTRA_VARIANTE", "28", "Boulevard", 2, "28", "280102", new TimeOnly(8, 0), "LAB") +}; +var tiemposAnalisisTramos = new List +{ + new("E1", new TimeOnly(8, 0), new TimeOnly(8, 0), 100, 1, 1, "", "", ""), + new("E1", new TimeOnly(8, 2), new TimeOnly(8, 2), 100, 2, 2, "", "", ""), + new("E1", new TimeOnly(8, 3), new TimeOnly(8, 3), 0, 3, 3, "", "", ""), + new("E2", new TimeOnly(9, 0), new TimeOnly(9, 0), 100, 1, 1, "", "", ""), + new("E2", new TimeOnly(9, 2, 30), new TimeOnly(9, 2, 30), 100, 2, 2, "", "", ""), + new("E2", new TimeOnly(9, 3, 40), new TimeOnly(9, 3, 40), 0, 3, 3, "", "", ""), + new("E3", new TimeOnly(10, 0), new TimeOnly(10, 0), 100, 1, 1, "", "", ""), + new("E3", new TimeOnly(10, 2, 59), new TimeOnly(10, 2, 59), 100, 2, 2, "", "", ""), + new("E3", new TimeOnly(10, 6, 9), new TimeOnly(10, 6, 9), 0, 3, 3, "", "", ""), + new("E4", new TimeOnly(11, 0), new TimeOnly(11, 0), 100, 1, 1, "", "", ""), + new("E4", new TimeOnly(11, 4, 10), new TimeOnly(11, 4, 10), 100, 2, 2, "", "", ""), + new("E4", new TimeOnly(11, 5, 15), new TimeOnly(11, 5, 15), 0, 3, 3, "", "", "") +}; +var archivoAnalisisTramos = new ArchivoImportacionDbus +{ + Calendarios = + [ + new FilaCalendarioCsv( + "LAB", 2, 2, 2, 2, 2, 1, 1, + new DateOnly(2026, 1, 1), new DateOnly(2026, 12, 31)) + ], + Expediciones = expedicionesAnalisisTramos, + Rutas = [new FilaRutaCsv("28", "28", "Amara-Ospitaleak", "", "4", "", "F39E00")], + Paradas = + [ + new FilaParadaCsv(1, "101", "Origen", 0, 0, "", "", "", "", "", ""), + new FilaParadaCsv(2, "102", "Intermedia", 0, 0, "", "", "", "", "", ""), + new FilaParadaCsv(3, "103", "Destino", 0, 0, "", "", "", "", "", "") + ], + TiemposParada = tiemposAnalisisTramos, + TiemposParadaPorExpedicion = tiemposAnalisisTramos + .GroupBy(x => x.IdExp, StringComparer.OrdinalIgnoreCase) + .ToDictionary( + g => g.Key, + g => g.OrderBy(x => x.SecPar).ToList(), + StringComparer.OrdinalIgnoreCase) +}; +var servicioAnalisisTramos = new ServicioHorariosDbus( + new FakeArchivoImportacionDbus(archivoAnalisisTramos)); +await servicioAnalisisTramos.AsegurarCargaAsync(""); +var analisisTramos = servicioAnalisisTramos.AnalizarTramosItinerario( + "28", "28", 1, "I", 280101, new DateOnly(2026, 9, 17)); +Check(analisisTramos.ExpedicionesAnalizadas == 4 && analisisTramos.Tramos.Count == 2, + "El analisis de tramos usa solo las expediciones activas de la variante exacta"); +Check(analisisTramos.Tramos[0].Repeticiones == 3 && + analisisTramos.Tramos[0].ExpedicionesAnalizadas == 4 && + analisisTramos.Tramos[0].DuracionHabitualSegundos == 150, + "Los tiempos con hasta un minuto de diferencia forman la frecuencia dominante"); + var panelSinCambios = new PlanificadorRutas(); var alternarPanel = typeof(PlanificadorRutas).GetMethod( "ToggleConfigPanel", @@ -261,10 +472,6 @@ typeof(PlanificadorRutas).GetField("_mostrarHistorialRutas", flags)! .SetValue(paginaCierreHistorial, true); typeof(PlanificadorRutas).GetField("_factorEquilibradoAndarBus", flags)! .SetValue(paginaCierreHistorial, 99.0); -var configuracionAnteriorHistorial = fabricaHistorial.Clonar(); -configuracionAnteriorHistorial.FactorEquilibradoAndarBus = 6; -typeof(PlanificadorRutas).GetField("_configuracionTemporalPreviaHistorial", flags)! - .SetValue(paginaCierreHistorial, configuracionAnteriorHistorial); var alternarHistorial = typeof(PlanificadorRutas).GetMethod("ToggleHistorialPanel", flags)!; await (Task)alternarHistorial.Invoke(paginaCierreHistorial, null)!; Check( @@ -386,6 +593,29 @@ sealed class FakeValhalla : IClienteValhalla } } +sealed class FakeArchivoImportacionDbus(ArchivoImportacionDbus archivo) : IServicioArchivoImportacionDbus +{ + public Task ObtenerAsync(CancellationToken cancellationToken = default) => + Task.FromResult(archivo); + + public Task> ListarModelosAsync( + CancellationToken cancellationToken = default) => + Task.FromResult>(Array.Empty()); + + public Task ObtenerModeloActivoAsync( + CancellationToken cancellationToken = default) => + Task.FromResult(null); + + public Task SeleccionarModeloAsync(string idModelo, CancellationToken cancellationToken = default) => + Task.CompletedTask; + + public Task GuardarModeloSubidoAsync( + string nombreArchivo, + Stream contenido, + CancellationToken cancellationToken = default) => + throw new NotSupportedException(); +} + sealed class OsmWidthHandler : HttpMessageHandler { public int Calls { get; private set; }