From 235297fd3a36463f0a79a01978e7c6bb9269815b Mon Sep 17 00:00:00 2001 From: Pedro Date: Tue, 8 Sep 2026 13:33:15 +0200 Subject: [PATCH] =?UTF-8?q?-=20Separado=20el=20control=20de=20poligonos=20?= =?UTF-8?q?excluyentes=20-=20A=C3=B1adido=20interpreacion=20de=20tipologia?= =?UTF-8?q?=20de=20suelo=20en=20segmentos=20andando=20-=20Persistencia=20m?= =?UTF-8?q?odo=20andando=20-=20Transbordo=20penalizado=20a=206=20-=20A?= =?UTF-8?q?=C3=B1adidos=20varias=20zonas=20de=20prohibicion=20de=20poligon?= =?UTF-8?q?os?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .serena/.gitignore | 2 + .serena/project.yml | 169 +++++++++++ .../Pages/PlanificadorRutas.CicloVidaYMapa.cs | 5 +- .../Pages/PlanificadorRutas.Estado.cs | 4 +- .../Pages/PlanificadorRutas.Historial.cs | 2 + .../Pages/PlanificadorRutas.Itinerarios.cs | 2 +- .../Pages/PlanificadorRutas.Persistencia.cs | 96 +------ .../Pages/PlanificadorRutas.Planificacion.cs | 27 +- .../Pages/PlanificadorRutas.Renderizado.cs | 5 +- ...PlanificadorRutas.RestriccionesCaminata.cs | 177 +++++++----- .../Pages/PlanificadorRutas.Superficie.cs | 86 ++++++ .../Pages/PlanificadorRutas.ValoresFabrica.cs | 37 +++ .../Components/Pages/PlanificadorRutas.razor | 104 +++---- .../Planificacion/InformacionSueloTramo.cs | 19 ++ .../Modelos/Planificacion/ParadaCandidata.cs | 2 +- RutasDBUS/Program.cs | 1 + .../Servicios/Caminata/ClienteValhalla.cs | 263 ++++++++++++++++++ .../Servicios/Caminata/IClienteValhalla.cs | 9 + .../Configuracion/ServicioValoresFabrica.cs | 57 ++++ .../PlanificadorRutasServicio.cs | 71 +++-- RutasDBUS/wwwroot/app.css | 117 ++++---- RutasDBUS/wwwroot/app.js | 206 +++++++++----- tests/RutasDBUS.RegressionTests/Program.cs | 111 ++++++++ .../RutasDBUS.RegressionTests.csproj | 11 + tests/verify-zones.cjs | 80 ++++++ 25 files changed, 1288 insertions(+), 375 deletions(-) create mode 100644 .serena/.gitignore create mode 100644 .serena/project.yml create mode 100644 RutasDBUS/Components/Pages/PlanificadorRutas.Superficie.cs create mode 100644 RutasDBUS/Components/Pages/PlanificadorRutas.ValoresFabrica.cs create mode 100644 RutasDBUS/Modelos/Planificacion/InformacionSueloTramo.cs create mode 100644 RutasDBUS/Servicios/Configuracion/ServicioValoresFabrica.cs create mode 100644 tests/RutasDBUS.RegressionTests/Program.cs create mode 100644 tests/RutasDBUS.RegressionTests/RutasDBUS.RegressionTests.csproj create mode 100644 tests/verify-zones.cjs diff --git a/.serena/.gitignore b/.serena/.gitignore new file mode 100644 index 0000000..2e510af --- /dev/null +++ b/.serena/.gitignore @@ -0,0 +1,2 @@ +/cache +/project.local.yml diff --git a/.serena/project.yml b/.serena/project.yml new file mode 100644 index 0000000..47052be --- /dev/null +++ b/.serena/project.yml @@ -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 read‑only. +# 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: [] diff --git a/RutasDBUS/Components/Pages/PlanificadorRutas.CicloVidaYMapa.cs b/RutasDBUS/Components/Pages/PlanificadorRutas.CicloVidaYMapa.cs index 5439307..0e13c8b 100644 --- a/RutasDBUS/Components/Pages/PlanificadorRutas.CicloVidaYMapa.cs +++ b/RutasDBUS/Components/Pages/PlanificadorRutas.CicloVidaYMapa.cs @@ -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) { + var poligonos = ObtenerPoligonosExclusionCaminata(); var key = WalkKey("valhalla", lat1, lon1, lat2, lon2); + if (poligonos is not null) key += "|" + JsonSerializer.Serialize(poligonos); if (_walkCache.TryGetValue(key, out var v)) return v; 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" if (line is not null && dist > 0) diff --git a/RutasDBUS/Components/Pages/PlanificadorRutas.Estado.cs b/RutasDBUS/Components/Pages/PlanificadorRutas.Estado.cs index 6b5c944..0bd9ff4 100644 --- a/RutasDBUS/Components/Pages/PlanificadorRutas.Estado.cs +++ b/RutasDBUS/Components/Pages/PlanificadorRutas.Estado.cs @@ -68,12 +68,12 @@ public partial class PlanificadorRutas : ComponentBase private double _factorPenalizacionDistanciaPie = FactorPenalizacionDistanciaPiePorDefecto; private double _parametroPenalizacionDistanciaPieMetros = ParametroPenalizacionDistanciaPieMetrosPorDefecto; private double _coeficienteEsfuerzoAltura = CoeficienteEsfuerzoAlturaPorDefecto; - private double _pesoTransbordoRankingMinutos = 4.0; + private double _pesoTransbordoRankingMinutos = 6.0; private double _factorEquilibradoAndarBus = FactorEquilibradoAndarBusPorDefecto; private string _factorPenalizacionDistanciaPieTextoEdicion = "1.8"; private string _parametroPenalizacionDistanciaPieTextoEdicion = "200"; private string _coeficienteEsfuerzoAlturaTextoEdicion = "5.20"; - private string _pesoTransbordoRankingTextoEdicion = "4,0"; + private string _pesoTransbordoRankingTextoEdicion = "6,0"; private string _factorEquilibradoAndarBusTextoEdicion = "6"; private double _velocidadBusKmH = 16.0; private int _minutosTransbordo = 5; diff --git a/RutasDBUS/Components/Pages/PlanificadorRutas.Historial.cs b/RutasDBUS/Components/Pages/PlanificadorRutas.Historial.cs index e9644c0..3e5395a 100644 --- a/RutasDBUS/Components/Pages/PlanificadorRutas.Historial.cs +++ b/RutasDBUS/Components/Pages/PlanificadorRutas.Historial.cs @@ -1522,6 +1522,8 @@ public partial class PlanificadorRutas _coordenadasOrigen = new[] { item.OrigenLatitud, item.OrigenLongitud }; _coordenadasDestino = new[] { item.DestinoLatitud, item.DestinoLongitud }; + if (_modoConfiguracionHistorial == ModoConfiguracionHistorial.Predeterminada) + _configuracionFabrica = await ValoresFabrica.CargarAsync(); var configuracionHistorial = _modoConfiguracionHistorial switch { ModoConfiguracionHistorial.Guardada => CrearConfiguracionGuardadaHistorial(item), diff --git a/RutasDBUS/Components/Pages/PlanificadorRutas.Itinerarios.cs b/RutasDBUS/Components/Pages/PlanificadorRutas.Itinerarios.cs index 7b2b574..31395f0 100644 --- a/RutasDBUS/Components/Pages/PlanificadorRutas.Itinerarios.cs +++ b/RutasDBUS/Components/Pages/PlanificadorRutas.Itinerarios.cs @@ -1400,7 +1400,7 @@ public partial class PlanificadorRutas : ComponentBase private bool _elevPopupMinimizado = false; - private const string _versionApp = "(v-20260907a)"; + private const string _versionApp = "(v-20260908a)"; private double? _paradaElevIdee; diff --git a/RutasDBUS/Components/Pages/PlanificadorRutas.Persistencia.cs b/RutasDBUS/Components/Pages/PlanificadorRutas.Persistencia.cs index 2bb0ed7..25cffe1 100644 --- a/RutasDBUS/Components/Pages/PlanificadorRutas.Persistencia.cs +++ b/RutasDBUS/Components/Pages/PlanificadorRutas.Persistencia.cs @@ -23,6 +23,8 @@ public partial class PlanificadorRutas /// private async Task CargarConfiguracionLocalAsync() { + _configuracionFabrica = await ValoresFabrica.CargarAsync(); + AplicarConfiguracionLocal(CrearConfiguracionPredeterminadaHistorial()); try { var json = await JS.InvokeAsync("rt.loadLocalValue", ClaveConfiguracionLocal); @@ -146,7 +148,7 @@ public partial class PlanificadorRutas PreferenciaEtiquetaRuta = _preferenciaEtiquetaRuta }; - private static ConfiguracionHistorialRuta CrearConfiguracionPredeterminadaHistorial() + private static ConfiguracionHistorialRuta CrearConfiguracionBaseHistorial() { var ahora = DateTime.Now; return new ConfiguracionHistorialRuta @@ -174,7 +176,7 @@ public partial class PlanificadorRutas ParametroPenalizacionDistanciaPieMetros = ParametroPenalizacionDistanciaPieMetrosPorDefecto, CoeficienteEsfuerzoAltura = CoeficienteEsfuerzoAlturaPorDefecto, VersionCoeficienteEsfuerzo = VersionPersistenciaCoeficienteEsfuerzo, - PesoTransbordoRankingMinutos = 4.0, + PesoTransbordoRankingMinutos = 6.0, FactorEquilibradoAndarBus = FactorEquilibradoAndarBusPorDefecto, VersionFactorEquilibrado = VersionPersistenciaFactorEquilibrado, MostrarElevacionesRuta = true, @@ -267,7 +269,7 @@ public partial class PlanificadorRutas ? CoeficienteEsfuerzoAlturaPorDefecto : coeficienteAltura; 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 && double.IsFinite(factorEquilibrado) && factorEquilibrado > 0) @@ -442,48 +444,8 @@ public partial class PlanificadorRutas if (!confirmado) return; - _mostrarParadas = true; - _usarHorariosTeoricos = true; - _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; + _configuracionFabrica = await ValoresFabrica.CargarAsync(); + AplicarValoresFabricaConservandoSeleccion(); if (_usarHoraSimulada) StopClock(); @@ -504,48 +466,8 @@ public partial class PlanificadorRutas { _mostrarConfirmacionRestablecer = false; - _mostrarParadas = true; - _usarHorariosTeoricos = true; - _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; + _configuracionFabrica = await ValoresFabrica.CargarAsync(); + AplicarValoresFabricaConservandoSeleccion(); if (_usarHoraSimulada) StopClock(); diff --git a/RutasDBUS/Components/Pages/PlanificadorRutas.Planificacion.cs b/RutasDBUS/Components/Pages/PlanificadorRutas.Planificacion.cs index 370a7a5..14c6bb6 100644 --- a/RutasDBUS/Components/Pages/PlanificadorRutas.Planificacion.cs +++ b/RutasDBUS/Components/Pages/PlanificadorRutas.Planificacion.cs @@ -1146,6 +1146,7 @@ public partial class PlanificadorRutas : ComponentBase if (indice < 0 || indice >= _opcionesRuta.Count) return; _indiceAlternativaSeleccionada = indice; + _proveedorSoloAPieRecalculo = _opcionesRuta[indice].EsSoloAPie ? _opcionesRuta[indice].ProveedorRuta : null; await CerrarPopupsAsync(); ResetEstadoRuta(limpiarOpciones: false, limpiarMensaje: false); @@ -1755,6 +1756,7 @@ public partial class PlanificadorRutas : ComponentBase [JSInvokable] public async Task OnMobileMapLongPress(double lat, double lon) { + if (_modoDibujoExclusionCaminata) return; await InvokeAsync(async () => { if (_inspectorActivo) @@ -1874,15 +1876,27 @@ public partial class PlanificadorRutas : ComponentBase /// /// Calcula ruta completa. /// + 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) { if (_mapa is null || _coordenadasOrigen is null || _coordenadasDestino is null) return; - var restaurarSeleccionSoloAPie = conservarSeleccionSoloAPie && - _indiceAlternativaSeleccionada >= 0 && - _indiceAlternativaSeleccionada < _opcionesRuta.Count && - _opcionesRuta[_indiceAlternativaSeleccionada].EsSoloAPie; + var proveedorSoloAPiePreferido = ObtenerProveedorSoloAPieParaRecalculo(conservarSeleccionSoloAPie); _mostrarElevacionesRuta = _mostrarElevacionesRutaConfiguracion; @@ -2002,8 +2016,9 @@ public partial class PlanificadorRutas : ComponentBase return; } - var idxPreferida = restaurarSeleccionSoloAPie - ? _opcionesRuta.FindIndex(opcion => opcion.EsSoloAPie) + var idxPreferida = proveedorSoloAPiePreferido is not null + ? _opcionesRuta.FindIndex(opcion => opcion.EsSoloAPie && + string.Equals(opcion.ProveedorRuta, proveedorSoloAPiePreferido, StringComparison.OrdinalIgnoreCase)) : BuscarIndicePreferidoPorCriterio(_criterioPrioritarioRuta); if (idxPreferida < 0 || idxPreferida >= _opcionesRuta.Count) { diff --git a/RutasDBUS/Components/Pages/PlanificadorRutas.Renderizado.cs b/RutasDBUS/Components/Pages/PlanificadorRutas.Renderizado.cs index 7208d01..aa5b2de 100644 --- a/RutasDBUS/Components/Pages/PlanificadorRutas.Renderizado.cs +++ b/RutasDBUS/Components/Pages/PlanificadorRutas.Renderizado.cs @@ -1312,7 +1312,7 @@ public partial class PlanificadorRutas : ComponentBase } await CerrarPopupsAsync(); - await CalcularRutaCompletaAsync(); + await CalcularRutaCompletaAsync(conservarSeleccionSoloAPie: true); StateHasChanged(); } @@ -1321,6 +1321,9 @@ public partial class PlanificadorRutas : ComponentBase /// private async Task ReiniciarSeleccion() { + _proveedorSoloAPieRecalculo = null; + await LimpiarZonasExclusionAsync(); + await CerrarHerramientaZonasAsync(); await CerrarTodasLasPreviewsAsync(); await CerrarPopupsAsync(); diff --git a/RutasDBUS/Components/Pages/PlanificadorRutas.RestriccionesCaminata.cs b/RutasDBUS/Components/Pages/PlanificadorRutas.RestriccionesCaminata.cs index 560ee12..1cd5fbb 100644 --- a/RutasDBUS/Components/Pages/PlanificadorRutas.RestriccionesCaminata.cs +++ b/RutasDBUS/Components/Pages/PlanificadorRutas.RestriccionesCaminata.cs @@ -4,112 +4,159 @@ namespace RutasDBUS.Components.Pages; public partial class PlanificadorRutas { - private bool _modoDibujoExclusionCaminata; - private bool _poligonoExclusionCaminataCerrado; - private readonly List _verticesExclusionCaminata = new(); + private sealed class ZonaExclusionCaminata + { + public string Id { get; } = Guid.NewGuid().ToString("N"); + public List Vertices { get; } = new(); + public bool Cerrada { get; set; } + } - private bool PuedeProbarExclusionCaminata => - _walkProvider == ProveedorCaminata.Valhalla && - _hayRutaValida && - _opcionesRuta.Count > 0 && - _indiceAlternativaSeleccionada >= 0 && - _indiceAlternativaSeleccionada < _opcionesRuta.Count && - (_opcionesRuta[_indiceAlternativaSeleccionada].EsSoloAPie || - _tramosRuta.Any(tramo => tramo.Tipo.StartsWith("A pie", StringComparison.OrdinalIgnoreCase))); - - private bool PoligonoExclusionCaminataValido => - _verticesExclusionCaminata.Count >= 3 && _poligonoExclusionCaminataCerrado; + private readonly List _zonasExclusionCaminata = new(); + private string? _zonaExclusionSeleccionada; + private bool _mostrarHerramientaZonas; + private bool _recalculandoZonas; + private string? _errorZonas; + private ZonaExclusionCaminata? ZonaExclusionSeleccionada => + _zonasExclusionCaminata.Find(zona => zona.Id == _zonaExclusionSeleccionada); + private bool _modoDibujoExclusionCaminata => + _mostrarHerramientaZonas && ZonaExclusionSeleccionada is { Cerrada: false }; + private int NumeroZonasExclusion => _zonasExclusionCaminata.Count(zona => zona.Cerrada); private List>? ObtenerPoligonosExclusionCaminata() { - if (!PoligonoExclusionCaminataValido) - return null; + var poligonos = _zonasExclusionCaminata.Where(zona => zona.Cerrada) + .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> - { - _verticesExclusionCaminata - .Select(punto => new[] { punto[1], punto[0] }) - .Append(new[] { _verticesExclusionCaminata[0][1], _verticesExclusionCaminata[0][0] }) - .ToList() - }; + private async Task AbrirHerramientaZonasAsync() + { + _mostrarConfigAvanzada = false; + _mostrarHerramientaZonas = true; + await ActualizarEditorExclusionCaminataAsync(); + } + + private async Task CerrarHerramientaZonasAsync() + { + _zonasExclusionCaminata.RemoveAll(zona => !zona.Cerrada); + _zonaExclusionSeleccionada = null; + _mostrarHerramientaZonas = false; + _errorZonas = null; + await JS.InvokeVoidAsync("rt.clearExclusionPolygonEditor"); } private async Task IniciarDibujoExclusionCaminataAsync() { - if (!PuedeProbarExclusionCaminata) - return; - - _modoDibujoExclusionCaminata = true; - _poligonoExclusionCaminataCerrado = false; - _verticesExclusionCaminata.Clear(); - await JS.InvokeVoidAsync("rt.beginExclusionPolygonEditor"); - StateHasChanged(); + if (_recalculandoZonas || _modoDibujoExclusionCaminata || _zonasExclusionCaminata.Count >= 10) return; + var zona = new ZonaExclusionCaminata(); + _zonasExclusionCaminata.Add(zona); + _zonaExclusionSeleccionada = zona.Id; + _errorZonas = null; + await ActualizarEditorExclusionCaminataAsync(); } - private async Task CancelarDibujoExclusionCaminataAsync() + private async Task SeleccionarZonaExclusionAsync(string id) { - _modoDibujoExclusionCaminata = false; - _poligonoExclusionCaminataCerrado = false; - _verticesExclusionCaminata.Clear(); + if (_recalculandoZonas) return; + _zonasExclusionCaminata.RemoveAll(zona => !zona.Cerrada); + _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"); - StateHasChanged(); } private async Task DeshacerVerticeExclusionCaminataAsync() { - if (_verticesExclusionCaminata.Count == 0 || _poligonoExclusionCaminataCerrado) - return; - - _verticesExclusionCaminata.RemoveAt(_verticesExclusionCaminata.Count - 1); + if (ZonaExclusionSeleccionada is not { Cerrada: false } zona || zona.Vertices.Count == 0) return; + zona.Vertices.RemoveAt(zona.Vertices.Count - 1); await ActualizarEditorExclusionCaminataAsync(); } private async Task CerrarPoligonoExclusionCaminataAsync() { - if (_verticesExclusionCaminata.Count < 3) + if (ZonaExclusionSeleccionada is not { Cerrada: false } zona || zona.Vertices.Count < 3) return; + if (!await JS.InvokeAsync("rt.isExclusionPolygonValid", zona.Vertices)) + { + _errorZonas = "Revisa la zona: los lados no pueden cruzarse ni quedar alineados."; return; - - _poligonoExclusionCaminataCerrado = true; - await JS.InvokeVoidAsync("rt.closeExclusionPolygonEditor"); - StateHasChanged(); + } + zona.Cerrada = true; + _errorZonas = null; + await ActualizarEditorExclusionCaminataAsync(); } private async Task CalcularRutaEvitandoPoligonoCaminataAsync() { - if (!PoligonoExclusionCaminataValido) - return; - - _modoDibujoExclusionCaminata = false; - await JS.InvokeVoidAsync("rt.finishExclusionPolygonEditor"); - _omitirRegistroHistorialEnSiguienteCalculo = true; - await CalcularRutaCompletaAsync(); + if (_recalculandoZonas || _modoDibujoExclusionCaminata || _coordenadasOrigen is null || _coordenadasDestino is null) return; + foreach (var zona in _zonasExclusionCaminata) + { + if (!await JS.InvokeAsync("rt.isExclusionPolygonValid", zona.Vertices)) + { + _errorZonas = "Una zona tiene lados cruzados o no tiene superficie. Ajusta sus puntos."; + return; + } + } + _recalculandoZonas = true; + _errorZonas = null; + await ActualizarEditorExclusionCaminataAsync(); + try + { + _omitirRegistroHistorialEnSiguienteCalculo = true; + await CalcularRutaCompletaAsync(conservarSeleccionSoloAPie: true); + } + finally + { + _recalculandoZonas = false; + await ActualizarEditorExclusionCaminataAsync(); + } } [JSInvokable] public async Task OnExclusionPolygonMapClick(double latitud, double longitud) { - if (!_modoDibujoExclusionCaminata || _poligonoExclusionCaminataCerrado) - return; - - _verticesExclusionCaminata.Add(new[] { latitud, longitud }); + if (!_modoDibujoExclusionCaminata || _recalculandoZonas || !CoordenadaExclusionValida(latitud, longitud)) return; + var zona = ZonaExclusionSeleccionada!; + if (zona.Vertices.Count >= 50) return; + zona.Vertices.Add(new[] { latitud, longitud }); await ActualizarEditorExclusionCaminataAsync(); + StateHasChanged(); } [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) - { - _verticesExclusionCaminata[indice][0] = latitud; - _verticesExclusionCaminata[indice][1] = longitud; - } - + var zona = _zonasExclusionCaminata.Find(zona => zona.Id == id); + if (_mostrarHerramientaZonas && !_recalculandoZonas && zona is not null && + indice >= 0 && indice < zona.Vertices.Count && CoordenadaExclusionValida(latitud, longitud)) + zona.Vertices[indice] = new[] { latitud, longitud }; 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() { - await JS.InvokeVoidAsync("rt.updateExclusionPolygonEditor", _verticesExclusionCaminata); - StateHasChanged(); + if (!_mostrarHerramientaZonas) return; + await JS.InvokeVoidAsync("rt.updateExclusionPolygonEditor", + _zonasExclusionCaminata.Select(zona => new { id = zona.Id, points = zona.Vertices, closed = zona.Cerrada }), + _zonaExclusionSeleccionada, _recalculandoZonas); } } diff --git a/RutasDBUS/Components/Pages/PlanificadorRutas.Superficie.cs b/RutasDBUS/Components/Pages/PlanificadorRutas.Superficie.cs new file mode 100644 index 0000000..fb95bf2 --- /dev/null +++ b/RutasDBUS/Components/Pages/PlanificadorRutas.Superficie.cs @@ -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; + + /// + /// Consulta la superficie únicamente del segmento de elevación que se acaba de seleccionar. + /// + [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( + "
", + 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}"; + })); + } +} diff --git a/RutasDBUS/Components/Pages/PlanificadorRutas.ValoresFabrica.cs b/RutasDBUS/Components/Pages/PlanificadorRutas.ValoresFabrica.cs new file mode 100644 index 0000000..af3741a --- /dev/null +++ b/RutasDBUS/Components/Pages/PlanificadorRutas.ValoresFabrica.cs @@ -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; + } +} diff --git a/RutasDBUS/Components/Pages/PlanificadorRutas.razor b/RutasDBUS/Components/Pages/PlanificadorRutas.razor index a218463..4fdef7b 100644 --- a/RutasDBUS/Components/Pages/PlanificadorRutas.razor +++ b/RutasDBUS/Components/Pages/PlanificadorRutas.razor @@ -227,6 +227,16 @@ } + + @* STACK DE POPUPS (izquierda) *@ @if (_paradaClicada is not null || _itinerarioClicado is not null || _popupInspectorVisible) @@ -847,53 +857,6 @@ } - @if (PuedeProbarExclusionCaminata) - { -
-
- Probar evitando una zona andando - v2 -
- - @if (!_modoDibujoExclusionCaminata && !PoligonoExclusionCaminataValido) - { - - } - else - { -
- @if (_poligonoExclusionCaminataCerrado) - { - Zona cerrada con @_verticesExclusionCaminata.Count vértices. Puedes arrastrarlos. - } - else - { - Pulsa en el mapa para añadir vértices: @_verticesExclusionCaminata.Count. - } -
-
- @if (!_poligonoExclusionCaminataCerrado) - { - - - } - @if (PoligonoExclusionCaminataValido) - { - - } - -
- } -
- } @* ========= RESUMEN BONITO ARRIBA (único) ========= *@

@@ -1503,6 +1466,53 @@ } + @if (_mostrarHerramientaZonas) + { +

+
+
Zonas a evitar v2
+ +
+
+ @for (var i = 0; i < _zonasExclusionCaminata.Count; i++) + { + var zona = _zonasExclusionCaminata[i]; +
+ + +
+ } +
+
+ @if (_modoDibujoExclusionCaminata) + { + + + } + else + { + + } + + +
+ @if (!string.IsNullOrWhiteSpace(_errorZonas)) + { + + } +
+ } + @* *@ diff --git a/RutasDBUS/Modelos/Planificacion/InformacionSueloTramo.cs b/RutasDBUS/Modelos/Planificacion/InformacionSueloTramo.cs new file mode 100644 index 0000000..fb00f3c --- /dev/null +++ b/RutasDBUS/Modelos/Planificacion/InformacionSueloTramo.cs @@ -0,0 +1,19 @@ +namespace RutasDBUS.Modelos.Planificacion; + +public sealed class InformacionSueloTramo +{ + public bool Disponible { get; init; } + public string? Mensaje { get; init; } + public List 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; } +} diff --git a/RutasDBUS/Modelos/Planificacion/ParadaCandidata.cs b/RutasDBUS/Modelos/Planificacion/ParadaCandidata.cs index db78ae2..cd72061 100644 --- a/RutasDBUS/Modelos/Planificacion/ParadaCandidata.cs +++ b/RutasDBUS/Modelos/Planificacion/ParadaCandidata.cs @@ -33,7 +33,7 @@ public sealed class ParametrosPlanificadorRuta public int SegundosPorParadaBus { get; set; } = 30; public double FactorPenalizacionDistanciaPie { get; set; } = 1.8; 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. public double FactorEquilibradoAndarBus { get; set; } = 6.0; diff --git a/RutasDBUS/Program.cs b/RutasDBUS/Program.cs index 74c058d..d310e89 100644 --- a/RutasDBUS/Program.cs +++ b/RutasDBUS/Program.cs @@ -51,6 +51,7 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); // HttpClient genérico (lo usas en el Razor: @inject HttpClient Http) builder.Services.AddHttpClient(); diff --git a/RutasDBUS/Servicios/Caminata/ClienteValhalla.cs b/RutasDBUS/Servicios/Caminata/ClienteValhalla.cs index dce0f80..5c9cdb4 100644 --- a/RutasDBUS/Servicios/Caminata/ClienteValhalla.cs +++ b/RutasDBUS/Servicios/Caminata/ClienteValhalla.cs @@ -5,6 +5,7 @@ using System.Net.Http.Json; using System.Text.Json.Serialization; using System.Threading.Tasks; using Microsoft.Extensions.Configuration; +using RutasDBUS.Modelos.Planificacion; namespace RutasDBUS.Servicios.Caminata; @@ -112,6 +113,199 @@ public class ClienteValhalla : IClienteValhalla duracionSegundos); } + public async Task 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 + { + "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(timeout.Token); + return ConvertirInformacionSuelo(data); + } + catch + { + return CrearInformacionSueloNoDisponible("No se ha podido consultar la superficie del tramo."); + } + } + + private static List ConstruirFormaTraza(double[,] linea) + { + var forma = new List(); + 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(); + 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 // ======================= @@ -134,6 +328,75 @@ public class ClienteValhalla : IClienteValhalla public OpcionesIndicacionesValhalla OpcionesIndicaciones { get; set; } = new(); } + private sealed class SolicitudAtributosSuperficieValhalla + { + [JsonPropertyName("shape")] + public List 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 Atributos { get; set; } = new(); + + [JsonPropertyName("action")] + public string Accion { get; set; } = "include"; + } + + private sealed class RespuestaAtributosSuperficieValhalla + { + [JsonPropertyName("edges")] + public List 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 { [JsonPropertyName("lat")] diff --git a/RutasDBUS/Servicios/Caminata/IClienteValhalla.cs b/RutasDBUS/Servicios/Caminata/IClienteValhalla.cs index 6430b20..f6283d8 100644 --- a/RutasDBUS/Servicios/Caminata/IClienteValhalla.cs +++ b/RutasDBUS/Servicios/Caminata/IClienteValhalla.cs @@ -1,4 +1,5 @@ using System.Threading.Tasks; +using RutasDBUS.Modelos.Planificacion; namespace RutasDBUS.Servicios.Caminata; @@ -15,4 +16,12 @@ public interface IClienteValhalla double longitudHasta, string costing = "pedestrian", List>? excludePolygons = null); // "pedestrian", "bicycle", etc. + + /// + /// 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. + /// + Task ObtenerInformacionSueloAsync( + double[,] linea, + CancellationToken cancellationToken = default); } diff --git a/RutasDBUS/Servicios/Configuracion/ServicioValoresFabrica.cs b/RutasDBUS/Servicios/Configuracion/ServicioValoresFabrica.cs new file mode 100644 index 0000000..35b6cbf --- /dev/null +++ b/RutasDBUS/Servicios/Configuracion/ServicioValoresFabrica.cs @@ -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 _logger; + private readonly SemaphoreSlim _bloqueo = new(1, 1); + private readonly string _ruta; + + public ServicioValoresFabrica(IConfiguration configuracion, ILogger 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 CargarAsync() + { + await _bloqueo.WaitAsync(); + try + { + if (!File.Exists(_ruta)) return null; + using var archivo = File.OpenRead(_ruta); + var config = await JsonSerializer.DeserializeAsync(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; + } +} diff --git a/RutasDBUS/Servicios/Planificacion/PlanificadorRutasServicio.cs b/RutasDBUS/Servicios/Planificacion/PlanificadorRutasServicio.cs index 86607ca..65a6c75 100644 --- a/RutasDBUS/Servicios/Planificacion/PlanificadorRutasServicio.cs +++ b/RutasDBUS/Servicios/Planificacion/PlanificadorRutasServicio.cs @@ -3884,13 +3884,12 @@ public sealed class PlanificadorRutasServicio : IPlanificadorRutas { if (excludePolygons is { Count: > 0 }) { - // Una zona claramente alejada no puede cambiar razonablemente este trayecto. - // Evitamos enviar a Valhalla una busqueda restringida innecesaria. - if (!ExclusionPuedeAfectarTrayecto( - latitudOrigen, longitudOrigen, latitudDestino, longitudDestino, excludePolygons)) + // Solo reutilizar una geometria conocida y alejada de todas las zonas. + var claveNormal = BuildWalkKey("valhalla", latitudOrigen, longitudOrigen, latitudDestino, longitudDestino); + if (_cacheCaminatas.TryGetValue(claveNormal, out var normal) && normal.linea is not null && + !ExclusionPuedeAfectarTrayecto(normal.linea, excludePolygons)) { - return await WalkValhallaCached( - latitudOrigen, longitudOrigen, latitudDestino, longitudDestino); + return normal; } var claveExclusion = BuildWalkKey("valhalla-exclusion", latitudOrigen, longitudOrigen, latitudDestino, longitudDestino) @@ -3909,13 +3908,16 @@ public sealed class PlanificadorRutasServicio : IPlanificadorRutas excludePolygons: excludePolygons); 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; } catch { var vacio = (linea: (double[,]?)null, distancia: 0.0, duracion: 0.0); - _cacheCaminatasConExclusion[claveExclusion] = vacio; return vacio; } } @@ -3945,38 +3947,35 @@ public sealed class PlanificadorRutasServicio : IPlanificadorRutas } } - private static bool ExclusionPuedeAfectarTrayecto( - double latitudOrigen, - double longitudOrigen, - double latitudDestino, - double longitudDestino, - List> poligonos) + private static bool ExclusionPuedeAfectarTrayecto(double[,] linea, List> poligonos) { - const double umbralMetros = 2500.0; - var escalaLat = 111_320.0; - var escalaLon = Math.Max(1.0, escalaLat * Math.Cos(latitudOrigen * Math.PI / 180.0)); - var ax = 0.0; - var ay = 0.0; - var bx = (longitudDestino - longitudOrigen) * escalaLon; - var by = (latitudDestino - latitudOrigen) * escalaLat; - var ab2 = bx * bx + by * by; - + if (linea.GetLength(0) < 2) return true; + var minLat = double.PositiveInfinity; + var maxLat = double.NegativeInfinity; + var minLon = double.PositiveInfinity; + var maxLon = double.NegativeInfinity; + for (var i = 0; i < linea.GetLength(0); i++) + { + 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 punto in poligono) - { - if (punto is null || punto.Length < 2) continue; - // Valhalla recibe los vertices como [longitud, latitud]. - var px = (punto[0] - longitudOrigen) * escalaLon; - var py = (punto[1] - latitudOrigen) * escalaLat; - var t = ab2 > 0 ? Math.Clamp((px * bx + py * by) / ab2, 0.0, 1.0) : 0.0; - var dx = px - (ax + t * bx); - var dy = py - (ay + t * by); - if (Math.Sqrt(dx * dx + dy * dy) <= umbralMetros) - return true; - } + if (poligono.Count < 3 || poligono.Any(p => p.Length < 2 || !double.IsFinite(p[0]) || !double.IsFinite(p[1]))) + return true; + // Los poligonos usan [lon, lat]; sus limites incluyen lados y area interior. + if (poligono.Max(p => p[1]) >= minLat - margenLat && + poligono.Min(p => p[1]) <= maxLat + margenLat && + poligono.Max(p => p[0]) >= minLon - margenLon && + poligono.Min(p => p[0]) <= maxLon + margenLon) + return true; } - return false; } diff --git a/RutasDBUS/wwwroot/app.css b/RutasDBUS/wwwroot/app.css index e9d4c62..bc41d80 100644 --- a/RutasDBUS/wwwroot/app.css +++ b/RutasDBUS/wwwroot/app.css @@ -1132,65 +1132,69 @@ html, body { line-height: 1.25; } -.route-exclusion-tool { - margin: 0 0 .55rem; - padding: .48rem .6rem; - border: 1px solid rgba(248, 113, 113, .42); - border-left: 3px solid #ef4444; - border-radius: 6px; - background: rgba(127, 29, 29, .16); +.zones-panel { + position: fixed; + top: 90px; + left: 16px; + width: min(340px, calc(100vw - 32px)); + max-height: calc(100dvh - 110px); + 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 { - display: flex; +.zones-panel .config-panel-header h5 { font-size: .94rem; } +.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; - justify-content: space-between; - gap: .6rem; - margin-bottom: .28rem; -} - -.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); + gap: .38rem; + min-height: 34px; + padding: .35rem .7rem; + border: 1px solid rgba(148, 163, 184, .65); border-radius: 999px; - background: #1d4ed8; - padding: .3rem .9rem; - font-size: .78rem; - font-weight: 700; - color: #fff; + background: rgba(15, 23, 42, .94); + color: #e5e7eb; + box-shadow: 0 8px 20px rgba(15, 23, 42, .45); cursor: pointer; + font-size: .76rem; + font-weight: 650; + line-height: 1; } -.route-exclusion-tool__calculate:hover { - background: #2563eb; - border-color: #bae6fd; +.map-zones-toggle:hover, +.map-zones-toggle:focus-visible, +.map-zones-toggle.is-active { + border-color: #c084fc; + background: #312e81; + color: #fff; + outline: none; } -.route-exclusion-tool__hint { - color: var(--muted-text, #a1a1aa); - font-size: .82rem; +.map-zones-toggle > span:first-child { + color: #e879f9; + font-size: 1rem; } .rt-exclusion-vertex { @@ -1198,8 +1202,8 @@ html, body { height: 18px !important; border: 2px solid #fff; border-radius: 50%; - background: #dc2626; - box-shadow: 0 0 0 2px #7f1d1d, 0 1px 4px rgba(0, 0, 0, .55); + background: #a21caf; + box-shadow: 0 0 0 2px #581c87, 0 1px 4px rgba(0, 0, 0, .55); } .taxibus-notice { @@ -3254,6 +3258,7 @@ html, body { font-size: .74rem; } + /* ===== SLIDER (range) limpio para el panel ===== */ .config-range { width: 100%; @@ -3588,6 +3593,14 @@ html, body { 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, .map-container .leaflet-control-zoom { display: none !important; diff --git a/RutasDBUS/wwwroot/app.js b/RutasDBUS/wwwroot/app.js index f00af84..c41f19b 100644 --- a/RutasDBUS/wwwroot/app.js +++ b/RutasDBUS/wwwroot/app.js @@ -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 () { if (!window.rt.ensureRouteLayer()) return; window.rt.clearElevationSegmentSelection(); window.rt._routeLayer.clearLayers(); window.rt._routeLines = {}; window.rt._routeCoords = {}; + window.rt._elevationSegmentLines = {}; if (window.rt._routeStopsLayer) { window.rt._routeStopsLayer.clearLayers(); @@ -292,6 +311,8 @@ window.rt.drawRoute = function (id, coords, options) { window.rt._routeLayer.removeLayer(window.rt._routeLines[id]); delete window.rt._routeLines[id]; } + window.rt._elevationSegmentLines = window.rt._elevationSegmentLines || {}; + delete window.rt._elevationSegmentLines[id]; const arrowId = id + "__arrow"; if (window.rt._routeLines[arrowId]) { @@ -357,6 +378,8 @@ window.rt.drawRoute = function (id, coords, options) { if (elevationSegments.length > 0 && coords && coords.length >= 2) { const group = window.L.layerGroup(); + window.rt._elevationSegmentLines = window.rt._elevationSegmentLines || {}; + window.rt._elevationSegmentLines[id] = {}; const progressAlongSegment = function (segmentCoords, latlng) { 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" : "-"; - return [ + const tooltipLines = [ "Pendiente: " + slopeText, "Dist. anterior: " + distanceText, "Altura: " + elevationText - ].join("
"); + ]; + if (segment && segment.surfaceText) { + tooltipLines.push("Superficie: " + String(segment.surfaceText)); + } + return tooltipLines.join("
"); }; - elevationSegments.forEach(function (segment) { + elevationSegments.forEach(function (segment, segmentIndex) { const segmentCoords = segment && segment.coords; if (!Array.isArray(segmentCoords) || segmentCoords.length < 2) return; @@ -442,6 +469,10 @@ window.rt.drawRoute = function (id, coords, options) { } const segmentLine = buildLine(segmentCoords, segmentOpts); + segmentLine._rtElevationSegment = segment; + segmentLine._rtElevationTooltip = elevationTooltip; + segmentLine._rtElevationProgress = 0.5; + window.rt._elevationSegmentLines[id][segmentIndex] = segmentLine; const originalSegmentStyle = { color: segmentColor, weight, @@ -460,6 +491,7 @@ window.rt.drawRoute = function (id, coords, options) { }); const updateTooltipAtCursor = function (e) { const progress = progressAlongSegment(segmentCoords, e && e.latlng); + segmentLine._rtElevationProgress = progress; segmentLine.setTooltipContent(elevationTooltip(segment, progress)); }; segmentLine.on("mouseover", updateTooltipAtCursor); @@ -475,6 +507,19 @@ window.rt.drawRoute = function (id, coords, options) { group, originalSegmentStyle, 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 () { try { if (segmentLine._path && typeof segmentLine._path.blur === "function") { @@ -657,91 +702,100 @@ window.rt.setDotNetHelper = function (dotNetHelper) { window.rt._dotNetHelper = dotNetHelper; }; -// Editor temporal de zonas que se excluiran de las caminatas de Valhalla. -window.rt._exclusionPolygonLayer = null; -window.rt._exclusionPolygon = null; -window.rt._exclusionPolygonMarkers = []; +// Las zonas son independientes de las capas de rutas; cerrar el editor retira sus eventos. window.rt._exclusionEditMode = false; +window.rt._exclusionPolygonLayer = null; +window.rt._exclusionClickHandler = null; +window.rt._exclusionMapContainer = null; -window.rt.beginExclusionPolygonEditor = function () { - const map = window.__rtLeafletMap; - if (!map || !window.L) return; +window.rt.clearExclusionPolygonEditor = function () { + if (window.rt._exclusionMapContainer && window.rt._exclusionClickHandler) + 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._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; - if (!map || !window.L) return; - if (!window.rt._exclusionPolygonLayer) window.rt.beginExclusionPolygonEditor(); + const L = window.L; + if (!map || !L) return; - const polygonPoints = Array.isArray(points) - ? points.filter(p => Array.isArray(p) && Number.isFinite(Number(p[0])) && Number.isFinite(Number(p[1]))) - .map(p => [Number(p[0]), Number(p[1])]) - : []; + const layer = L.layerGroup().addTo(map); + window.rt._exclusionPolygonLayer = layer; + const drawing = !busy && zones.some(zone => zone.id === selectedId && !zone.closed); + window.rt._exclusionEditMode = drawing; - window.rt._exclusionPolygon.setLatLngs(polygonPoints); - window.rt._exclusionPolygonMarkers.forEach(marker => marker.remove()); - window.rt._exclusionPolygonMarkers = polygonPoints.map((point, index) => { - const marker = window.L.marker(point, { - draggable: true, - autoPan: true, - bubblingMouseEvents: false, - icon: window.L.divIcon({ - className: "rt-exclusion-vertex", - html: "", - iconSize: [18, 18], - iconAnchor: [9, 9] - }) - }).addTo(window.rt._exclusionPolygonLayer); - - marker.on("click", event => { - if (event && event.originalEvent) event.originalEvent.stopPropagation(); + for (const zone of zones) { + const selected = zone.id === selectedId; + const points = zone.points.map(p => [p[0], p[1]]); + const options = { + color: selected ? "#e879f9" : "#f97316", weight: selected ? 3 : 2, + fillOpacity: zone.closed ? 0.18 : 0.08, interactive: false + }; + const polygon = (zone.closed ? L.polygon(points, options) : L.polyline(points, options)).addTo(layer); + if (!selected || busy) continue; + points.forEach((point, index) => { + const marker = L.marker(point, { + draggable: true, autoPan: true, bubblingMouseEvents: false, zIndexOffset: 3000, + icon: L.divIcon({ className: "rt-exclusion-vertex", html: "", + iconSize: [18, 18], iconAnchor: [9, 9] }) + }).addTo(layer); + const update = () => { + 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 (window.rt._exclusionPolygon) { - window.rt._exclusionPolygon.setStyle({ fillOpacity: 0.28 }); + if (drawing) { + const container = map.getContainer(); + 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._exclusionEditMode = false; -}; - -window.rt.clearExclusionPolygonEditor = function () { - window.rt._exclusionPolygonMarkers.forEach(marker => marker.remove()); - window.rt._exclusionPolygonMarkers = []; - window.rt._exclusionPolygonLayer?.remove(); - window.rt._exclusionPolygonLayer = null; - window.rt._exclusionPolygon = null; - window.rt._exclusionEditMode = false; +window.rt.isExclusionPolygonValid = function (points) { + 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]); + const on = (a, b, p) => Math.abs(cross(a, b, p)) < eps && + p[0] >= Math.min(a[0], b[0]) - eps && p[0] <= Math.max(a[0], b[0]) + eps && + p[1] >= Math.min(a[1], b[1]) - eps && p[1] <= Math.max(a[1], b[1]) + eps; + const n = points.length; + let area = 0; + for (let i = 0; i < n; i++) { + 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; }; diff --git a/tests/RutasDBUS.RegressionTests/Program.cs b/tests/RutasDBUS.RegressionTests/Program.cs new file mode 100644 index 0000000..16bb67e --- /dev/null +++ b/tests/RutasDBUS.RegressionTests/Program.cs @@ -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 +{ + ["ValoresFabrica:Directorio"] = folder +}).Build(); +ServicioValoresFabrica Service() => new(Config(), NullLogger.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>? zones = null) => + await (Task<(double[,]?, double, double)>)walk.Invoke(planner, new object?[] { 43.31, -1.99, 43.32, -1.98, zones })!; +List 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> { Ring(-2.3, 43.0, .01) }; +await Walk(far); +Check(fake.Calls == 1, "Zona lejana reutiliza la geometria conocida"); +var near = new List> { 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> { 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)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>? excludePolygons = null) + { + Calls++; + Zones = excludePolygons?.Count ?? 0; + return Task.FromResult<(double[,]?, double, double)>(Fail ? (null, 0, 0) : + (new[,] { { latitudDesde, longitudDesde }, { latitudHasta, longitudHasta } }, 100, 100)); + } +} diff --git a/tests/RutasDBUS.RegressionTests/RutasDBUS.RegressionTests.csproj b/tests/RutasDBUS.RegressionTests/RutasDBUS.RegressionTests.csproj new file mode 100644 index 0000000..08d423f --- /dev/null +++ b/tests/RutasDBUS.RegressionTests/RutasDBUS.RegressionTests.csproj @@ -0,0 +1,11 @@ + + + Exe + net8.0 + enable + enable + + + + + diff --git a/tests/verify-zones.cjs b/tests/verify-zones.cjs new file mode 100644 index 0000000..9e2cb37 --- /dev/null +++ b/tests/verify-zones.cjs @@ -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; });