v20260917 - actual se sustituya y original metido en el modal de derivadas

This commit is contained in:
2026-09-18 13:43:04 +02:00
parent f63a1b8b08
commit 19377e07c2
16 changed files with 1616 additions and 356 deletions

View File

@@ -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
/// </summary>
private async Task CerrarTodasLasPreviewsAsync()
{
await LimpiarResaltadoTramoHorarioAsync();
try { await JS.InvokeVoidAsync("rt.clearPreview"); } catch { }
_itPreviewActivos.Clear();
@@ -356,6 +356,7 @@ public partial class PlanificadorRutas : ComponentBase, IDisposable
}
_itinerarioClicado = encontrado;
await CargarTramosHorariosItinerarioAsync(GetVarianteRealClicada());
await InvokeAsync(StateHasChanged);
}

View File

@@ -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

View File

@@ -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<bool>? _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,12 +1107,19 @@ public partial class PlanificadorRutas
? _pasosDerivacionHistorial[_indicePasoDerivacionSeleccionado]
: null;
private RegistroHistorialRuta? EscenarioDerivacionSeleccionado =>
_indicePasoDerivacionSeleccionado == -1
? _escenarioBaseDerivacionHistorial
: PasoDerivacionSeleccionado?.Escenario;
private void AbrirInformacionDerivacionHistorial()
{
if (!PuedeMostrarInformacionDerivacionHistorial)
return;
_indicePasoDerivacionSeleccionado = _indicePasoDerivacionActivo >= 0
_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)

View File

@@ -43,10 +43,7 @@ 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
/// </summary>
private async Task CerrarPopupsAsync()
{
_itinerarioClicado = null;
await CerrarPopupItinerarioAsync();
await CerrarPopupParadaAsync();
await CerrarInspectorAsync();

View File

@@ -219,14 +219,13 @@ public partial class PlanificadorRutas : ComponentBase
private List<ProximaSalidaVm> _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;
}
/// <summary>

View File

@@ -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<ResumenTramoHorarioDbus> _tramosHorariosItinerario =
Array.Empty<ResumenTramoHorarioDbus>();
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<ResumenTramoHorarioDbus>();
_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<ResumenTramoHorarioDbus>();
_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)} %)";
}

View File

@@ -304,7 +304,7 @@
aria-pressed="@todosItinerariosParadaPintados"
disabled="@_procesandoTodosItinerariosParada"
@onclick="ToggleTodosItinerariosParadaAsync">
<span aria-hidden="true">👁</span>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7S2 12 2 12Z" /><circle cx="12" cy="12" r="3" /></svg>
<span>@TextoBotonTodosItinerariosParada</span>
</button>
@@ -317,10 +317,10 @@
</div>
</div>
@if (HorariosTaxibusSimuladosVisibles)
@if (HorariosTaxibusVisibles)
{
<div class="taxibus-notice taxibus-notice--schedule" role="status">
<strong>Taxibús:</strong> @MensajeHorarioTaxibusSimuladoVisible
<strong>Taxibús:</strong> @MensajeHorarioTaxibusVisible
</div>
}
@@ -348,7 +348,7 @@
aria-label="@(itinerarioPintado ? "Ocultar itinerario" : "Ver itinerario")"
aria-pressed="@itinerarioPintado"
@onclick="() => ToggleItinerarioSalidaParadaAsync(x)">
<span aria-hidden="true">👁</span>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7S2 12 2 12Z" /><circle cx="12" cy="12" r="3" /></svg>
</button>
</li>
}
@@ -385,10 +385,66 @@
<div><b>Recorrido:</b> @ConstruirTextoRecorridoVariante(vReal, incluirCodigosParada: true)</div>
<div><b>Longitud:</b> @FormatearDistanciaVariante(vReal)</div>
</div>
<section class="itinerary-segments">
<div class="itinerary-segments__header">
<strong>Tramos detectados</strong>
<span class="itinerary-segments__tolerance">± 1 min</span>
</div>
@if (_tramosHorariosItinerarioCargando)
{
<div class="itinerary-segments__status">Analizando horarios...</div>
}
else if (!string.IsNullOrWhiteSpace(_tramosHorariosItinerarioMensaje))
{
<div class="itinerary-segments__status">@_tramosHorariosItinerarioMensaje</div>
}
else
{
<div class="itinerary-segments__summary">
@_tramosHorariosItinerario.Count tramo(s) · @_expedicionesTramosHorarios expedición(es)
@if (_tramosHorariosItinerarioFecha is { } fechaTramos)
{
<span> · @fechaTramos.ToString("dd/MM/yyyy", CultureInfo.CurrentCulture)</span>
}
</div>
<ol class="itinerary-segments__list">
@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)}";
<li class="itinerary-segment-row @(tramoHorarioActivo ? "is-active" : "")">
<span class="itinerary-segment-row__content">
<span class="itinerary-segment-row__stops">
@tramoHorario.NombreParadaOrigen (@FormatearCodigoParadaTramoHorario(tramoHorario.CodigoParadaOrigen))
<span aria-hidden="true">→</span>
@tramoHorario.NombreParadaDestino (@FormatearCodigoParadaTramoHorario(tramoHorario.CodigoParadaDestino))
</span>
<span class="itinerary-segment-row__meta" title="@tituloTiempoTramo">
<span>Tiempo: @FormatearTiempoTramoHorario(tramoHorario.DuracionHabitualSegundos)</span>
<span>Frecuencia: @FormatearFrecuenciaTramoHorario(tramoHorario)</span>
</span>
</span>
<button type="button"
class="stop-itinerary-toggle itinerary-segment-row__eye @(tramoHorarioActivo ? "is-active" : "")"
title="@(tramoHorarioActivo ? "Ocultar tramo" : "Ver tramo")"
aria-label="@(tramoHorarioActivo ? "Ocultar tramo" : "Ver tramo")"
aria-pressed="@tramoHorarioActivo"
@onclick="() => AlternarResaltadoTramoHorarioAsync(tramoHorario)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7S2 12 2 12Z" /><circle cx="12" cy="12" r="3" /></svg>
</button>
</li>
}
</ol>
}
</section>
}
</div>
<button class="popup-parada-cerrar" @onclick="() => { _itinerarioClicado = null; _itinerarioClicadoSubId = null; }">
<button class="popup-parada-cerrar" @onclick="CerrarPopupItinerarioAsync">
×
</button>
</div>
@@ -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<CambioDerivacionHistorial>()
: 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 @@
</div>
<div class="history-derivation-steps" aria-label="Derivaciones registradas">
<button type="button"
class="history-derivation-step @(_indicePasoDerivacionSeleccionado == -1 ? "history-derivation-step--selected" : "") @(EsOriginalDerivacionActivo ? "history-derivation-step--active" : "")"
title="@(EsOriginalDerivacionActivo ? "Ruta original cargada actualmente" : "Ver ruta original")"
@onclick="() => SeleccionarPasoDerivacionHistorial(-1)">
Original
</button>
@foreach (var paso in _pasosDerivacionHistorial)
{
var indicePaso = paso.Numero - 1;
@@ -1426,8 +1492,16 @@
<div class="history-derivation-selected">
<div class="history-derivation-selected__summary">
@if (esOriginalDerivacion)
{
<strong>Ruta original</strong>
<span>@_escenarioBaseDerivacionHistorial.FechaCreacionUtc.ToLocalTime().ToString("dd/MM/yyyy HH:mm:ss")</span>
}
else if (pasoDerivacion is not null)
{
<strong>Derivación n.º @pasoDerivacion.Numero</strong>
<span>@pasoDerivacion.FechaUtc.ToLocalTime().ToString("dd/MM/yyyy HH:mm:ss")</span>
}
</div>
<button type="button"
class="btn-ghost history-derivation-load"
@@ -1437,10 +1511,23 @@
? "Cargando..."
: _indicePasoDerivacionSeleccionado == _indicePasoDerivacionActivo
? "Cargada"
: esOriginalDerivacion
? "Cargar original"
: "Cargar derivación")
</button>
</div>
@if (esOriginalDerivacion)
{
<section class="history-derivation-comparison">
<h6>Escenario original</h6>
<div class="history-derivation-no-changes">
Punto de partida de todas las derivaciones registradas.
</div>
</section>
}
else
{
<section class="history-derivation-comparison">
<h6>Respecto al Historial Compartido original</h6>
@if (cambiosRespectoOriginal.Count == 0)
@@ -1471,8 +1558,9 @@
}
}
</section>
}
@if (pasoDerivacion.NumeroAnterior is not null)
@if (pasoDerivacion?.NumeroAnterior is not null)
{
<section class="history-derivation-comparison">
<h6>@textoComparacionAnterior</h6>

View File

@@ -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<ResumenTramoHorarioDbus> Tramos);
/// <summary>
/// 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;
}
/// <summary>
/// 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.
/// </summary>
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<int>>();
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<int>();
duraciones.Add(duracion);
}
}
var tramos = new List<ResumenTramoHorarioDbus>();
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<int> 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);
}
/// <summary>
/// Próximas salidas desde una parada (por stopId interno).
/// Devuelve: (horaSalidaEnParada, expId, rutaId)

View File

@@ -0,0 +1,677 @@
using RutasDBUS.Modelos.Importacion;
namespace RutasDBUS.Servicios.Importacion;
/// <summary>
/// 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).
/// </summary>
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<string> AplicarSiFaltanHorarios(
List<FilaCalendarioCsv> calendarios,
List<FilaCalendarioExcepcionCsv> calendarioExcepciones,
List<FilaExpedicionCsv> expediciones,
IReadOnlyList<FilaRutaCsv> rutas,
List<FilaTiempoParadaCsv> tiemposParada)
{
var rutasIntegradas = new HashSet<string>(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<FilaTiempoParadaCsv>()))
.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<PlantillaTaxibus> 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<FilaExpedicionCsv> expediciones,
IReadOnlyDictionary<string, List<FilaTiempoParadaCsv>> 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<PlantillaTaxibus> plantillas,
IReadOnlyDictionary<int, string> calendariosVerano,
ICollection<FilaExpedicionCsv> expediciones,
ICollection<FilaTiempoParadaCsv> 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<PlantillaTaxibus> plantillas,
IReadOnlyDictionary<int, string> calendariosVerano,
ICollection<FilaExpedicionCsv> expediciones,
ICollection<FilaTiempoParadaCsv> 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<FilaExpedicionCsv> expediciones,
ICollection<FilaTiempoParadaCsv> 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<FilaExpedicionCsv> expediciones,
ICollection<FilaTiempoParadaCsv> 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<FilaExpedicionCsv> expediciones,
ICollection<FilaTiempoParadaCsv> 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<FilaTiempoParadaCsv> RecortarTiempos(
IReadOnlyList<FilaTiempoParadaCsv> 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<FilaTiempoParadaCsv> 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<int, int>();
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<int, int>
{
[372] = 0,
[346] = 10,
[521] = 20
}),
521 => new PlanHorario(521, 372, 20, "Colón 21", new Dictionary<int, int>
{
[521] = 0,
[457] = 10,
[372] = 20
}),
240 => new PlanHorario(240, 599, 15, "Añorga", new Dictionary<int, int>
{
[240] = 0,
[617] = 8,
[599] = 15
}),
599 => new PlanHorario(599, 42, 15, "Antiguo", new Dictionary<int, int>
{
[599] = 0,
[343] = 2,
[42] = 15
}),
42 => new PlanHorario(42, 625, 20, "Paseo del Faro", new Dictionary<int, int>
{
[42] = 0,
[625] = 20
}),
625 => new PlanHorario(625, 240, 10, "Antiguo", new Dictionary<int, int>
{
[625] = 0,
[240] = 10
}),
_ => null
};
private static PlanHorario ObtenerPlanTb7VeranoAtotxaerreka()
=> new(240, 620, 11, "Atotxaerreka", new Dictionary<int, int>
{
[240] = 0,
[617] = 8,
[620] = 11
});
private static void ConfigurarCalendariosOficiales(
List<FilaCalendarioCsv> calendarios,
List<FilaCalendarioExcepcionCsv> calendarioExcepciones,
IReadOnlyList<FilaCalendarioCsv> calendariosOriginales,
IReadOnlyList<FilaCalendarioExcepcionCsv> excepcionesOriginales,
RangoFechas rangoModelo,
IReadOnlyDictionary<int, string> 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<FilaCalendarioExcepcionCsv> destino,
IReadOnlyList<FilaCalendarioCsv> calendariosOriginales,
IReadOnlyList<FilaCalendarioExcepcionCsv> 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<FilaCalendarioExcepcionCsv> 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<FilaCalendarioCsv> 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<int, string> ObtenerCalendariosVerano(RangoFechas rango)
{
var resultado = new Dictionary<int, string>();
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<FilaTiempoParadaCsv> Tiempos);
private sealed record PlanHorario(
int IdPrimeraParada,
int IdUltimaParada,
int DuracionMinutos,
string Destino,
IReadOnlyDictionary<int, int> AnclasMinutos);
private sealed record RangoFechas(DateOnly Inicio, DateOnly Fin);
}

View File

@@ -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),

View File

@@ -1,212 +0,0 @@
using RutasDBUS.Modelos.Importacion;
namespace RutasDBUS.Servicios.Importacion;
/// <summary>
/// Completa las plantillas sin horario de TB6 y TB7 con una frecuencia provisional.
/// </summary>
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<string> AplicarSiFaltanHorarios(
List<FilaCalendarioCsv> calendarios,
List<FilaExpedicionCsv> expediciones,
IReadOnlyList<FilaRutaCsv> rutas,
List<FilaTiempoParadaCsv> tiemposParada)
{
var rutasSimuladas = new HashSet<string>(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<FilaTiempoParadaCsv>()))
.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<FilaExpedicionCsv> expediciones,
IReadOnlyDictionary<string, List<FilaTiempoParadaCsv>> 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<FilaExpedicionCsv> expediciones,
ICollection<FilaTiempoParadaCsv> 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<FilaTiempoParadaCsv> Tiempos);
}

View File

@@ -5,12 +5,9 @@ namespace RutasDBUS.Servicios;
/// </summary>
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<string?> codigosLinea)
{
var codigos = codigosLinea.Where(codigo => !string.IsNullOrWhiteSpace(codigo)).ToList();
var mensajes = new List<string>(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);
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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();

View File

@@ -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<FilaCalendarioCsv>
{
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<FilaCalendarioExcepcionCsv>
{
new("FEST_BASE", new DateOnly(2026, 10, 12), 1)
};
var rutasTaxibus = new List<FilaRutaCsv>
{
new("6", "TB6", "Ulia", "Ulia", "4", "", "94AF5D"),
new("7", "TB7", "Atotxaerreka", "Atotxaerreka", "4", "", "63C600")
};
var expedicionesTaxibus = new List<FilaExpedicionCsv>
{
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<FilaTiempoParadaCsv>
{
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<string>)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<FilaExpedicionCsv>
{
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<FilaTiempoParadaCsv>
{
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<ArchivoImportacionDbus> ObtenerAsync(CancellationToken cancellationToken = default) =>
Task.FromResult(archivo);
public Task<IReadOnlyList<ModeloHorarioDbusInfo>> ListarModelosAsync(
CancellationToken cancellationToken = default) =>
Task.FromResult<IReadOnlyList<ModeloHorarioDbusInfo>>(Array.Empty<ModeloHorarioDbusInfo>());
public Task<ModeloHorarioDbusInfo?> ObtenerModeloActivoAsync(
CancellationToken cancellationToken = default) =>
Task.FromResult<ModeloHorarioDbusInfo?>(null);
public Task SeleccionarModeloAsync(string idModelo, CancellationToken cancellationToken = default) =>
Task.CompletedTask;
public Task<ModeloHorarioDbusInfo> GuardarModeloSubidoAsync(
string nombreArchivo,
Stream contenido,
CancellationToken cancellationToken = default) =>
throw new NotSupportedException();
}
sealed class OsmWidthHandler : HttpMessageHandler
{
public int Calls { get; private set; }