- Separado el control de poligonos excluyentes

- Añadido interpreacion de tipologia de suelo en segmentos andando
- Persistencia modo andando
- Transbordo penalizado a 6
- Añadidos varias zonas de prohibicion de poligonos
This commit is contained in:
2026-09-08 13:33:15 +02:00
parent d13ff13da1
commit 235297fd3a
25 changed files with 1288 additions and 375 deletions

2
.serena/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
/cache
/project.local.yml

169
.serena/project.yml Normal file
View File

@@ -0,0 +1,169 @@
# the name by which the project can be referenced within Serena/when chatting with the LLM.
project_name: "DBUS.net"
# list of language servers to start when using the LSP backend; choose from:
# ada al angular ansible bash
# bsl clojure cpp cpp_ccls crystal
# csharp csharp_omnisharp cue dart deno
# elixir elm erlang fortran fsharp
# gdscript gleam go groovy haskell
# haxe hlsl html java json
# julia kotlin latex lean4 lua
# luau markdown matlab msl nextflow
# nix ocaml pascal perl php
# php_phpactor php_phpantom powershell python python_basedpyright
# python_jedi python_pyrefly python_ty qml r
# rego ruby ruby_solargraph rust scala
# scss solidity svelte swift systemverilog
# terraform toml typescript typescript_vts vue
# wolfram yaml zig
# (This list may be outdated; generated with scripts/print_language_list.py;
# For the current list, see values of the LanguageServerId enum here:
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py)
# For some languages, there are several alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
# Note:
# - For C, use cpp
# - For JavaScript, use typescript
# - For Angular projects, use angular (subsumes typescript+html; requires `npm install` in the project root)
# - For Svelte projects, use svelte (subsumes typescript/javascript for .svelte projects; requires npm)
# - For Deno projects, use deno (serves the same .ts/.js files as typescript; requires the deno CLI on PATH)
# - For SCSS / Sass / plain CSS, use scss (some-sass-language-server handles all three)
# - For Free Pascal/Lazarus, use pascal
# Special requirements:
# Some language servers require additional setup/installations.
# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers
# When using multiple language servers, the first language server that supports a given file will be used for that file.
# The first language server is the default language and the respective language server will be used as a fallback.
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
language_servers:
- csharp
# the encoding used by text files in the project
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
encoding: "utf-8"
# optional shell command to run before the language backend (LSP or JetBrains) is initialised.
# the command runs in the project root directory and is only executed if the project is trusted
# (see trusted_project_path_patterns in the global configuration).
# serena waits for the command to exit: a non-zero exit code is logged as an error but does not
# abort activation. a per-project timeout (activation_command_timeout, default 180s) is the safety
# backstop for non-terminating commands; on expiry the process is killed and activation continues.
# example: activation_command: "npx nx run-many -t build"
activation_command:
# maximum time in seconds to wait for activation_command to complete before killing it (default 180s).
# must be a positive number.
activation_command_timeout: 180.0
# line ending convention to use when writing source files.
# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default)
# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings.
line_ending:
# The language backend to use for this project.
# If not set, the global setting from serena_config.yml is used.
# Valid values: LSP, JetBrains
# Note: the backend is fixed at startup. If a project with a different backend
# is activated post-init, an error will be returned.
language_backend:
# whether to use project's .gitignore files to ignore files
ignore_all_files_in_gitignore: true
# advanced configuration option allowing to configure language server-specific options.
# Maps the language key to the options.
# The settings are considered only if the project is trusted (see global configuration to define trusted projects).
# See https://oraios.github.io/serena/02-usage/050_configuration.html#language-server-specific-settings
ls_specific_settings: {}
# list of workspace folder paths (LSP backend only).
# These folders will be used to build up Serena's symbol index.
# Paths must be within the project root and should thus be relative to the project root.
# Furthermore, the paths should not be filtered by ignore settings.
# Default setting: The entire project root folder (".") is considered.
# In (large) monorepos, this can be used to index only subfolders of the project root, e.g.
# ls_workspace_folders:
# - "./subproject1"
# - "./subproject2"
ls_workspace_folders:
- "."
# list of additional workspace folder paths for cross-package reference support.
# Paths can be absolute or relative to the project root.
# Each folder is registered as an LSP workspace folder, enabling language servers to discover
# symbols and references across package boundaries, but these folders are not indexed by Serena,
# i.e. the respective symbols will not be found using Serena's symbol search tools.
# Example:
# additional_workspace_folders:
# - ../sibling-package
# - ../shared-lib
ls_additional_workspace_folders: []
# list of additional paths to ignore in this project.
# Same syntax as gitignore, so you can use * and **.
# Important: quote patterns that start with `*`, otherwise YAML treats them as aliases.
# Example:
# ignored_paths:
# - "examples/**"
# - ".worktrees/**"
# - "**/bin/**"
# - "**/obj/**"
# Note: global ignored_paths from serena_config.yml are also applied additively.
ignored_paths: []
# whether the project is in read-only mode
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
# Added on 2025-04-18
read_only: false
# list of tool names to exclude.
# This extends the existing exclusions (e.g. from the global configuration)
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
excluded_tools: []
# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default).
# This extends the existing inclusions (e.g. from the global configuration).
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
included_optional_tools: []
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
fixed_tools: []
# list of mode names that are to be activated by default, overriding the setting in the global configuration.
# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
# If the setting is undefined/empty, the default_modes from the global configuration (serena_config.yml) apply.
# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
# Therefore, you can set this to [] if you do not want the default modes defined in the global config to apply
# for this project.
# This setting can, in turn, be overridden by CLI parameters (--mode).
# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
default_modes:
# list of mode names to be activated additionally for this project, e.g. ["query-projects"]
# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
added_modes:
# initial prompt for the project. It will always be given to the LLM upon activating the project
# (contrary to the memories, which are loaded on demand).
initial_prompt: ""
# time budget (seconds) per tool call for the retrieval of additional symbol information
# such as docstrings or parameter information.
# This overrides the corresponding setting in the global configuration; see the documentation there.
# If null or missing, use the setting from the global configuration.
symbol_info_budget:
# list of regex patterns which, when matched, mark a memory entry as readonly.
# Extends the list from the global configuration, merging the two lists.
read_only_memory_patterns: []
# list of regex patterns for memories to completely ignore.
# Matching memories will not appear in list_memories or activate_project output
# and cannot be accessed via read_memory or write_memory.
# To access ignored memory files, use the read_file tool on the raw file path.
# Extends the list from the global configuration, merging the two lists.
# Example: ["_archive/.*", "_episodes/.*"]
ignored_memory_patterns: []

View File

@@ -506,13 +506,16 @@ public partial class PlanificadorRutas : ComponentBase, IDisposable
private async Task<(double[,]? line, double dist, double dur)> WalkValhallaCached(double lat1, double lon1, double lat2, double lon2) private async Task<(double[,]? line, double dist, double dur)> WalkValhallaCached(double lat1, double lon1, double lat2, double lon2)
{ {
var poligonos = ObtenerPoligonosExclusionCaminata();
var key = WalkKey("valhalla", lat1, lon1, lat2, lon2); var key = WalkKey("valhalla", lat1, lon1, lat2, lon2);
if (poligonos is not null) key += "|" + JsonSerializer.Serialize(poligonos);
if (_walkCache.TryGetValue(key, out var v)) if (_walkCache.TryGetValue(key, out var v))
return v; return v;
try try
{ {
var (line, dist, dur) = await ClienteValhalla.ObtenerRutaAsync(lat1, lon1, lat2, lon2, costing: "pedestrian"); var (line, dist, dur) = await ClienteValhalla.ObtenerRutaAsync(lat1, lon1, lat2, lon2,
costing: "pedestrian", excludePolygons: poligonos);
// ✅ Solo cachear si es un resultado "bueno" // ✅ Solo cachear si es un resultado "bueno"
if (line is not null && dist > 0) if (line is not null && dist > 0)

View File

@@ -68,12 +68,12 @@ public partial class PlanificadorRutas : ComponentBase
private double _factorPenalizacionDistanciaPie = FactorPenalizacionDistanciaPiePorDefecto; private double _factorPenalizacionDistanciaPie = FactorPenalizacionDistanciaPiePorDefecto;
private double _parametroPenalizacionDistanciaPieMetros = ParametroPenalizacionDistanciaPieMetrosPorDefecto; private double _parametroPenalizacionDistanciaPieMetros = ParametroPenalizacionDistanciaPieMetrosPorDefecto;
private double _coeficienteEsfuerzoAltura = CoeficienteEsfuerzoAlturaPorDefecto; private double _coeficienteEsfuerzoAltura = CoeficienteEsfuerzoAlturaPorDefecto;
private double _pesoTransbordoRankingMinutos = 4.0; private double _pesoTransbordoRankingMinutos = 6.0;
private double _factorEquilibradoAndarBus = FactorEquilibradoAndarBusPorDefecto; private double _factorEquilibradoAndarBus = FactorEquilibradoAndarBusPorDefecto;
private string _factorPenalizacionDistanciaPieTextoEdicion = "1.8"; private string _factorPenalizacionDistanciaPieTextoEdicion = "1.8";
private string _parametroPenalizacionDistanciaPieTextoEdicion = "200"; private string _parametroPenalizacionDistanciaPieTextoEdicion = "200";
private string _coeficienteEsfuerzoAlturaTextoEdicion = "5.20"; private string _coeficienteEsfuerzoAlturaTextoEdicion = "5.20";
private string _pesoTransbordoRankingTextoEdicion = "4,0"; private string _pesoTransbordoRankingTextoEdicion = "6,0";
private string _factorEquilibradoAndarBusTextoEdicion = "6"; private string _factorEquilibradoAndarBusTextoEdicion = "6";
private double _velocidadBusKmH = 16.0; private double _velocidadBusKmH = 16.0;
private int _minutosTransbordo = 5; private int _minutosTransbordo = 5;

View File

@@ -1522,6 +1522,8 @@ public partial class PlanificadorRutas
_coordenadasOrigen = new[] { item.OrigenLatitud, item.OrigenLongitud }; _coordenadasOrigen = new[] { item.OrigenLatitud, item.OrigenLongitud };
_coordenadasDestino = new[] { item.DestinoLatitud, item.DestinoLongitud }; _coordenadasDestino = new[] { item.DestinoLatitud, item.DestinoLongitud };
if (_modoConfiguracionHistorial == ModoConfiguracionHistorial.Predeterminada)
_configuracionFabrica = await ValoresFabrica.CargarAsync();
var configuracionHistorial = _modoConfiguracionHistorial switch var configuracionHistorial = _modoConfiguracionHistorial switch
{ {
ModoConfiguracionHistorial.Guardada => CrearConfiguracionGuardadaHistorial(item), ModoConfiguracionHistorial.Guardada => CrearConfiguracionGuardadaHistorial(item),

View File

@@ -1400,7 +1400,7 @@ public partial class PlanificadorRutas : ComponentBase
private bool _elevPopupMinimizado = false; private bool _elevPopupMinimizado = false;
private const string _versionApp = "(v-20260907a)"; private const string _versionApp = "(v-20260908a)";
private double? _paradaElevIdee; private double? _paradaElevIdee;

View File

@@ -23,6 +23,8 @@ public partial class PlanificadorRutas
/// </summary> /// </summary>
private async Task CargarConfiguracionLocalAsync() private async Task CargarConfiguracionLocalAsync()
{ {
_configuracionFabrica = await ValoresFabrica.CargarAsync();
AplicarConfiguracionLocal(CrearConfiguracionPredeterminadaHistorial());
try try
{ {
var json = await JS.InvokeAsync<string?>("rt.loadLocalValue", ClaveConfiguracionLocal); var json = await JS.InvokeAsync<string?>("rt.loadLocalValue", ClaveConfiguracionLocal);
@@ -146,7 +148,7 @@ public partial class PlanificadorRutas
PreferenciaEtiquetaRuta = _preferenciaEtiquetaRuta PreferenciaEtiquetaRuta = _preferenciaEtiquetaRuta
}; };
private static ConfiguracionHistorialRuta CrearConfiguracionPredeterminadaHistorial() private static ConfiguracionHistorialRuta CrearConfiguracionBaseHistorial()
{ {
var ahora = DateTime.Now; var ahora = DateTime.Now;
return new ConfiguracionHistorialRuta return new ConfiguracionHistorialRuta
@@ -174,7 +176,7 @@ public partial class PlanificadorRutas
ParametroPenalizacionDistanciaPieMetros = ParametroPenalizacionDistanciaPieMetrosPorDefecto, ParametroPenalizacionDistanciaPieMetros = ParametroPenalizacionDistanciaPieMetrosPorDefecto,
CoeficienteEsfuerzoAltura = CoeficienteEsfuerzoAlturaPorDefecto, CoeficienteEsfuerzoAltura = CoeficienteEsfuerzoAlturaPorDefecto,
VersionCoeficienteEsfuerzo = VersionPersistenciaCoeficienteEsfuerzo, VersionCoeficienteEsfuerzo = VersionPersistenciaCoeficienteEsfuerzo,
PesoTransbordoRankingMinutos = 4.0, PesoTransbordoRankingMinutos = 6.0,
FactorEquilibradoAndarBus = FactorEquilibradoAndarBusPorDefecto, FactorEquilibradoAndarBus = FactorEquilibradoAndarBusPorDefecto,
VersionFactorEquilibrado = VersionPersistenciaFactorEquilibrado, VersionFactorEquilibrado = VersionPersistenciaFactorEquilibrado,
MostrarElevacionesRuta = true, MostrarElevacionesRuta = true,
@@ -267,7 +269,7 @@ public partial class PlanificadorRutas
? CoeficienteEsfuerzoAlturaPorDefecto ? CoeficienteEsfuerzoAlturaPorDefecto
: coeficienteAltura; : coeficienteAltura;
if (config.PesoTransbordoRankingMinutos is { } pesoTransbordo && double.IsFinite(pesoTransbordo)) if (config.PesoTransbordoRankingMinutos is { } pesoTransbordo && double.IsFinite(pesoTransbordo))
_pesoTransbordoRankingMinutos = Math.Abs(pesoTransbordo - 8.0) < 0.0001 ? 4.0 : Math.Max(0, pesoTransbordo); _pesoTransbordoRankingMinutos = Math.Max(0, pesoTransbordo);
if (config.FactorEquilibradoAndarBus is { } factorEquilibrado && if (config.FactorEquilibradoAndarBus is { } factorEquilibrado &&
double.IsFinite(factorEquilibrado) && double.IsFinite(factorEquilibrado) &&
factorEquilibrado > 0) factorEquilibrado > 0)
@@ -442,48 +444,8 @@ public partial class PlanificadorRutas
if (!confirmado) if (!confirmado)
return; return;
_mostrarParadas = true; _configuracionFabrica = await ValoresFabrica.CargarAsync();
_usarHorariosTeoricos = true; AplicarValoresFabricaConservandoSeleccion();
_segundosPorParadaBusManual = 30;
_segundosPorParadaBus = 30;
_activarRangoItinerarios = true;
_itinerarioMin = 1;
_itinerarioMax = 99;
_filtroSentidoItinerario = FiltroSentidoItinerario.Ambos;
_activarRangoTransbordos = true;
_minTransbordosPermitidos = 0;
_maxTransbordosPermitidos = 3;
_factorPenalizacionDistanciaPie = FactorPenalizacionDistanciaPiePorDefecto;
_parametroPenalizacionDistanciaPieMetros = ParametroPenalizacionDistanciaPieMetrosPorDefecto;
_coeficienteEsfuerzoAltura = CoeficienteEsfuerzoAlturaPorDefecto;
_pesoTransbordoRankingMinutos = 4.0;
_factorEquilibradoAndarBus = FactorEquilibradoAndarBusPorDefecto;
_mostrarElevacionesRutaConfiguracion = true;
_fuenteElevacionCalculo = FuenteElevacionLocal5;
_umbralPendienteSubidaRuta = UmbralPendienteSubidaRutaPorDefecto;
_umbralPendienteSubidaMediaRuta = UmbralPendienteSubidaMediaRutaPorDefecto;
_umbralPendienteSubidaFuerteRuta = UmbralPendienteSubidaFuerteRutaPorDefecto;
_colorPendienteBajadaRuta = ColorPendienteBajadaRutaPorDefecto;
_colorPendienteLlanoRuta = ColorPendienteLlanoRutaPorDefecto;
_colorPendienteSubidaSuaveRuta = ColorPendienteSubidaSuaveRutaPorDefecto;
_colorPendienteSubidaMediaRuta = ColorPendienteSubidaMediaRutaPorDefecto;
_colorPendienteSubidaFuerteRuta = ColorPendienteSubidaFuerteRutaPorDefecto;
_umbralPendienteBajadaRuta = UmbralPendienteBajadaRutaPorDefecto;
_umbralAlturaAlertaPendienteRutaMetros = UmbralAlturaAlertaPendienteRutaMetrosPorDefecto;
_velocidadPieKmH = 3.6;
_velocidadBusKmH = 16.0;
_minutosTransbordo = 5;
_margenTransbordoSegundos = 120;
_elevMaxPuntosPorTramo = 999;
_radioCandidatasMetros = 1200;
_walkProvider = ProveedorCaminata.Valhalla;
_nivelBrilloMapa = 0;
_usarColorRealLineaEnItinerarios = false;
_usarOsrmParaRanking = false;
_modoConfiguracionHistorial = ModoConfiguracionHistorial.Actual;
if (_usarHoraSimulada) if (_usarHoraSimulada)
StopClock(); StopClock();
@@ -504,48 +466,8 @@ public partial class PlanificadorRutas
{ {
_mostrarConfirmacionRestablecer = false; _mostrarConfirmacionRestablecer = false;
_mostrarParadas = true; _configuracionFabrica = await ValoresFabrica.CargarAsync();
_usarHorariosTeoricos = true; AplicarValoresFabricaConservandoSeleccion();
_segundosPorParadaBusManual = 30;
_segundosPorParadaBus = 30;
_activarRangoItinerarios = true;
_itinerarioMin = 1;
_itinerarioMax = 99;
_filtroSentidoItinerario = FiltroSentidoItinerario.Ambos;
_activarRangoTransbordos = true;
_minTransbordosPermitidos = 0;
_maxTransbordosPermitidos = 3;
_factorPenalizacionDistanciaPie = FactorPenalizacionDistanciaPiePorDefecto;
_parametroPenalizacionDistanciaPieMetros = ParametroPenalizacionDistanciaPieMetrosPorDefecto;
_coeficienteEsfuerzoAltura = CoeficienteEsfuerzoAlturaPorDefecto;
_pesoTransbordoRankingMinutos = 4.0;
_factorEquilibradoAndarBus = FactorEquilibradoAndarBusPorDefecto;
_mostrarElevacionesRutaConfiguracion = true;
_fuenteElevacionCalculo = FuenteElevacionLocal5;
_umbralPendienteSubidaRuta = UmbralPendienteSubidaRutaPorDefecto;
_umbralPendienteSubidaMediaRuta = UmbralPendienteSubidaMediaRutaPorDefecto;
_umbralPendienteSubidaFuerteRuta = UmbralPendienteSubidaFuerteRutaPorDefecto;
_colorPendienteBajadaRuta = ColorPendienteBajadaRutaPorDefecto;
_colorPendienteLlanoRuta = ColorPendienteLlanoRutaPorDefecto;
_colorPendienteSubidaSuaveRuta = ColorPendienteSubidaSuaveRutaPorDefecto;
_colorPendienteSubidaMediaRuta = ColorPendienteSubidaMediaRutaPorDefecto;
_colorPendienteSubidaFuerteRuta = ColorPendienteSubidaFuerteRutaPorDefecto;
_umbralPendienteBajadaRuta = UmbralPendienteBajadaRutaPorDefecto;
_umbralAlturaAlertaPendienteRutaMetros = UmbralAlturaAlertaPendienteRutaMetrosPorDefecto;
_velocidadPieKmH = 3.6;
_velocidadBusKmH = 16.0;
_minutosTransbordo = 5;
_margenTransbordoSegundos = 120;
_elevMaxPuntosPorTramo = 999;
_radioCandidatasMetros = 1200;
_walkProvider = ProveedorCaminata.Valhalla;
_nivelBrilloMapa = 0;
_usarColorRealLineaEnItinerarios = false;
_usarOsrmParaRanking = false;
_modoConfiguracionHistorial = ModoConfiguracionHistorial.Actual;
if (_usarHoraSimulada) if (_usarHoraSimulada)
StopClock(); StopClock();

View File

@@ -1146,6 +1146,7 @@ public partial class PlanificadorRutas : ComponentBase
if (indice < 0 || indice >= _opcionesRuta.Count) return; if (indice < 0 || indice >= _opcionesRuta.Count) return;
_indiceAlternativaSeleccionada = indice; _indiceAlternativaSeleccionada = indice;
_proveedorSoloAPieRecalculo = _opcionesRuta[indice].EsSoloAPie ? _opcionesRuta[indice].ProveedorRuta : null;
await CerrarPopupsAsync(); await CerrarPopupsAsync();
ResetEstadoRuta(limpiarOpciones: false, limpiarMensaje: false); ResetEstadoRuta(limpiarOpciones: false, limpiarMensaje: false);
@@ -1755,6 +1756,7 @@ public partial class PlanificadorRutas : ComponentBase
[JSInvokable] [JSInvokable]
public async Task OnMobileMapLongPress(double lat, double lon) public async Task OnMobileMapLongPress(double lat, double lon)
{ {
if (_modoDibujoExclusionCaminata) return;
await InvokeAsync(async () => await InvokeAsync(async () =>
{ {
if (_inspectorActivo) if (_inspectorActivo)
@@ -1874,15 +1876,27 @@ public partial class PlanificadorRutas : ComponentBase
/// <summary> /// <summary>
/// Calcula ruta completa. /// Calcula ruta completa.
/// </summary> /// </summary>
private string? _proveedorSoloAPieRecalculo;
private string? ObtenerProveedorSoloAPieParaRecalculo(bool conservarSeleccion)
{
if (!conservarSeleccion)
_proveedorSoloAPieRecalculo = null;
else if (_indiceAlternativaSeleccionada >= 0 && _indiceAlternativaSeleccionada < _opcionesRuta.Count)
{
var seleccionada = _opcionesRuta[_indiceAlternativaSeleccionada];
_proveedorSoloAPieRecalculo = seleccionada.EsSoloAPie ? seleccionada.ProveedorRuta : null;
}
// Durante un calculo las opciones se vacian; otro cambio de punto conserva la preferencia.
return _proveedorSoloAPieRecalculo;
}
private async Task CalcularRutaCompletaAsync(bool conservarSeleccionSoloAPie = false) private async Task CalcularRutaCompletaAsync(bool conservarSeleccionSoloAPie = false)
{ {
if (_mapa is null || _coordenadasOrigen is null || _coordenadasDestino is null) if (_mapa is null || _coordenadasOrigen is null || _coordenadasDestino is null)
return; return;
var restaurarSeleccionSoloAPie = conservarSeleccionSoloAPie && var proveedorSoloAPiePreferido = ObtenerProveedorSoloAPieParaRecalculo(conservarSeleccionSoloAPie);
_indiceAlternativaSeleccionada >= 0 &&
_indiceAlternativaSeleccionada < _opcionesRuta.Count &&
_opcionesRuta[_indiceAlternativaSeleccionada].EsSoloAPie;
_mostrarElevacionesRuta = _mostrarElevacionesRutaConfiguracion; _mostrarElevacionesRuta = _mostrarElevacionesRutaConfiguracion;
@@ -2002,8 +2016,9 @@ public partial class PlanificadorRutas : ComponentBase
return; return;
} }
var idxPreferida = restaurarSeleccionSoloAPie var idxPreferida = proveedorSoloAPiePreferido is not null
? _opcionesRuta.FindIndex(opcion => opcion.EsSoloAPie) ? _opcionesRuta.FindIndex(opcion => opcion.EsSoloAPie &&
string.Equals(opcion.ProveedorRuta, proveedorSoloAPiePreferido, StringComparison.OrdinalIgnoreCase))
: BuscarIndicePreferidoPorCriterio(_criterioPrioritarioRuta); : BuscarIndicePreferidoPorCriterio(_criterioPrioritarioRuta);
if (idxPreferida < 0 || idxPreferida >= _opcionesRuta.Count) if (idxPreferida < 0 || idxPreferida >= _opcionesRuta.Count)
{ {

View File

@@ -1312,7 +1312,7 @@ public partial class PlanificadorRutas : ComponentBase
} }
await CerrarPopupsAsync(); await CerrarPopupsAsync();
await CalcularRutaCompletaAsync(); await CalcularRutaCompletaAsync(conservarSeleccionSoloAPie: true);
StateHasChanged(); StateHasChanged();
} }
@@ -1321,6 +1321,9 @@ public partial class PlanificadorRutas : ComponentBase
/// </summary> /// </summary>
private async Task ReiniciarSeleccion() private async Task ReiniciarSeleccion()
{ {
_proveedorSoloAPieRecalculo = null;
await LimpiarZonasExclusionAsync();
await CerrarHerramientaZonasAsync();
await CerrarTodasLasPreviewsAsync(); await CerrarTodasLasPreviewsAsync();
await CerrarPopupsAsync(); await CerrarPopupsAsync();

View File

@@ -4,112 +4,159 @@ namespace RutasDBUS.Components.Pages;
public partial class PlanificadorRutas public partial class PlanificadorRutas
{ {
private bool _modoDibujoExclusionCaminata; private sealed class ZonaExclusionCaminata
private bool _poligonoExclusionCaminataCerrado; {
private readonly List<double[]> _verticesExclusionCaminata = new(); public string Id { get; } = Guid.NewGuid().ToString("N");
public List<double[]> Vertices { get; } = new();
public bool Cerrada { get; set; }
}
private bool PuedeProbarExclusionCaminata => private readonly List<ZonaExclusionCaminata> _zonasExclusionCaminata = new();
_walkProvider == ProveedorCaminata.Valhalla && private string? _zonaExclusionSeleccionada;
_hayRutaValida && private bool _mostrarHerramientaZonas;
_opcionesRuta.Count > 0 && private bool _recalculandoZonas;
_indiceAlternativaSeleccionada >= 0 && private string? _errorZonas;
_indiceAlternativaSeleccionada < _opcionesRuta.Count && private ZonaExclusionCaminata? ZonaExclusionSeleccionada =>
(_opcionesRuta[_indiceAlternativaSeleccionada].EsSoloAPie || _zonasExclusionCaminata.Find(zona => zona.Id == _zonaExclusionSeleccionada);
_tramosRuta.Any(tramo => tramo.Tipo.StartsWith("A pie", StringComparison.OrdinalIgnoreCase))); private bool _modoDibujoExclusionCaminata =>
_mostrarHerramientaZonas && ZonaExclusionSeleccionada is { Cerrada: false };
private bool PoligonoExclusionCaminataValido => private int NumeroZonasExclusion => _zonasExclusionCaminata.Count(zona => zona.Cerrada);
_verticesExclusionCaminata.Count >= 3 && _poligonoExclusionCaminataCerrado;
private List<List<double[]>>? ObtenerPoligonosExclusionCaminata() private List<List<double[]>>? ObtenerPoligonosExclusionCaminata()
{ {
if (!PoligonoExclusionCaminataValido) var poligonos = _zonasExclusionCaminata.Where(zona => zona.Cerrada)
return null; .Select(zona => zona.Vertices.Select(p => new[] { p[1], p[0] })
.Append(new[] { zona.Vertices[0][1], zona.Vertices[0][0] }).ToList()).ToList();
return poligonos.Count == 0 ? null : poligonos;
}
return new List<List<double[]>> private async Task AbrirHerramientaZonasAsync()
{ {
_verticesExclusionCaminata _mostrarConfigAvanzada = false;
.Select(punto => new[] { punto[1], punto[0] }) _mostrarHerramientaZonas = true;
.Append(new[] { _verticesExclusionCaminata[0][1], _verticesExclusionCaminata[0][0] }) await ActualizarEditorExclusionCaminataAsync();
.ToList() }
};
private async Task CerrarHerramientaZonasAsync()
{
_zonasExclusionCaminata.RemoveAll(zona => !zona.Cerrada);
_zonaExclusionSeleccionada = null;
_mostrarHerramientaZonas = false;
_errorZonas = null;
await JS.InvokeVoidAsync("rt.clearExclusionPolygonEditor");
} }
private async Task IniciarDibujoExclusionCaminataAsync() private async Task IniciarDibujoExclusionCaminataAsync()
{ {
if (!PuedeProbarExclusionCaminata) if (_recalculandoZonas || _modoDibujoExclusionCaminata || _zonasExclusionCaminata.Count >= 10) return;
return; var zona = new ZonaExclusionCaminata();
_zonasExclusionCaminata.Add(zona);
_modoDibujoExclusionCaminata = true; _zonaExclusionSeleccionada = zona.Id;
_poligonoExclusionCaminataCerrado = false; _errorZonas = null;
_verticesExclusionCaminata.Clear(); await ActualizarEditorExclusionCaminataAsync();
await JS.InvokeVoidAsync("rt.beginExclusionPolygonEditor");
StateHasChanged();
} }
private async Task CancelarDibujoExclusionCaminataAsync() private async Task SeleccionarZonaExclusionAsync(string id)
{ {
_modoDibujoExclusionCaminata = false; if (_recalculandoZonas) return;
_poligonoExclusionCaminataCerrado = false; _zonasExclusionCaminata.RemoveAll(zona => !zona.Cerrada);
_verticesExclusionCaminata.Clear(); _zonaExclusionSeleccionada = id;
_errorZonas = null;
await ActualizarEditorExclusionCaminataAsync();
}
private async Task EliminarZonaExclusionAsync(string id)
{
if (_recalculandoZonas) return;
_zonasExclusionCaminata.RemoveAll(zona => zona.Id == id);
if (_zonaExclusionSeleccionada == id) _zonaExclusionSeleccionada = null;
_errorZonas = null;
await ActualizarEditorExclusionCaminataAsync();
}
private async Task LimpiarZonasExclusionAsync()
{
_zonasExclusionCaminata.Clear();
_zonaExclusionSeleccionada = null;
_errorZonas = null;
await JS.InvokeVoidAsync("rt.clearExclusionPolygonEditor"); await JS.InvokeVoidAsync("rt.clearExclusionPolygonEditor");
StateHasChanged();
} }
private async Task DeshacerVerticeExclusionCaminataAsync() private async Task DeshacerVerticeExclusionCaminataAsync()
{ {
if (_verticesExclusionCaminata.Count == 0 || _poligonoExclusionCaminataCerrado) if (ZonaExclusionSeleccionada is not { Cerrada: false } zona || zona.Vertices.Count == 0) return;
return; zona.Vertices.RemoveAt(zona.Vertices.Count - 1);
_verticesExclusionCaminata.RemoveAt(_verticesExclusionCaminata.Count - 1);
await ActualizarEditorExclusionCaminataAsync(); await ActualizarEditorExclusionCaminataAsync();
} }
private async Task CerrarPoligonoExclusionCaminataAsync() private async Task CerrarPoligonoExclusionCaminataAsync()
{ {
if (_verticesExclusionCaminata.Count < 3) if (ZonaExclusionSeleccionada is not { Cerrada: false } zona || zona.Vertices.Count < 3) return;
if (!await JS.InvokeAsync<bool>("rt.isExclusionPolygonValid", zona.Vertices))
{
_errorZonas = "Revisa la zona: los lados no pueden cruzarse ni quedar alineados.";
return; return;
}
_poligonoExclusionCaminataCerrado = true; zona.Cerrada = true;
await JS.InvokeVoidAsync("rt.closeExclusionPolygonEditor"); _errorZonas = null;
StateHasChanged(); await ActualizarEditorExclusionCaminataAsync();
} }
private async Task CalcularRutaEvitandoPoligonoCaminataAsync() private async Task CalcularRutaEvitandoPoligonoCaminataAsync()
{ {
if (!PoligonoExclusionCaminataValido) if (_recalculandoZonas || _modoDibujoExclusionCaminata || _coordenadasOrigen is null || _coordenadasDestino is null) return;
return; foreach (var zona in _zonasExclusionCaminata)
{
_modoDibujoExclusionCaminata = false; if (!await JS.InvokeAsync<bool>("rt.isExclusionPolygonValid", zona.Vertices))
await JS.InvokeVoidAsync("rt.finishExclusionPolygonEditor"); {
_omitirRegistroHistorialEnSiguienteCalculo = true; _errorZonas = "Una zona tiene lados cruzados o no tiene superficie. Ajusta sus puntos.";
await CalcularRutaCompletaAsync(); return;
}
}
_recalculandoZonas = true;
_errorZonas = null;
await ActualizarEditorExclusionCaminataAsync();
try
{
_omitirRegistroHistorialEnSiguienteCalculo = true;
await CalcularRutaCompletaAsync(conservarSeleccionSoloAPie: true);
}
finally
{
_recalculandoZonas = false;
await ActualizarEditorExclusionCaminataAsync();
}
} }
[JSInvokable] [JSInvokable]
public async Task OnExclusionPolygonMapClick(double latitud, double longitud) public async Task OnExclusionPolygonMapClick(double latitud, double longitud)
{ {
if (!_modoDibujoExclusionCaminata || _poligonoExclusionCaminataCerrado) if (!_modoDibujoExclusionCaminata || _recalculandoZonas || !CoordenadaExclusionValida(latitud, longitud)) return;
return; var zona = ZonaExclusionSeleccionada!;
if (zona.Vertices.Count >= 50) return;
_verticesExclusionCaminata.Add(new[] { latitud, longitud }); zona.Vertices.Add(new[] { latitud, longitud });
await ActualizarEditorExclusionCaminataAsync(); await ActualizarEditorExclusionCaminataAsync();
StateHasChanged();
} }
[JSInvokable] [JSInvokable]
public Task OnExclusionPolygonVertexDragged(int indice, double latitud, double longitud) public Task OnExclusionPolygonVertexDragged(string id, int indice, double latitud, double longitud)
{ {
if (indice >= 0 && indice < _verticesExclusionCaminata.Count) var zona = _zonasExclusionCaminata.Find(zona => zona.Id == id);
{ if (_mostrarHerramientaZonas && !_recalculandoZonas && zona is not null &&
_verticesExclusionCaminata[indice][0] = latitud; indice >= 0 && indice < zona.Vertices.Count && CoordenadaExclusionValida(latitud, longitud))
_verticesExclusionCaminata[indice][1] = longitud; zona.Vertices[indice] = new[] { latitud, longitud };
}
return InvokeAsync(StateHasChanged); return InvokeAsync(StateHasChanged);
} }
private static bool CoordenadaExclusionValida(double latitud, double longitud) =>
double.IsFinite(latitud) && double.IsFinite(longitud) && Math.Abs(latitud) <= 90 && Math.Abs(longitud) <= 180;
private async Task ActualizarEditorExclusionCaminataAsync() private async Task ActualizarEditorExclusionCaminataAsync()
{ {
await JS.InvokeVoidAsync("rt.updateExclusionPolygonEditor", _verticesExclusionCaminata); if (!_mostrarHerramientaZonas) return;
StateHasChanged(); await JS.InvokeVoidAsync("rt.updateExclusionPolygonEditor",
_zonasExclusionCaminata.Select(zona => new { id = zona.Id, points = zona.Vertices, closed = zona.Cerrada }),
_zonaExclusionSeleccionada, _recalculandoZonas);
} }
} }

View File

@@ -0,0 +1,86 @@
using System.Globalization;
using Microsoft.JSInterop;
using RutasDBUS.Modelos.Planificacion;
namespace RutasDBUS.Components.Pages;
public partial class PlanificadorRutas
{
private int _versionConsultaSuperficieSegmento;
/// <summary>
/// Consulta la superficie únicamente del segmento de elevación que se acaba de seleccionar.
/// </summary>
[JSInvokable]
public async Task OnElevationSegmentSelected(
string routeId,
int segmentIndex,
double[][] coordenadas)
{
if (string.IsNullOrWhiteSpace(routeId)
|| segmentIndex < 0
|| coordenadas is null
|| coordenadas.Length < 2)
return;
var linea = new double[coordenadas.Length, 2];
for (var i = 0; i < coordenadas.Length; i++)
{
if (coordenadas[i] is null || coordenadas[i].Length < 2)
return;
linea[i, 0] = coordenadas[i][0];
linea[i, 1] = coordenadas[i][1];
if (!double.IsFinite(linea[i, 0]) || !double.IsFinite(linea[i, 1]))
return;
}
var version = ++_versionConsultaSuperficieSegmento;
string superficie;
try
{
var informacion = await ClienteValhalla.ObtenerInformacionSueloAsync(linea);
superficie = FormatearSuperficieSegmento(informacion);
}
catch
{
superficie = "No disponible";
}
if (version != _versionConsultaSuperficieSegmento)
return;
try
{
await JS.InvokeVoidAsync(
"rt.setElevationSegmentSurface",
routeId,
segmentIndex,
superficie);
}
catch
{
// El mapa puede haber sido redibujado mientras respondía la consulta.
}
}
private static string FormatearSuperficieSegmento(InformacionSueloTramo? informacion)
{
if (informacion is null || !informacion.Disponible || informacion.Segmentos.Count == 0)
return "No disponible";
return string.Join(
"<br>",
informacion.Segmentos.Select(segmento =>
{
var uso = string.IsNullOrWhiteSpace(segmento.Uso)
? ""
: $" ({segmento.Uso})";
var distancia = segmento.DistanciaMetros < 1000
? segmento.DistanciaMetros.ToString("0.##", CultureInfo.InvariantCulture) + " m"
: (segmento.DistanciaMetros / 1000.0).ToString("0.###", CultureInfo.InvariantCulture) + " km";
return $"{segmento.Superficie}{uso}, {distancia}";
}));
}
}

View File

@@ -0,0 +1,37 @@
using Microsoft.AspNetCore.Components;
using RutasDBUS.Modelos.Historial;
using RutasDBUS.Servicios.Configuracion;
namespace RutasDBUS.Components.Pages;
public partial class PlanificadorRutas
{
[Inject] private ServicioValoresFabrica ValoresFabrica { get; set; } = default!;
private ConfiguracionHistorialRuta? _configuracionFabrica;
private ConfiguracionHistorialRuta CrearConfiguracionPredeterminadaHistorial()
{
var config = _configuracionFabrica?.Clonar() ?? CrearConfiguracionBaseHistorial();
var ahora = DateTime.Now;
config.FechaSimulada = ahora.ToString("yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture);
config.HoraSimulada = ahora.ToString("HH:mm:ss", System.Globalization.CultureInfo.InvariantCulture);
return config;
}
private void AplicarValoresFabricaConservandoSeleccion()
{
var actual = CrearConfiguracionLocal();
var predeterminada = CrearConfiguracionPredeterminadaHistorial();
predeterminada.ActivarRangoLineas = actual.ActivarRangoLineas;
predeterminada.LineaMin = actual.LineaMin;
predeterminada.LineaMax = actual.LineaMax;
predeterminada.LineasPermitidas = actual.LineasPermitidas;
predeterminada.UsarHoraSimulada = actual.UsarHoraSimulada;
predeterminada.FechaSimulada = actual.FechaSimulada;
predeterminada.HoraSimulada = actual.HoraSimulada;
predeterminada.CriterioPrioritarioRuta = actual.CriterioPrioritarioRuta;
predeterminada.PreferenciaEtiquetaRuta = actual.PreferenciaEtiquetaRuta;
AplicarConfiguracionLocal(predeterminada);
_modoConfiguracionHistorial = ModoConfiguracionHistorial.Actual;
}
}

View File

@@ -227,6 +227,16 @@
</div> </div>
} }
<button type="button"
class="map-zones-toggle @(_mostrarHerramientaZonas ? "is-active" : "")"
title="Zonas a evitar: @NumeroZonasExclusion"
aria-label="Abrir zonas a evitar"
aria-pressed="@_mostrarHerramientaZonas"
@onclick="AbrirHerramientaZonasAsync">
<span aria-hidden="true">⬡</span>
<span>Zonas</span>
</button>
</div> </div>
@* STACK DE POPUPS (izquierda) *@ @* STACK DE POPUPS (izquierda) *@
@if (_paradaClicada is not null || _itinerarioClicado is not null || _popupInspectorVisible) @if (_paradaClicada is not null || _itinerarioClicado is not null || _popupInspectorVisible)
@@ -847,53 +857,6 @@
</div> </div>
} }
@if (PuedeProbarExclusionCaminata)
{
<div class="route-exclusion-tool" role="region" aria-label="Evitar zona andando">
<div class="route-exclusion-tool__header">
<strong>Probar evitando una zona andando</strong>
<span class="muted">v2</span>
</div>
@if (!_modoDibujoExclusionCaminata && !PoligonoExclusionCaminataValido)
{
<button type="button" class="btn-ghost route-exclusion-tool__button"
title="Dibujar una zona para evitarla en la siguiente búsqueda"
@onclick="IniciarDibujoExclusionCaminataAsync">
Dibujar zona a evitar
</button>
}
else
{
<div class="route-exclusion-tool__status">
@if (_poligonoExclusionCaminataCerrado)
{
<span>Zona cerrada con @_verticesExclusionCaminata.Count vértices. Puedes arrastrarlos.</span>
}
else
{
<span>Pulsa en el mapa para añadir vértices: @_verticesExclusionCaminata.Count.</span>
}
</div>
<div class="route-exclusion-tool__actions">
@if (!_poligonoExclusionCaminataCerrado)
{
<button type="button" class="btn-ghost" @onclick="DeshacerVerticeExclusionCaminataAsync"
disabled="@(_verticesExclusionCaminata.Count == 0)">Deshacer punto</button>
<button type="button" class="btn-ghost route-exclusion-tool__close" @onclick="CerrarPoligonoExclusionCaminataAsync"
disabled="@(_verticesExclusionCaminata.Count < 3)">Cerrar zona</button>
}
@if (PoligonoExclusionCaminataValido)
{
<button type="button" class="btn-primary route-exclusion-tool__calculate" @onclick="CalcularRutaEvitandoPoligonoCaminataAsync">
Calcular alternativa
</button>
}
<button type="button" class="btn-ghost" @onclick="CancelarDibujoExclusionCaminataAsync">Cancelar</button>
</div>
}
</div>
}
@* ========= RESUMEN BONITO ARRIBA (único) ========= *@ @* ========= RESUMEN BONITO ARRIBA (único) ========= *@
<p class="route-main-text"> <p class="route-main-text">
@@ -1503,6 +1466,53 @@
</div> </div>
} }
@if (_mostrarHerramientaZonas)
{
<section class="zones-panel draggable-popup" data-drag-key="zonas-exclusion" aria-label="Zonas a evitar">
<div class="config-panel-header draggable-popup-handle">
<h5>Zonas a evitar <small>v2</small></h5>
<button type="button" class="config-close-btn" title="Cerrar herramienta" aria-label="Cerrar herramienta" @onclick="CerrarHerramientaZonasAsync">×</button>
</div>
<div class="zones-panel__list">
@for (var i = 0; i < _zonasExclusionCaminata.Count; i++)
{
var zona = _zonasExclusionCaminata[i];
<div class="zones-panel__item" @key="zona.Id">
<button type="button" class="btn-ghost @(zona.Id == _zonaExclusionSeleccionada ? "zones-panel__selected" : "")"
disabled="@(_recalculandoZonas || !zona.Cerrada)" @onclick="() => SeleccionarZonaExclusionAsync(zona.Id)">
Zona @(i + 1) · @zona.Vertices.Count puntos @(!zona.Cerrada ? "(abierta)" : "")
</button>
<button type="button" class="btn-ghost zones-panel__icon" title="Eliminar zona" aria-label="Eliminar zona"
disabled="@_recalculandoZonas" @onclick="() => EliminarZonaExclusionAsync(zona.Id)">×</button>
</div>
}
</div>
<div class="zones-panel__actions">
@if (_modoDibujoExclusionCaminata)
{
<button type="button" class="btn-ghost" title="Deshacer último punto" aria-label="Deshacer último punto"
disabled="@(ZonaExclusionSeleccionada!.Vertices.Count == 0)" @onclick="DeshacerVerticeExclusionCaminataAsync">↶</button>
<button type="button" class="btn-ghost" disabled="@(ZonaExclusionSeleccionada!.Vertices.Count < 3)"
@onclick="CerrarPoligonoExclusionCaminataAsync">Cerrar zona</button>
}
else
{
<button type="button" class="btn-ghost" disabled="@(_recalculandoZonas || _zonasExclusionCaminata.Count >= 10)"
@onclick="IniciarDibujoExclusionCaminataAsync">Añadir zona</button>
}
<button type="button" class="btn-ghost" disabled="@(_recalculandoZonas || _zonasExclusionCaminata.Count == 0)"
@onclick="LimpiarZonasExclusionAsync">Borrar todas</button>
<button type="button" class="btn-ghost zones-panel__primary"
disabled="@(_recalculandoZonas || _modoDibujoExclusionCaminata || _coordenadasOrigen is null || _coordenadasDestino is null)"
@onclick="CalcularRutaEvitandoPoligonoCaminataAsync">@(_recalculandoZonas ? "Calculando..." : "Recalcular")</button>
</div>
@if (!string.IsNullOrWhiteSpace(_errorZonas))
{
<p class="zones-panel__error" role="alert">@_errorZonas</p>
}
</section>
}
<!-- CONFIG --> <!-- CONFIG -->
@* <button class="config-toggle-btn" @onclick="ToggleConfigPanel">⚙</button> @* <button class="config-toggle-btn" @onclick="ToggleConfigPanel">⚙</button>
*@ *@

View File

@@ -0,0 +1,19 @@
namespace RutasDBUS.Modelos.Planificacion;
public sealed class InformacionSueloTramo
{
public bool Disponible { get; init; }
public string? Mensaje { get; init; }
public List<SegmentoSueloTramo> Segmentos { get; init; } = new();
}
public sealed class SegmentoSueloTramo
{
public double DistanciaMetros { get; init; }
public string Superficie { get; init; } = "Sin datos";
public string? Uso { get; init; }
public bool? SinPavimentar { get; init; }
public long? IdCamino { get; init; }
public int? IndiceInicioForma { get; init; }
public int? IndiceFinForma { get; init; }
}

View File

@@ -33,7 +33,7 @@ public sealed class ParametrosPlanificadorRuta
public int SegundosPorParadaBus { get; set; } = 30; public int SegundosPorParadaBus { get; set; } = 30;
public double FactorPenalizacionDistanciaPie { get; set; } = 1.8; public double FactorPenalizacionDistanciaPie { get; set; } = 1.8;
public double ParametroPenalizacionDistanciaPieMetros { get; set; } = 200.0; public double ParametroPenalizacionDistanciaPieMetros { get; set; } = 200.0;
public double PesoTransbordoRankingMinutos { get; set; } = 4.0; public double PesoTransbordoRankingMinutos { get; set; } = 6.0;
// Divisor aplicado a la distancia en bus dentro del score equilibrado. // Divisor aplicado a la distancia en bus dentro del score equilibrado.
public double FactorEquilibradoAndarBus { get; set; } = 6.0; public double FactorEquilibradoAndarBus { get; set; } = 6.0;

View File

@@ -51,6 +51,7 @@ builder.Services.AddSingleton<CreadorIndiceTransitoDbus>();
builder.Services.AddSingleton<ServicioIndiceTransitoDbus>(); builder.Services.AddSingleton<ServicioIndiceTransitoDbus>();
builder.Services.AddSingleton<BuscadorRutasTemporalesDbus>(); builder.Services.AddSingleton<BuscadorRutasTemporalesDbus>();
builder.Services.AddSingleton<IPlanificadorRutas, PlanificadorRutasServicio>(); builder.Services.AddSingleton<IPlanificadorRutas, PlanificadorRutasServicio>();
builder.Services.AddSingleton<RutasDBUS.Servicios.Configuracion.ServicioValoresFabrica>();
// HttpClient genérico (lo usas en el Razor: @inject HttpClient Http) // HttpClient genérico (lo usas en el Razor: @inject HttpClient Http)
builder.Services.AddHttpClient(); builder.Services.AddHttpClient();

View File

@@ -5,6 +5,7 @@ using System.Net.Http.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using RutasDBUS.Modelos.Planificacion;
namespace RutasDBUS.Servicios.Caminata; namespace RutasDBUS.Servicios.Caminata;
@@ -112,6 +113,199 @@ public class ClienteValhalla : IClienteValhalla
duracionSegundos); duracionSegundos);
} }
public async Task<InformacionSueloTramo?> ObtenerInformacionSueloAsync(
double[,] linea,
CancellationToken cancellationToken = default)
{
var forma = ConstruirFormaTraza(linea);
if (forma.Count < 2)
return new InformacionSueloTramo
{
Disponible = false,
Mensaje = "No hay geometría suficiente para consultar la superficie."
};
var cuerpo = new SolicitudAtributosSuperficieValhalla
{
Forma = forma,
PerfilCoste = "pedestrian",
AjusteForma = "walk_or_snap",
Filtros = new FiltrosAtributosValhalla
{
Atributos = new List<string>
{
"edge.length",
"edge.surface",
"edge.unpaved",
"edge.use",
"edge.way_id",
"edge.begin_shape_index",
"edge.end_shape_index"
}
},
OpcionesIndicaciones = new OpcionesIndicacionesValhalla
{
Idioma = "es-ES",
Unidades = "kilometers"
}
};
try
{
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeout.CancelAfter(TimeSpan.FromSeconds(8));
using var respuesta = await _httpClient.PostAsJsonAsync(
$"{_baseUrl}/trace_attributes",
cuerpo,
timeout.Token);
if (!respuesta.IsSuccessStatusCode)
return CrearInformacionSueloNoDisponible("El servicio de superficies no ha respondido correctamente.");
var data = await respuesta.Content.ReadFromJsonAsync<RespuestaAtributosSuperficieValhalla>(timeout.Token);
return ConvertirInformacionSuelo(data);
}
catch
{
return CrearInformacionSueloNoDisponible("No se ha podido consultar la superficie del tramo.");
}
}
private static List<PuntoFormaValhalla> ConstruirFormaTraza(double[,] linea)
{
var forma = new List<PuntoFormaValhalla>();
if (linea is null || linea.Rank != 2 || linea.GetLength(1) < 2)
return forma;
for (var i = 0; i < linea.GetLength(0); i++)
{
var latitud = linea[i, 0];
var longitud = linea[i, 1];
if (!double.IsFinite(latitud) || !double.IsFinite(longitud))
continue;
if (forma.Count > 0
&& Math.Abs(forma[^1].Latitud - latitud) < 0.00000001
&& Math.Abs(forma[^1].Longitud - longitud) < 0.00000001)
continue;
forma.Add(new PuntoFormaValhalla { Latitud = latitud, Longitud = longitud });
}
return forma;
}
private static InformacionSueloTramo ConvertirInformacionSuelo(RespuestaAtributosSuperficieValhalla? data)
{
if (data?.Aristas is null || data.Aristas.Count == 0)
return CrearInformacionSueloNoDisponible("No hay datos de superficie para este tramo.");
var segmentos = new List<SegmentoSueloTramo>();
foreach (var arista in data.Aristas)
{
var distanciaMetros = ConvertirLongitudMetros(arista.Longitud, data.Unidades);
if (distanciaMetros <= 0)
continue;
var superficie = EtiquetaSuperficie(arista.Superficie, arista.SinPavimentar);
var uso = EtiquetaUso(arista.Uso);
if (segmentos.Count > 0 && EsMismaSuperficie(segmentos[^1], superficie, uso, arista.SinPavimentar))
{
var anterior = segmentos[^1];
segmentos[^1] = new SegmentoSueloTramo
{
DistanciaMetros = anterior.DistanciaMetros + distanciaMetros,
Superficie = anterior.Superficie,
Uso = anterior.Uso,
SinPavimentar = anterior.SinPavimentar,
IdCamino = anterior.IdCamino == arista.IdCamino ? anterior.IdCamino : null,
IndiceInicioForma = anterior.IndiceInicioForma,
IndiceFinForma = arista.IndiceFinForma ?? anterior.IndiceFinForma
};
}
else
{
segmentos.Add(new SegmentoSueloTramo
{
DistanciaMetros = distanciaMetros,
Superficie = superficie,
Uso = uso,
SinPavimentar = arista.SinPavimentar,
IdCamino = arista.IdCamino,
IndiceInicioForma = arista.IndiceInicioForma,
IndiceFinForma = arista.IndiceFinForma
});
}
}
if (segmentos.Count == 0)
return CrearInformacionSueloNoDisponible("No hay datos de superficie para este tramo.");
return new InformacionSueloTramo
{
Disponible = true,
Segmentos = segmentos
};
}
private static bool EsMismaSuperficie(
SegmentoSueloTramo anterior,
string superficie,
string? uso,
bool? sinPavimentar)
=> string.Equals(anterior.Superficie, superficie, StringComparison.Ordinal)
&& string.Equals(anterior.Uso, uso, StringComparison.Ordinal)
&& anterior.SinPavimentar == sinPavimentar;
private static double ConvertirLongitudMetros(double? longitud, string? unidades)
{
if (longitud is not double valor || !double.IsFinite(valor) || valor <= 0)
return 0;
return string.Equals(unidades, "miles", StringComparison.OrdinalIgnoreCase)
? valor * 1609.344
: valor * 1000.0;
}
private static string EtiquetaSuperficie(string? superficie, bool? sinPavimentar)
=> (superficie ?? "").Trim().ToLowerInvariant() switch
{
"paved_smooth" => "Pavimento liso",
"paved" => "Pavimento",
"paved_rough" => "Pavimento rugoso",
"compacted" => "Tierra compactada",
"dirt" => "Tierra",
"gravel" => "Grava",
"path" => "Senda",
"impassable" => "Intransitable",
_ when sinPavimentar == true => "No pavimentado",
_ => "Sin datos"
};
private static string? EtiquetaUso(string? uso)
=> (uso ?? "").Trim().ToLowerInvariant() switch
{
"sidewalk" => "Acera",
"footway" => "Camino peatonal",
"steps" => "Escaleras",
"path" => "Senda",
"track" => "Pista",
"road" => "Calzada",
"service_other" => "Vía de servicio",
"other" => "Otro",
"" => null,
var valor => valor
};
private static InformacionSueloTramo CrearInformacionSueloNoDisponible(string mensaje)
=> new()
{
Disponible = false,
Mensaje = mensaje
};
// ======================= // =======================
// Modelos request/response // Modelos request/response
// ======================= // =======================
@@ -134,6 +328,75 @@ public class ClienteValhalla : IClienteValhalla
public OpcionesIndicacionesValhalla OpcionesIndicaciones { get; set; } = new(); public OpcionesIndicacionesValhalla OpcionesIndicaciones { get; set; } = new();
} }
private sealed class SolicitudAtributosSuperficieValhalla
{
[JsonPropertyName("shape")]
public List<PuntoFormaValhalla> Forma { get; set; } = new();
[JsonPropertyName("costing")]
public string PerfilCoste { get; set; } = "pedestrian";
[JsonPropertyName("shape_match")]
public string AjusteForma { get; set; } = "walk_or_snap";
[JsonPropertyName("filters")]
public FiltrosAtributosValhalla Filtros { get; set; } = new();
[JsonPropertyName("directions_options")]
public OpcionesIndicacionesValhalla OpcionesIndicaciones { get; set; } = new();
}
private sealed class PuntoFormaValhalla
{
[JsonPropertyName("lat")]
public double Latitud { get; set; }
[JsonPropertyName("lon")]
public double Longitud { get; set; }
}
private sealed class FiltrosAtributosValhalla
{
[JsonPropertyName("attributes")]
public List<string> Atributos { get; set; } = new();
[JsonPropertyName("action")]
public string Accion { get; set; } = "include";
}
private sealed class RespuestaAtributosSuperficieValhalla
{
[JsonPropertyName("edges")]
public List<AristaAtributosValhalla> Aristas { get; set; } = new();
[JsonPropertyName("units")]
public string? Unidades { get; set; }
}
private sealed class AristaAtributosValhalla
{
[JsonPropertyName("length")]
public double? Longitud { get; set; }
[JsonPropertyName("surface")]
public string? Superficie { get; set; }
[JsonPropertyName("unpaved")]
public bool? SinPavimentar { get; set; }
[JsonPropertyName("use")]
public string? Uso { get; set; }
[JsonPropertyName("way_id")]
public long? IdCamino { get; set; }
[JsonPropertyName("begin_shape_index")]
public int? IndiceInicioForma { get; set; }
[JsonPropertyName("end_shape_index")]
public int? IndiceFinForma { get; set; }
}
private sealed class LocalizacionValhalla private sealed class LocalizacionValhalla
{ {
[JsonPropertyName("lat")] [JsonPropertyName("lat")]

View File

@@ -1,4 +1,5 @@
using System.Threading.Tasks; using System.Threading.Tasks;
using RutasDBUS.Modelos.Planificacion;
namespace RutasDBUS.Servicios.Caminata; namespace RutasDBUS.Servicios.Caminata;
@@ -15,4 +16,12 @@ public interface IClienteValhalla
double longitudHasta, double longitudHasta,
string costing = "pedestrian", string costing = "pedestrian",
List<List<double[]>>? excludePolygons = null); // "pedestrian", "bicycle", etc. List<List<double[]>>? excludePolygons = null); // "pedestrian", "bicycle", etc.
/// <summary>
/// Obtiene la superficie y el tipo de uso de los segmentos de una geometría peatonal.
/// La respuesta es informativa y no interviene en el cálculo de rutas.
/// </summary>
Task<InformacionSueloTramo?> ObtenerInformacionSueloAsync(
double[,] linea,
CancellationToken cancellationToken = default);
} }

View File

@@ -0,0 +1,57 @@
using System.Text.Json;
using RutasDBUS.Modelos.Historial;
namespace RutasDBUS.Servicios.Configuracion;
public sealed class ServicioValoresFabrica
{
private readonly IConfiguration _configuracion;
private readonly ILogger<ServicioValoresFabrica> _logger;
private readonly SemaphoreSlim _bloqueo = new(1, 1);
private readonly string _ruta;
public ServicioValoresFabrica(IConfiguration configuracion, ILogger<ServicioValoresFabrica> logger)
{
_configuracion = configuracion;
_logger = logger;
var directorio = configuracion["ValoresFabrica:Directorio"] ??
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "RutasDBUS");
_ruta = Path.Combine(directorio, "configuracion-fabrica.json");
}
public async Task<ConfiguracionHistorialRuta?> CargarAsync()
{
await _bloqueo.WaitAsync();
try
{
if (!File.Exists(_ruta)) return null;
using var archivo = File.OpenRead(_ruta);
var config = await JsonSerializer.DeserializeAsync<ConfiguracionHistorialRuta>(archivo);
return config is not null && EsValida(config) ? config : null;
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException)
{
_logger.LogWarning("No se pudieron leer los valores de fabrica: {Tipo}", ex.GetType().Name);
return null;
}
finally { _bloqueo.Release(); }
}
private static bool EsValida(ConfiguracionHistorialRuta config)
{
static bool Positivo(double? valor) => valor is { } v && double.IsFinite(v) && v > 0;
static bool NoNegativo(double? valor) => valor is { } v && double.IsFinite(v) && v >= 0;
return config.Version == 1 &&
Positivo(config.VelocidadPieKmH) && Positivo(config.VelocidadBusKmH) &&
Positivo(config.FactorEquilibradoAndarBus) && Positivo(config.FactorPenalizacionDistanciaPie) &&
Positivo(config.ParametroPenalizacionDistanciaPieMetros) && Positivo(config.CoeficienteEsfuerzoAltura) &&
NoNegativo(config.PesoTransbordoRankingMinutos) &&
NoNegativo(config.UmbralPendienteSubidaRuta) && NoNegativo(config.UmbralPendienteBajadaRuta) &&
NoNegativo(config.UmbralAlturaAlertaPendienteRutaMetros) &&
config.ItinerarioMin is >= 1 and <= 99 && config.ItinerarioMax >= config.ItinerarioMin &&
config.ItinerarioMax <= 99 && config.MinTransbordosPermitidos >= 0 &&
config.MaxTransbordosPermitidos >= config.MinTransbordosPermitidos &&
config.MaxTransbordosPermitidos <= 10 && config.ElevMaxPuntosPorTramo is >= 2 and <= 999 &&
config.RadioCandidatasMetros > 0 && config.MargenTransbordoSegundos is >= 0 and <= 1800;
}
}

View File

@@ -3884,13 +3884,12 @@ public sealed class PlanificadorRutasServicio : IPlanificadorRutas
{ {
if (excludePolygons is { Count: > 0 }) if (excludePolygons is { Count: > 0 })
{ {
// Una zona claramente alejada no puede cambiar razonablemente este trayecto. // Solo reutilizar una geometria conocida y alejada de todas las zonas.
// Evitamos enviar a Valhalla una busqueda restringida innecesaria. var claveNormal = BuildWalkKey("valhalla", latitudOrigen, longitudOrigen, latitudDestino, longitudDestino);
if (!ExclusionPuedeAfectarTrayecto( if (_cacheCaminatas.TryGetValue(claveNormal, out var normal) && normal.linea is not null &&
latitudOrigen, longitudOrigen, latitudDestino, longitudDestino, excludePolygons)) !ExclusionPuedeAfectarTrayecto(normal.linea, excludePolygons))
{ {
return await WalkValhallaCached( return normal;
latitudOrigen, longitudOrigen, latitudDestino, longitudDestino);
} }
var claveExclusion = BuildWalkKey("valhalla-exclusion", latitudOrigen, longitudOrigen, latitudDestino, longitudDestino) var claveExclusion = BuildWalkKey("valhalla-exclusion", latitudOrigen, longitudOrigen, latitudDestino, longitudDestino)
@@ -3909,13 +3908,16 @@ public sealed class PlanificadorRutasServicio : IPlanificadorRutas
excludePolygons: excludePolygons); excludePolygons: excludePolygons);
var valor = (linea, distancia, duracion); var valor = (linea, distancia, duracion);
_cacheCaminatasConExclusion[claveExclusion] = valor; if (linea is not null && distancia > 0)
{
if (_cacheCaminatasConExclusion.Count >= 2048) _cacheCaminatasConExclusion.Clear();
_cacheCaminatasConExclusion[claveExclusion] = valor;
}
return valor; return valor;
} }
catch catch
{ {
var vacio = (linea: (double[,]?)null, distancia: 0.0, duracion: 0.0); var vacio = (linea: (double[,]?)null, distancia: 0.0, duracion: 0.0);
_cacheCaminatasConExclusion[claveExclusion] = vacio;
return vacio; return vacio;
} }
} }
@@ -3945,38 +3947,35 @@ public sealed class PlanificadorRutasServicio : IPlanificadorRutas
} }
} }
private static bool ExclusionPuedeAfectarTrayecto( private static bool ExclusionPuedeAfectarTrayecto(double[,] linea, List<List<double[]>> poligonos)
double latitudOrigen,
double longitudOrigen,
double latitudDestino,
double longitudDestino,
List<List<double[]>> poligonos)
{ {
const double umbralMetros = 2500.0; if (linea.GetLength(0) < 2) return true;
var escalaLat = 111_320.0; var minLat = double.PositiveInfinity;
var escalaLon = Math.Max(1.0, escalaLat * Math.Cos(latitudOrigen * Math.PI / 180.0)); var maxLat = double.NegativeInfinity;
var ax = 0.0; var minLon = double.PositiveInfinity;
var ay = 0.0; var maxLon = double.NegativeInfinity;
var bx = (longitudDestino - longitudOrigen) * escalaLon; for (var i = 0; i < linea.GetLength(0); i++)
var by = (latitudDestino - latitudOrigen) * escalaLat; {
var ab2 = bx * bx + by * by; if (!double.IsFinite(linea[i, 0]) || !double.IsFinite(linea[i, 1])) return true;
minLat = Math.Min(minLat, linea[i, 0]);
maxLat = Math.Max(maxLat, linea[i, 0]);
minLon = Math.Min(minLon, linea[i, 1]);
maxLon = Math.Max(maxLon, linea[i, 1]);
}
// Margen conservador para las vias y sus extremos, no solo los vertices dibujados.
const double margenLat = 2500.0 / 111320.0;
var margenLon = margenLat / Math.Max(0.01, Math.Cos((minLat + maxLat) * Math.PI / 360.0));
foreach (var poligono in poligonos) foreach (var poligono in poligonos)
{ {
foreach (var punto in poligono) if (poligono.Count < 3 || poligono.Any(p => p.Length < 2 || !double.IsFinite(p[0]) || !double.IsFinite(p[1])))
{ return true;
if (punto is null || punto.Length < 2) continue; // Los poligonos usan [lon, lat]; sus limites incluyen lados y area interior.
// Valhalla recibe los vertices como [longitud, latitud]. if (poligono.Max(p => p[1]) >= minLat - margenLat &&
var px = (punto[0] - longitudOrigen) * escalaLon; poligono.Min(p => p[1]) <= maxLat + margenLat &&
var py = (punto[1] - latitudOrigen) * escalaLat; poligono.Max(p => p[0]) >= minLon - margenLon &&
var t = ab2 > 0 ? Math.Clamp((px * bx + py * by) / ab2, 0.0, 1.0) : 0.0; poligono.Min(p => p[0]) <= maxLon + margenLon)
var dx = px - (ax + t * bx); return true;
var dy = py - (ay + t * by);
if (Math.Sqrt(dx * dx + dy * dy) <= umbralMetros)
return true;
}
} }
return false; return false;
} }

View File

@@ -1132,65 +1132,69 @@ html, body {
line-height: 1.25; line-height: 1.25;
} }
.route-exclusion-tool { .zones-panel {
margin: 0 0 .55rem; position: fixed;
padding: .48rem .6rem; top: 90px;
border: 1px solid rgba(248, 113, 113, .42); left: 16px;
border-left: 3px solid #ef4444; width: min(340px, calc(100vw - 32px));
border-radius: 6px; max-height: calc(100dvh - 110px);
background: rgba(127, 29, 29, .16); overflow: auto;
z-index: 1600;
padding: .75rem .85rem;
border: 1px solid rgba(148, 163, 184, .4);
border-radius: .75rem;
background: rgba(15, 23, 42, .97);
color: #e5e7eb;
box-shadow: 0 16px 30px rgba(0, 0, 0, .35);
scrollbar-width: thin;
scrollbar-color: #64748b transparent;
} }
.route-exclusion-tool__header { .zones-panel .config-panel-header h5 { font-size: .94rem; }
display: flex; .zones-panel h5 small { font-size: .74rem; color: #94a3b8; }
.zones-panel__list { display: grid; gap: .35rem; margin-block: .5rem; }
.zones-panel__item { display: flex; gap: .35rem; min-width: 0; }
.zones-panel__item > :first-child { flex: 1; text-align: left; }
.zones-panel__actions { display: flex; flex-wrap: wrap; align-items: center; gap: .4rem; margin-top: .5rem; }
.zones-panel__actions .btn-ghost { white-space: nowrap; }
.zones-panel__icon { padding-inline: .6rem; }
.btn-ghost.zones-panel__primary { background: #1d4ed8; color: #fff; }
.btn-ghost.zones-panel__primary:disabled { background: #111827; color: #94a3b8; }
.btn-ghost.zones-panel__selected { border-color: #e879f9; background: #35203d; }
.zones-panel__error { margin: .5rem 0 0; color: #fda4af; font-size: .8rem; }
.map-zones-toggle {
position: absolute;
left: 12px;
bottom: 42px;
z-index: 1150;
display: inline-flex;
align-items: center; align-items: center;
justify-content: space-between; gap: .38rem;
gap: .6rem; min-height: 34px;
margin-bottom: .28rem; padding: .35rem .7rem;
} border: 1px solid rgba(148, 163, 184, .65);
.route-exclusion-tool__actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: .35rem;
margin-top: .35rem;
}
.route-exclusion-tool__close {
border-color: rgba(248, 113, 113, .6);
color: #fecaca;
}
.route-exclusion-tool__close:not(:disabled):hover {
border-color: #f87171;
background: rgba(185, 28, 28, .25);
color: #fff;
}
.route-exclusion-tool__close:disabled {
opacity: .45;
}
.route-exclusion-tool__calculate {
border: 1px solid rgba(125, 211, 252, .7);
border-radius: 999px; border-radius: 999px;
background: #1d4ed8; background: rgba(15, 23, 42, .94);
padding: .3rem .9rem; color: #e5e7eb;
font-size: .78rem; box-shadow: 0 8px 20px rgba(15, 23, 42, .45);
font-weight: 700;
color: #fff;
cursor: pointer; cursor: pointer;
font-size: .76rem;
font-weight: 650;
line-height: 1;
} }
.route-exclusion-tool__calculate:hover { .map-zones-toggle:hover,
background: #2563eb; .map-zones-toggle:focus-visible,
border-color: #bae6fd; .map-zones-toggle.is-active {
border-color: #c084fc;
background: #312e81;
color: #fff;
outline: none;
} }
.route-exclusion-tool__hint { .map-zones-toggle > span:first-child {
color: var(--muted-text, #a1a1aa); color: #e879f9;
font-size: .82rem; font-size: 1rem;
} }
.rt-exclusion-vertex { .rt-exclusion-vertex {
@@ -1198,8 +1202,8 @@ html, body {
height: 18px !important; height: 18px !important;
border: 2px solid #fff; border: 2px solid #fff;
border-radius: 50%; border-radius: 50%;
background: #dc2626; background: #a21caf;
box-shadow: 0 0 0 2px #7f1d1d, 0 1px 4px rgba(0, 0, 0, .55); box-shadow: 0 0 0 2px #581c87, 0 1px 4px rgba(0, 0, 0, .55);
} }
.taxibus-notice { .taxibus-notice {
@@ -3254,6 +3258,7 @@ html, body {
font-size: .74rem; font-size: .74rem;
} }
/* ===== SLIDER (range) limpio para el panel ===== */ /* ===== SLIDER (range) limpio para el panel ===== */
.config-range { .config-range {
width: 100%; width: 100%;
@@ -3588,6 +3593,14 @@ html, body {
min-height: 0; min-height: 0;
} }
.map-zones-toggle {
left: 10px;
bottom: calc(var(--mobile-nav-height) + env(safe-area-inset-bottom) + 10px);
min-height: 44px;
padding-inline: .8rem;
font-size: .76rem;
}
.coords-bar, .coords-bar,
.map-container .leaflet-control-zoom { .map-container .leaflet-control-zoom {
display: none !important; display: none !important;

View File

@@ -268,12 +268,31 @@ window.rt.selectElevationSegment = function (
}; };
}; };
window.rt.setElevationSegmentSurface = function (routeId, segmentIndex, surfaceText) {
const linesByRoute = window.rt._elevationSegmentLines || {};
const line = linesByRoute[routeId] && linesByRoute[routeId][segmentIndex];
if (!line || !line._rtElevationSegment || typeof line._rtElevationTooltip !== "function") return;
line._rtElevationSegment.surfaceText = surfaceText || "No disponible";
const progress = Number.isFinite(line._rtElevationProgress)
? line._rtElevationProgress
: 0.5;
try {
line.setTooltipContent(line._rtElevationTooltip(line._rtElevationSegment, progress));
if (window.rt._selectedElevationSegment &&
window.rt._selectedElevationSegment.line === line) {
line.openTooltip();
}
} catch { }
};
window.rt.clearRoutes = function () { window.rt.clearRoutes = function () {
if (!window.rt.ensureRouteLayer()) return; if (!window.rt.ensureRouteLayer()) return;
window.rt.clearElevationSegmentSelection(); window.rt.clearElevationSegmentSelection();
window.rt._routeLayer.clearLayers(); window.rt._routeLayer.clearLayers();
window.rt._routeLines = {}; window.rt._routeLines = {};
window.rt._routeCoords = {}; window.rt._routeCoords = {};
window.rt._elevationSegmentLines = {};
if (window.rt._routeStopsLayer) { if (window.rt._routeStopsLayer) {
window.rt._routeStopsLayer.clearLayers(); window.rt._routeStopsLayer.clearLayers();
@@ -292,6 +311,8 @@ window.rt.drawRoute = function (id, coords, options) {
window.rt._routeLayer.removeLayer(window.rt._routeLines[id]); window.rt._routeLayer.removeLayer(window.rt._routeLines[id]);
delete window.rt._routeLines[id]; delete window.rt._routeLines[id];
} }
window.rt._elevationSegmentLines = window.rt._elevationSegmentLines || {};
delete window.rt._elevationSegmentLines[id];
const arrowId = id + "__arrow"; const arrowId = id + "__arrow";
if (window.rt._routeLines[arrowId]) { if (window.rt._routeLines[arrowId]) {
@@ -357,6 +378,8 @@ window.rt.drawRoute = function (id, coords, options) {
if (elevationSegments.length > 0 && coords && coords.length >= 2) { if (elevationSegments.length > 0 && coords && coords.length >= 2) {
const group = window.L.layerGroup(); const group = window.L.layerGroup();
window.rt._elevationSegmentLines = window.rt._elevationSegmentLines || {};
window.rt._elevationSegmentLines[id] = {};
const progressAlongSegment = function (segmentCoords, latlng) { const progressAlongSegment = function (segmentCoords, latlng) {
if (!latlng || !Array.isArray(segmentCoords) || segmentCoords.length < 2) return 0.5; if (!latlng || !Array.isArray(segmentCoords) || segmentCoords.length < 2) return 0.5;
@@ -404,14 +427,18 @@ window.rt.drawRoute = function (id, coords, options) {
? estimatedElevation.toFixed(2) + " m" ? estimatedElevation.toFixed(2) + " m"
: "-"; : "-";
return [ const tooltipLines = [
"Pendiente: " + slopeText, "Pendiente: " + slopeText,
"Dist. anterior: " + distanceText, "Dist. anterior: " + distanceText,
"Altura: " + elevationText "Altura: " + elevationText
].join("<br>"); ];
if (segment && segment.surfaceText) {
tooltipLines.push("Superficie: " + String(segment.surfaceText));
}
return tooltipLines.join("<br>");
}; };
elevationSegments.forEach(function (segment) { elevationSegments.forEach(function (segment, segmentIndex) {
const segmentCoords = segment && segment.coords; const segmentCoords = segment && segment.coords;
if (!Array.isArray(segmentCoords) || segmentCoords.length < 2) return; if (!Array.isArray(segmentCoords) || segmentCoords.length < 2) return;
@@ -442,6 +469,10 @@ window.rt.drawRoute = function (id, coords, options) {
} }
const segmentLine = buildLine(segmentCoords, segmentOpts); const segmentLine = buildLine(segmentCoords, segmentOpts);
segmentLine._rtElevationSegment = segment;
segmentLine._rtElevationTooltip = elevationTooltip;
segmentLine._rtElevationProgress = 0.5;
window.rt._elevationSegmentLines[id][segmentIndex] = segmentLine;
const originalSegmentStyle = { const originalSegmentStyle = {
color: segmentColor, color: segmentColor,
weight, weight,
@@ -460,6 +491,7 @@ window.rt.drawRoute = function (id, coords, options) {
}); });
const updateTooltipAtCursor = function (e) { const updateTooltipAtCursor = function (e) {
const progress = progressAlongSegment(segmentCoords, e && e.latlng); const progress = progressAlongSegment(segmentCoords, e && e.latlng);
segmentLine._rtElevationProgress = progress;
segmentLine.setTooltipContent(elevationTooltip(segment, progress)); segmentLine.setTooltipContent(elevationTooltip(segment, progress));
}; };
segmentLine.on("mouseover", updateTooltipAtCursor); segmentLine.on("mouseover", updateTooltipAtCursor);
@@ -475,6 +507,19 @@ window.rt.drawRoute = function (id, coords, options) {
group, group,
originalSegmentStyle, originalSegmentStyle,
weight); weight);
segment.surfaceText = "Consultando…";
updateTooltipAtCursor(e);
try {
segmentLine.setTooltipContent(
elevationTooltip(segment, segmentLine._rtElevationProgress));
} catch { }
if (window.rt._dotNetHelper) {
window.rt._dotNetHelper.invokeMethodAsync(
"OnElevationSegmentSelected",
id,
segmentIndex,
segmentCoords).catch(function () { });
}
window.requestAnimationFrame(function () { window.requestAnimationFrame(function () {
try { try {
if (segmentLine._path && typeof segmentLine._path.blur === "function") { if (segmentLine._path && typeof segmentLine._path.blur === "function") {
@@ -657,91 +702,100 @@ window.rt.setDotNetHelper = function (dotNetHelper) {
window.rt._dotNetHelper = dotNetHelper; window.rt._dotNetHelper = dotNetHelper;
}; };
// Editor temporal de zonas que se excluiran de las caminatas de Valhalla. // Las zonas son independientes de las capas de rutas; cerrar el editor retira sus eventos.
window.rt._exclusionPolygonLayer = null;
window.rt._exclusionPolygon = null;
window.rt._exclusionPolygonMarkers = [];
window.rt._exclusionEditMode = false; window.rt._exclusionEditMode = false;
window.rt._exclusionPolygonLayer = null;
window.rt._exclusionClickHandler = null;
window.rt._exclusionMapContainer = null;
window.rt.beginExclusionPolygonEditor = function () { window.rt.clearExclusionPolygonEditor = function () {
const map = window.__rtLeafletMap; if (window.rt._exclusionMapContainer && window.rt._exclusionClickHandler)
if (!map || !window.L) return; window.rt._exclusionMapContainer.removeEventListener("click", window.rt._exclusionClickHandler, true);
window.rt._exclusionPolygonLayer?.remove();
window.rt._exclusionPolygonLayer = null;
window.rt._exclusionClickHandler = null;
window.rt._exclusionMapContainer = null;
window.rt._exclusionEditMode = false;
};
window.rt.updateExclusionPolygonEditor = function (zones, selectedId, busy) {
window.rt.clearExclusionPolygonEditor(); window.rt.clearExclusionPolygonEditor();
window.rt._exclusionEditMode = true;
window.rt._exclusionPolygonLayer = window.L.layerGroup().addTo(map);
window.rt._exclusionPolygon = window.L.polygon([], {
color: "#dc2626",
weight: 3,
opacity: 0.95,
fillColor: "#ef4444",
fillOpacity: 0.2,
interactive: false
}).addTo(window.rt._exclusionPolygonLayer);
};
window.rt.updateExclusionPolygonEditor = function (points) {
const map = window.__rtLeafletMap; const map = window.__rtLeafletMap;
if (!map || !window.L) return; const L = window.L;
if (!window.rt._exclusionPolygonLayer) window.rt.beginExclusionPolygonEditor(); if (!map || !L) return;
const polygonPoints = Array.isArray(points) const layer = L.layerGroup().addTo(map);
? points.filter(p => Array.isArray(p) && Number.isFinite(Number(p[0])) && Number.isFinite(Number(p[1]))) window.rt._exclusionPolygonLayer = layer;
.map(p => [Number(p[0]), Number(p[1])]) const drawing = !busy && zones.some(zone => zone.id === selectedId && !zone.closed);
: []; window.rt._exclusionEditMode = drawing;
window.rt._exclusionPolygon.setLatLngs(polygonPoints); for (const zone of zones) {
window.rt._exclusionPolygonMarkers.forEach(marker => marker.remove()); const selected = zone.id === selectedId;
window.rt._exclusionPolygonMarkers = polygonPoints.map((point, index) => { const points = zone.points.map(p => [p[0], p[1]]);
const marker = window.L.marker(point, { const options = {
draggable: true, color: selected ? "#e879f9" : "#f97316", weight: selected ? 3 : 2,
autoPan: true, fillOpacity: zone.closed ? 0.18 : 0.08, interactive: false
bubblingMouseEvents: false, };
icon: window.L.divIcon({ const polygon = (zone.closed ? L.polygon(points, options) : L.polyline(points, options)).addTo(layer);
className: "rt-exclusion-vertex", if (!selected || busy) continue;
html: "", points.forEach((point, index) => {
iconSize: [18, 18], const marker = L.marker(point, {
iconAnchor: [9, 9] draggable: true, autoPan: true, bubblingMouseEvents: false, zIndexOffset: 3000,
}) icon: L.divIcon({ className: "rt-exclusion-vertex", html: "",
}).addTo(window.rt._exclusionPolygonLayer); iconSize: [18, 18], iconAnchor: [9, 9] })
}).addTo(layer);
marker.on("click", event => { const update = () => {
if (event && event.originalEvent) event.originalEvent.stopPropagation(); const pos = marker.getLatLng();
points[index] = [pos.lat, pos.lng];
polygon.setLatLngs(points);
return pos;
};
marker.on("drag", update);
marker.on("dragend", () => {
const pos = update();
window.rt._dotNetHelper?.invokeMethodAsync(
"OnExclusionPolygonVertexDragged", zone.id, index, pos.lat, pos.lng);
});
}); });
marker.on("drag", event => { }
const latLng = event.target.getLatLng();
const current = window.rt._exclusionPolygon.getLatLngs()[0] || [];
if (current[index]) {
current[index] = latLng;
window.rt._exclusionPolygon.setLatLngs([current]);
}
});
marker.on("dragend", event => {
const latLng = event.target.getLatLng();
window.rt._dotNetHelper?.invokeMethodAsync(
"OnExclusionPolygonVertexDragged", index, latLng.lat, latLng.lng);
});
return marker;
});
};
window.rt.closeExclusionPolygonEditor = function () { if (drawing) {
if (window.rt._exclusionPolygon) { const container = map.getContainer();
window.rt._exclusionPolygon.setStyle({ fillOpacity: 0.28 }); const handler = event => {
if (event.target.closest(".rt-exclusion-vertex, .leaflet-control, .leaflet-popup")) return;
event.preventDefault();
event.stopImmediatePropagation();
const pos = map.mouseEventToLatLng(event);
window.rt._dotNetHelper?.invokeMethodAsync("OnExclusionPolygonMapClick", pos.lat, pos.lng);
};
container.addEventListener("click", handler, true);
window.rt._exclusionMapContainer = container;
window.rt._exclusionClickHandler = handler;
} }
}; };
window.rt.finishExclusionPolygonEditor = function () { window.rt.isExclusionPolygonValid = function (points) {
window.rt._exclusionEditMode = false; if (!Array.isArray(points) || points.length < 3) return false;
}; const eps = 1e-12;
const cross = (a, b, c) => (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
window.rt.clearExclusionPolygonEditor = function () { const on = (a, b, p) => Math.abs(cross(a, b, p)) < eps &&
window.rt._exclusionPolygonMarkers.forEach(marker => marker.remove()); p[0] >= Math.min(a[0], b[0]) - eps && p[0] <= Math.max(a[0], b[0]) + eps &&
window.rt._exclusionPolygonMarkers = []; p[1] >= Math.min(a[1], b[1]) - eps && p[1] <= Math.max(a[1], b[1]) + eps;
window.rt._exclusionPolygonLayer?.remove(); const n = points.length;
window.rt._exclusionPolygonLayer = null; let area = 0;
window.rt._exclusionPolygon = null; for (let i = 0; i < n; i++) {
window.rt._exclusionEditMode = false; const a = points[i], b = points[(i + 1) % n];
if (!a || a.length < 2 || !a.every(Number.isFinite)) return false;
if (a[0] === b[0] && a[1] === b[1]) return false;
area += cross(points[0], a, b);
for (let j = i + 2; j < n; j++) {
if (i === 0 && j === n - 1) continue;
const c = points[j], d = points[(j + 1) % n];
if ((cross(a, b, c) * cross(a, b, d) < 0 && cross(c, d, a) * cross(c, d, b) < 0) ||
on(a, b, c) || on(a, b, d) || on(c, d, a) || on(c, d, b)) return false;
}
}
return Math.abs(area) > eps;
}; };

View File

@@ -0,0 +1,111 @@
using System.Reflection;
using System.Text.Json;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using RutasDBUS.Components.Pages;
using RutasDBUS.Modelos.Historial;
using RutasDBUS.Modelos.Planificacion;
using RutasDBUS.Servicios.Caminata;
using RutasDBUS.Servicios.Configuracion;
using RutasDBUS.Servicios.Planificacion;
static void Check(bool result, string message)
{
if (!result) throw new Exception(message);
Console.WriteLine("OK " + message);
}
var baseMethod = typeof(PlanificadorRutas).GetMethod("CrearConfiguracionBaseHistorial", BindingFlags.Static | BindingFlags.NonPublic)!;
var defaults = (ConfiguracionHistorialRuta)baseMethod.Invoke(null, null)!;
Check(defaults.PesoTransbordoRankingMinutos == 6, "Penalizacion inicial de 6 minutos");
var folder = Path.Combine(Path.GetTempPath(), "rutasdbus-regression-" + Guid.NewGuid().ToString("N"));
IConfiguration Config() => new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
{
["ValoresFabrica:Directorio"] = folder
}).Build();
ServicioValoresFabrica Service() => new(Config(), NullLogger<ServicioValoresFabrica>.Instance);
try
{
defaults.PesoTransbordoRankingMinutos = 9;
defaults.FechaSimulada = null;
defaults.HoraSimulada = null;
Directory.CreateDirectory(folder);
var file = Path.Combine(folder, "configuracion-fabrica.json");
await File.WriteAllTextAsync(file, JsonSerializer.Serialize(defaults));
var loaded = await Service().CargarAsync();
Check(loaded?.PesoTransbordoRankingMinutos == 9, "Valores compartidos entre instancias");
Check(loaded is { FechaSimulada: null, HoraSimulada: null }, "Los valores de fabrica no fijan la fecha y hora");
File.WriteAllText(file, "{incompleto");
Check(await Service().CargarAsync() is null, "JSON danado permite usar valores base");
}
finally
{
// Solo datos temporales creados por esta prueba.
if (Directory.Exists(folder)) Directory.Delete(folder, recursive: true);
}
var fake = new FakeValhalla();
var planner = new PlanificadorRutasServicio(null!, null!, fake, null!, null!, null!, null!);
var walk = typeof(PlanificadorRutasServicio).GetMethod("WalkValhallaCached", BindingFlags.Instance | BindingFlags.NonPublic)!;
async Task<(double[,]? linea, double distancia, double duracion)> Walk(List<List<double[]>>? zones = null) =>
await (Task<(double[,]?, double, double)>)walk.Invoke(planner, new object?[] { 43.31, -1.99, 43.32, -1.98, zones })!;
List<double[]> Ring(double lon, double lat, double size) =>
new() { new[] { lon, lat }, new[] { lon + size, lat }, new[] { lon + size, lat + size },
new[] { lon, lat + size }, new[] { lon, lat } };
await Walk();
var far = new List<List<double[]>> { Ring(-2.3, 43.0, .01) };
await Walk(far);
Check(fake.Calls == 1, "Zona lejana reutiliza la geometria conocida");
var near = new List<List<double[]>> { Ring(-1.985, 43.315, .001), Ring(-1.982, 43.317, .001) };
await Walk(near);
Check(fake.Zones == 2, "Se envian todos los poligonos");
await Walk(near);
Check(fake.Calls == 2, "Mismas zonas reutilizan cache restringida");
near[0][0][0] += .00001;
await Walk(near);
Check(fake.Calls == 3, "Mover un vertice invalida esa entrada de cache");
await Walk(new() { Ring(-2.2, 43.1, .5) });
Check(fake.Calls == 4, "Una zona que rodea la ruta no se ignora aunque sus vertices esten lejos");
fake.Fail = true;
var retry = new List<List<double[]>> { Ring(-1.984, 43.312, .0002) };
await Walk(retry);
fake.Fail = false;
await Walk(retry);
Check(fake.Calls == 6, "Un fallo temporal no queda guardado en cache de zonas");
var page = new PlanificadorRutas();
var flags = BindingFlags.Instance | BindingFlags.NonPublic;
var options = (List<AlternativaRuta>)typeof(PlanificadorRutas).GetField("_opcionesRuta", flags)!.GetValue(page)!;
var selected = typeof(PlanificadorRutas).GetField("_indiceAlternativaSeleccionada", flags)!;
var remember = typeof(PlanificadorRutas).GetMethod("ObtenerProveedorSoloAPieParaRecalculo", flags)!;
string? Remember(bool keep) => (string?)remember.Invoke(page, new object[] { keep });
options.Add(new() { EsSoloAPie = true, ProveedorRuta = "v1" });
options.Add(new() { EsSoloAPie = true, ProveedorRuta = "v2" });
selected.SetValue(page, 1);
Check(Remember(true) == "v2", "Se conserva la version andando seleccionada");
options.Clear();
Check(Remember(true) == "v2", "Otro cambio de punto durante el calculo conserva v2");
options.Add(new() { EsSoloAPie = false });
selected.SetValue(page, 0);
Check(Remember(true) is null, "Seleccionar bus libera la preferencia andando");
options.Clear();
Check(Remember(false) is null, "Nueva seleccion de criterio no hereda la preferencia andando");
Console.WriteLine("Pruebas completadas.");
sealed class FakeValhalla : IClienteValhalla
{
public int Calls { get; private set; }
public int Zones { get; private set; }
public bool Fail { get; set; }
public Task<(double[,]? Linea, double DistanciaMetros, double DuracionSegundos)> ObtenerRutaAsync(
double latitudDesde, double longitudDesde, double latitudHasta, double longitudHasta,
string costing = "pedestrian", List<List<double[]>>? excludePolygons = null)
{
Calls++;
Zones = excludePolygons?.Count ?? 0;
return Task.FromResult<(double[,]?, double, double)>(Fail ? (null, 0, 0) :
(new[,] { { latitudDesde, longitudDesde }, { latitudHasta, longitudHasta } }, 100, 100));
}
}

View File

@@ -0,0 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../RutasDBUS/RutasDBUS.csproj" />
</ItemGroup>
</Project>

80
tests/verify-zones.cjs Normal file
View File

@@ -0,0 +1,80 @@
const { chromium } = require(process.argv[2] || "playwright");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
(async () => {
const browser = await chromium.launch({ channel: "msedge", headless: true });
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
const errors = [];
page.on("pageerror", error => errors.push(error.stack || error.message));
const artifacts = path.join(__dirname, "../artifacts/zones-check");
fs.mkdirSync(artifacts, { recursive: true });
try {
await page.goto("http://127.0.0.1:5199", { waitUntil: "domcontentloaded" });
await page.waitForFunction(() => !!window.rt?._dotNetHelper && !!window.__rtLeafletMap);
const initialErrors = errors.slice();
await page.getByRole("button", { name: "Abrir zonas a evitar", exact: true }).click();
await page.getByRole("button", { name: "Añadir zona", exact: true }).click();
for (const [x, y] of [[600, 420], [850, 430], [760, 640]]) {
await page.mouse.click(x, y);
await page.waitForTimeout(250);
}
await page.getByRole("button", { name: "Cerrar zona", exact: true }).click();
await page.getByRole("button", { name: "Añadir zona", exact: true }).waitFor();
assert.equal(await page.locator(".zones-panel__item").count(), 1);
assert.equal(await page.locator(".rt-exclusion-vertex").count(), 3);
const polygon = page.locator('path[stroke="#e879f9"]');
const previousShape = await polygon.getAttribute("d");
const marker = await page.locator(".rt-exclusion-vertex").first().boundingBox();
await page.mouse.move(marker.x + marker.width / 2, marker.y + marker.height / 2);
await page.mouse.down();
await page.mouse.move(marker.x - 55, marker.y + 65, { steps: 10 });
assert.notEqual(await polygon.getAttribute("d"), previousShape, "Poligono se mueve durante el arrastre");
await page.mouse.up();
await page.waitForTimeout(250);
await page.getByRole("button", { name: "Añadir zona", exact: true }).click();
for (const [x, y] of [[920, 460], [1090, 460], [1020, 640]]) {
await page.mouse.click(x, y);
await page.waitForTimeout(250);
}
await page.getByRole("button", { name: "Cerrar zona", exact: true }).click();
await page.getByRole("button", { name: "Añadir zona", exact: true }).waitFor();
assert.equal(await page.locator(".zones-panel__item").count(), 2);
await page.screenshot({ path: path.join(artifacts, "desktop.png") });
const heights = await page.locator(".zones-panel__actions .btn-ghost").evaluateAll(
buttons => buttons.map(button => button.getBoundingClientRect().height));
assert.equal(new Set(heights).size, 1, "Botones de igual altura");
await page.getByRole("button", { name: "Cerrar herramienta", exact: true }).click();
await page.locator(".zones-panel").waitFor({ state: "detached" });
assert.equal(await page.locator(".rt-exclusion-vertex").count(), 0);
assert.equal(await page.locator('path[stroke="#e879f9"], path[stroke="#f97316"]').count(), 0);
await page.getByRole("button", { name: "Abrir zonas a evitar", exact: true }).click();
await page.locator(".zones-panel").waitFor();
assert.equal(await page.locator(".zones-panel__item").count(), 2, "Reabrir conserva zonas editables");
await page.getByRole("button", { name: "Eliminar zona", exact: true }).first().click();
await page.waitForFunction(() => document.querySelectorAll(".zones-panel__item").length === 1);
assert.equal(await page.locator(".zones-panel__item").count(), 1);
await page.getByRole("button", { name: "Borrar todas", exact: true }).click();
await page.waitForFunction(() => document.querySelectorAll(".zones-panel__item").length === 0);
assert.equal(await page.locator(".zones-panel__item").count(), 0);
await page.setViewportSize({ width: 390, height: 844 });
await page.screenshot({ path: path.join(artifacts, "mobile.png") });
const box = await page.locator(".zones-panel").boundingBox();
assert.ok(box.x >= 0 && box.x + box.width <= 390 && box.y + box.height <= 844, "Panel dentro del movil");
await page.getByRole("button", { name: "Añadir zona", exact: true }).click();
await page.getByRole("button", { name: "Cerrar herramienta", exact: true }).click();
await page.setViewportSize({ width: 1440, height: 1000 });
await page.getByTitle("Configuración", { exact: true }).click();
assert.equal(await page.locator(".config-more-menu").count(), 0, "No hay opcion para guardar valores de fabrica");
const externalErrors = errors.filter(error =>
error.includes("esri-leaflet-geocoder@3.1.4/dist/esri-leaflet-geocoder.js") &&
error.includes("reading 'extend'"));
const newErrors = errors.filter(error => !initialErrors.includes(error) && !externalErrors.includes(error));
assert.equal(newErrors.length, 0, newErrors.join("\n"));
if (externalErrors.length) console.log("Aviso del complemento externo de geocodificacion: " + externalErrors[0]);
console.log("OK multiples zonas, arrastre en vivo, cierre, reapertura, borrado, alturas de botones y movil");
} finally {
await browser.close();
}
})().catch(error => { console.error(error); process.exitCode = 1; });