Multi-país, idiomas, seguridad, instaladores y documentación

Seguridad
- Escapado de todo lo que viene de datos de terceros antes de ir al HTML,
  incluidos los tooltips de Leaflet. Los nombres de municipio salen de
  OpenStreetMap, que cualquiera puede editar: sin escapar, era una vía de
  inyección de código en el navegador de quien abriera el visor.

Multi-país
- El país pasa a ser un parámetro (REGION). Catálogo de 27 regiones entre
  Europa y el norte de África, declarando por país qué datos existen de verdad.
- Una malla por país y no una continental: Europa entera a 100 m son mil
  millones de celdas y unos 73 GB solo en capas ráster.
- Proyección equiárea propia para las regiones de fuera de Europa, donde
  EPSG:3035 deforma.

Interfaz
- Idiomas: castellano, inglés, francés y portugués, con selector persistente.
- Modo Mapa: el país entero sobre satélite, topográfico u OSM.
- Enlaces a mapas externos en la barra inferior, siempre visibles.
- Botón de ver de cerca también en la cabecera de la ficha.
- Fuera la distancia desde Madrid, que centralizaba algo de uso general.

Portabilidad
- install.sh e install.ps1 para Linux/macOS y Windows; requirements.txt.
- Sin rutas absolutas en el código.

Documentación
- Ocho páginas en docs/ para la wiki: instalación, uso, añadir un país,
  metodología, seguridad, fuentes y publicación.
This commit is contained in:
hacklab 2026-08-08 22:24:17 +02:00
parent d4b3b803e0
commit f890dd9448
22 changed files with 1288 additions and 280 deletions

View file

@ -9,13 +9,11 @@ explorarlas sobre relieve, ortofoto y catastro.
![vista del visor](docs/visor.png)
Documentación completa en la [wiki](../../wiki) y en `docs/`.
## Cómo usarlo
**En el navegador, sin instalar nada** — si está publicado en GitLab Pages:
`https://<usuario>.gitlab.io/<proyecto>/`. Funciona igual en Windows, Android y
Linux.
**En local**, tras clonar:
**Sin instalar nada**, tras clonar:
```bash
xdg-open out/visor.html # Linux
@ -64,18 +62,32 @@ perfecto al que no se puede llegar no salga con cuatro estrellas.
Los candidatos se eligen cogiendo el mejor punto de cada bloque de 20 km y
exigiendo después 10 km de separación entre los elegidos.
## Reproducirlo
## Otros países
Hacen falta ~25 GB de disco y un par de horas. Los datos de partida no están en
el repositorio: se descargan solos.
El país es un parámetro. Hay 27 declarados entre Europa y el norte de África,
pero **solo España está calculada**: el resto hay que procesarlo.
```bash
python3 -m venv .venv
./.venv/bin/pip install numpy scipy rasterio osmium shapely pyproj requests pillow
make all
./.venv/bin/python src/regions.py # ver el catálogo
REGION=pt make all # procesar Portugal
```
O paso a paso, ver el `Makefile`.
Una malla por país, no una continental: Europa entera a 100 m son mil millones
de celdas, 73 GB solo en capas ráster. Ver [Añadir un país](docs/Anadir-un-pais.md).
Ojo con lo que hay disponible en cada sitio: en el norte de África **no hay
fuente libre de espacios protegidos**, así que el filtro legal no se aplica y
hay que conseguir la cartografía del país aparte.
## Reproducirlo
Hacen falta ~25 GB de disco por país y un par de horas.
```bash
./install.sh # Linux y macOS
.\install.ps1 # Windows (PowerShell)
make all
```
## Lo que este análisis no sabe

6
abrir.ps1 Normal file
View file

@ -0,0 +1,6 @@
# Abre el visor en el navegador por defecto (Windows).
$ErrorActionPreference = "Stop"
Set-Location -Path $PSScriptRoot
$f = if ($args[0] -eq "informe") { "out\informe.html" } else { "out\visor.html" }
if (-not (Test-Path $f)) { Write-Host "No encuentro $f" -ForegroundColor Red; exit 1 }
Start-Process (Resolve-Path $f)

56
docs/Anadir-un-pais.md Normal file
View file

@ -0,0 +1,56 @@
# Añadir un país
El país es un parámetro. Se elige con la variable `REGION`.
```bash
./.venv/bin/python src/regions.py # ver el catálogo
REGION=pt make all # procesar Portugal entero
```
Los resultados van a `out/pt/` y los intermedios a `data/interim/pt/`. España,
que es la región por defecto, publica en `out/` para no romper rutas.
## Por qué una malla por país y no un mapa continental
Europa entera a 100 m son mil millones de celdas: **4 GB por capa** y unos
**73 GB** para las dieciocho capas del modelo, más de lo que cabe en memoria de
una vez. El norte de África añade otros 61 GB. Por país el problema es tratable
y además se puede paralelizar.
## Lo que hay que mirar antes
`src/regions.py` declara, por país, qué datos existen de verdad:
| Campo | Qué significa |
|---|---|
| `protected='eea'` | Hay Red Natura 2000 y designaciones nacionales de la Agencia Europea de Medio Ambiente. El filtro legal funciona |
| `protected=None` | **No hay fuente libre equivalente.** El filtro legal no se aplica. Pasa en todo el norte de África: hay que conseguir la cartografía del país antes de fiarse de un resultado |
| `ortho='pnoa'` | Ortofoto nacional descargable. Solo España |
| `ortho=None` | Sin foto aérea incrustada. En el visor queda el mapa deslizante, que sí funciona en todas partes |
## Proyección
Europa usa EPSG:3035 (equiárea europea). Para el norte de África se construye
una proyección equiárea centrada en el propio país, porque 3035 deforma fuera de
Europa. Está en `regions.crs_for()`.
## Añadir un país que no esté
En `src/regions.py`:
```python
"si": dict(name="Eslovenia", bbox=(13.3, 45.4, 16.6, 46.9),
osm=["europe/slovenia"],
neighbours=["europe/croatia", "europe/austria", "europe/italy"],
protected="eea", ortho=None),
```
- `bbox` en lon/lat con algo de margen.
- `osm` y `neighbours`, rutas de [Geofabrik](https://download.geofabrik.de).
- **Los vecinos importan.** Sin ellos, la franja fronteriza sale falsamente
vacía: el extracto está recortado por la frontera y al otro lado no hay
edificios en los datos aunque sí los haya en el terreno.
## Coste aproximado
Por país mediano: 2-6 GB de descarga, ~25 GB de disco temporal y un par de horas.

View file

@ -0,0 +1,28 @@
# Fuentes y licencias
| Dato | Fuente | Licencia |
|---|---|---|
| Edificios, viales, agua, arbolado, roca, límites administrativos | [OpenStreetMap](https://www.openstreetmap.org/copyright) vía Geofabrik | ODbL |
| Red Natura 2000 y designaciones nacionales | Agencia Europea de Medio Ambiente / MITECO | Reutilización con atribución |
| Ortofotos PNOA (solo España) | [Instituto Geográfico Nacional](https://www.ign.es) | CC BY 4.0 |
| Cartografía catastral (solo España) | [Dirección General del Catastro](https://www.sedecatastro.gob.es) | Reutilización con atribución |
| Modelo de elevaciones | Copernicus DEM GLO-90 | Copernicus |
| Clima | [WorldClim 2.1](https://www.worldclim.org) | CC BY-SA 4.0 |
| Mapa deslizante | [Leaflet](https://leafletjs.com) 1.9.4 | BSD-2 |
El código, MIT. Los datos mantienen la licencia de su fuente: si redistribuyes
resultados, **arrastra las atribuciones**.
## Servicios que consulta el visor en vivo
Solo en los modos Mapa y Ver de cerca, y solo si los activas:
- `www.ign.es` — ortofoto PNOA y mapa topográfico
- `ovc.catastro.meh.es` — parcelas catastrales
- `geoserver.iepnb.es` — Red Natura 2000 y espacios protegidos
- `tile.openstreetmap.org` — mapa base de OSM
Son servicios públicos con condiciones de uso razonable. El visor pide teselas
solo de lo que estás mirando; no descarga en bloque.
El modo Relieve y todos los datos del modelo funcionan **sin conexión**.

34
docs/Home.md Normal file
View file

@ -0,0 +1,34 @@
# EXPLORER
Localiza los puntos de un país más alejados de cualquier rastro de gente **a los
que además se puede llegar** con un vehículo, y los valora de 1 a 5 estrellas.
Barre el territorio celda a celda sobre una malla de 100 m usando datos abiertos,
descarta lo que no es viable (espacios protegidos, zonas militares, sin acceso,
demasiada pendiente) y puntúa lo que queda en seis criterios.
## Empezar
| Quiero… | Ir a |
|---|---|
| Solo mirar los resultados | [Uso del visor](Uso-del-visor) |
| Instalarlo en mi máquina | [Instalación](Instalacion) |
| Procesar otro país | [Añadir un país](Anadir-un-pais) |
| Entender cómo se calcula | [Metodología](Metodologia) |
| Saber qué datos usa | [Fuentes y licencias](Fuentes-y-licencias) |
| Publicarlo para más gente | [Publicar](Publicar) |
## Estado
Ahora mismo hay **un solo país procesado, España**, con 355 localizaciones.
El resto del catálogo (27 países entre Europa y el norte de África) está
declarado y el código los soporta, pero **sus datos no están calculados**: hay
que ejecutar el pipeline para cada uno. Ver [Añadir un país](Anadir-un-pais).
## Aviso
Esto localiza sitios despoblados; no es permiso para nada. Casi todo el terreno
es privado, muchas zonas tienen restricciones de acceso por incendios, y celebrar
una concentración sin autorización tiene consecuencias administrativas y, si
prende algo, penales. Comprueba propiedad, figura de protección y época del año
antes de moverte.

60
docs/Instalacion.md Normal file
View file

@ -0,0 +1,60 @@
# Instalación
## Para solo mirar los resultados: nada
El visor es un HTML. Clona el repositorio y ábrelo:
| Sistema | Orden |
|---|---|
| Linux | `xdg-open out/visor.html` o `./abrir.sh` |
| Windows | doble clic en `out\visor.html`, o `.\abrir.ps1` |
| macOS | `open out/visor.html` |
| Android / iPhone | ver [Publicar](Publicar) |
No hace falta servidor, ni Python, ni conexión.
**Ojo:** `visor.html` necesita la carpeta `out/ortho/` a su lado, que es donde
están las fotos aéreas. Si mueves el HTML solo, se quedan sin cargar.
### Si al abrirlo se abre un editor de texto
Le pasa a quien tiene los `.html` asociados a un editor, algo habitual si
programas. `xdg-open` respeta esa asociación, no el navegador por defecto.
Usa `./abrir.sh`, que llama al navegador directamente, o arrástralo a una
ventana del navegador.
## Para reconstruir los datos
Hacen falta Python 3.9+, unos 25 GB de disco por país y un par de horas.
### Linux / macOS
```bash
./install.sh
```
Si falta el módulo `venv`: `sudo apt install python3-venv` en Debian/Ubuntu.
### Windows
```powershell
Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass
.\install.ps1
```
Si no tienes Python: `winget install Python.Python.3.12`, marcando
*Add Python to PATH*.
### Qué instala
Solo paquetes de Python, dentro de `.venv/`, sin tocar el sistema:
`numpy`, `scipy`, `rasterio`, `osmium`, `shapely`, `pyproj`, `requests`,
`pillow`. Todos traen binarios precompilados para Linux, Windows y macOS, así
que **no hace falta compilar GDAL ni nada parecido**.
## Comprobar que funciona
```bash
./.venv/bin/python src/regions.py # lista los países disponibles
./.venv/bin/python src/revisar.py # comprueba la coherencia del resultado
```

80
docs/Metodologia.md Normal file
View file

@ -0,0 +1,80 @@
# Metodología
Malla de 100 m en proyección equiárea, así que una distancia euclídea en la
malla es una distancia real en el terreno.
## 1. Se descarta
Una celda queda fuera, por buena que sea, si cumple alguna:
| Condición | Por qué |
|---|---|
| En espacio protegido oficial, o a menos de 300 m del borde | Los parques se trazaron rodeando lo más vacío, y el modelo tendía a pegarse al límite, donde hay zona periférica y vigilancia |
| En zona militar o sobre agua | Obvio |
| A menos de 2 km de un edificio | Por debajo te oyen desde casa |
| A menos de 400 m de una carretera | Te ve cada coche que pasa |
| A más de 800 m de un vial | No metes un equipo más lejos |
| Más de 10° de pendiente | Ni se monta ni se aparca |
En España sobreviven 47.519 km², el 9,5 % del país.
## 2. Manda el sonido
Se estima el nivel que llega a la casa más cercana con propagación real:
```
SPL = 130 20·log₁₀(d) 0,5·d/1000 atenuación_arbolado apantallamiento
```
Divergencia esférica desde 130 dB a 1 m (equipo grande), absorción atmosférica
en frecuencias bajas (0,5 dB/km, que es lo que viaja), hasta 6 dB de absorción
por arbolado y hasta 12 dB de apantallamiento si estás en una hondonada.
Se exige **≤ 45 dB**, el límite nocturno típico en suelo rural. Es el filtro que
de verdad corta: de 47.519 km² quedan **505 km²** en toda España. El cuello de
botella no es la soledad, es el sonido.
## 3. Se puntúa
| Criterio | Peso | Qué mide |
|---|---|---|
| Sonido | 30 % | Los dB del apartado anterior |
| Soledad | 22 % | Lejanía de edificios, carreteras y núcleos; densidad construida en 5 km |
| Acceso | 18 % | Pista en condiciones cerca (OSM distingue firme y grado), llano, sin pedregal |
| Agua | 12 % | Río, arroyo o fuente entre 120 y 900 m |
| Arbolado | 10 % | Entre 20 y 65 % de cobertura: sombra y pantalla sin ser impenetrable |
| Clima | 8 % | Máxima del mes más cálido y mínima del más frío, corregidas por altitud |
Las estrellas salen de la media ponderada **penalizada por el eslabón más
débil**: un sitio perfecto al que no se puede llegar no es de cuatro estrellas.
## 4. Se reparten
Se divide el país en bloques de 20 km, se coge el mejor punto elegible de cada
bloque y después se exige 10 km de separación entre los elegidos. Sin ese
segundo paso salen pares casi duplicados: dos bloques vecinos pueden elegir cada
uno una celda pegada a su frontera común.
## Trampas que costaron sangre
- **La frontera.** El extracto de un país está recortado por su frontera, así
que al otro lado no hay edificios en los datos aunque sí en el terreno. Sin
añadir los países vecinos, toda la raya sale falsamente vacía.
- **La rejilla no está alineada con la latitud.** En EPSG:3035 baja más al sur
que la ventana de análisis, y colaba Melilla, cuyos edificios sí se habían
filtrado. Salía como el punto más aislado del país con 74,6 km. El máximo real
son 9,8 km.
- **Los protegidos de OpenStreetMap cubren el 15 %; los oficiales, el 29 %.**
Con los buenos, 33 de los 60 candidatos de la primera versión estaban dentro
de espacio protegido.
- **Sin distancia a carretera en la puntuación**, el modelo colocaba puntos de
cinco estrellas sobre el propio asfalto.
## Lo que no sabe
- **No ve vallas ni propiedad.** Es la mayor diferencia con la realidad de campo.
Por eso el visor lleva la capa de catastro.
- **El modelo acústico es de manual.** Una inversión térmica nocturna o el viento
a favor llevan el bajo mucho más lejos.
- **Ignora la estación.** Riesgo de incendio de junio a septiembre.
- **Distancia en línea recta, no tiempo de coche.**

48
docs/Publicar.md Normal file
View file

@ -0,0 +1,48 @@
# Publicar
## Este repositorio está en Gitea
**Gitea no tiene Pages integrado**, a diferencia de GitLab. Opciones:
### Clonar y abrir (lo más simple)
```bash
git clone https://gitea.laenre.net/hacklab/EXPLORER.git
cd EXPLORER
xdg-open out/visor.html
```
Funciona en Linux, Windows y macOS sin instalar nada.
### Servirlo con cualquier servidor estático
```bash
cd out && python3 -m http.server 8000
```
Y abrir `http://localhost:8000/visor.html`. Así también funciona desde el móvil
si estás en la misma red: `http://IP-DEL-ORDENADOR:8000/visor.html`.
Para dejarlo en un dominio, cualquier nginx/Apache sirviendo `out/` vale. No hay
backend.
### GitLab Pages, si lo espejas
El repositorio trae un `.gitlab-ci.yml` listo. Si creas un espejo en gitlab.com,
publica solo con hacer push y queda en
`https://USUARIO.gitlab.io/PROYECTO/`. Ese es el camino más cómodo para que lo
use gente desde el móvil sin descargar nada.
## Móvil
El visor se adapta a pantalla estrecha: el mapa arriba, los controles debajo y la
ficha a pantalla completa. Probado a 412 px de ancho.
Lo que **no** está probado en un móvil real es el gesto de pellizcar para hacer
zoom en el modo Relieve, que usa eventos de puntero propios. En los modos Mapa y
Ver de cerca el zoom lo gestiona Leaflet y no hay problema.
## Tamaño
El repositorio son unos 18 MB: `visor.html` 3,9 MB, `informe.html` 1,8 MB y
8,4 MB de fotos aéreas. Los 22 GB de datos de partida no se versionan.

40
docs/Seguridad.md Normal file
View file

@ -0,0 +1,40 @@
# Seguridad y privacidad
## Qué NO hace el visor
- No pide permisos del navegador. No usa geolocalización.
- No manda datos a ningún sitio. No tiene analítica, ni cookies, ni rastreo.
- Lo único que guarda es el idioma elegido, en `localStorage`.
- No carga código de ningún CDN: Leaflet va incrustado en el fichero.
## Qué SÍ sale de tu máquina
En los modos Mapa y Ver de cerca, el navegador pide teselas a los servidores de
la lista de [Fuentes](Fuentes-y-licencias). Esos servidores ven tu IP y **la zona
que estás mirando**, como con cualquier mapa web. Si eso te importa, quédate en
el modo Relieve, que es completamente local.
Los enlaces de la barra inferior abren servicios de terceros (Google, Mapillary,
Catastro) con las coordenadas en la URL. Solo se abren si pulsas.
## Escapado de contenido
Los nombres de municipio y de espacio protegido vienen de OpenStreetMap y de
ficheros oficiales, o sea de terceros: **cualquiera puede editar el nombre de un
municipio en OSM**. Todo lo que sale de los datos y acaba en el HTML pasa por
escapado antes, incluidos los tooltips del mapa. Sin eso, alguien podría inyectar
código editando un topónimo.
## Al reconstruir los datos
- Todas las descargas van por HTTPS con verificación de certificado.
- Los servidores del ministerio español sirven la cadena TLS incompleta. El
proyecto **no desactiva la verificación**: descarga el certificado intermedio
oficial de la FNMT y valida la cadena entera contra la raíz del sistema.
- El repositorio no contiene credenciales. `data/` está en `.gitignore`.
## Si lo publicas
El visor es HTML estático, sin backend, así que la superficie de ataque es la de
cualquier página estática. Sírvelo por HTTPS y añade cabeceras
`Content-Security-Policy` si tu servidor lo permite.

56
docs/Uso-del-visor.md Normal file
View file

@ -0,0 +1,56 @@
# Uso del visor
## Los tres modos
Arriba a la izquierda:
- **Relieve** — el país entero sobre relieve sombreado, generado a partir del
modelo de elevaciones. Funciona sin conexión. Encima puedes activar la capa de
aislamiento y la de espacios protegidos.
- **Mapa** — el país entero sobre mapa real: satélite, topográfico u
OpenStreetMap. Necesita conexión.
- **Ver de cerca** — lo mismo, centrado en la localización elegida.
## Capas
En los modos Mapa y Ver de cerca:
| Fondo | Qué aporta |
|---|---|
| Satélite | Ortofoto. Ves la explanada, los árboles y por dónde entra la pista |
| Topográfico | Curvas de nivel, cotas, arroyos, topónimos |
| OpenStreetMap | Caminos y nombres |
| Encima | Qué aporta |
|---|---|
| Parcelas del catastro | De quién es la finca y dónde acaba. **Lo más útil**: el modelo ve la pista, no el candado |
| Red Natura 2000 | Límites oficiales en vivo |
| Espacios protegidos | Figuras nacionales |
| Otras localizaciones | El resto de puntos, para ver alternativas cerca |
## Filtros
Estrellas mínimas, región, y mínimos por criterio (sonido, agua, arbolado,
acceso). La lista y el mapa se actualizan a la vez.
## La ficha
Al pulsar una localización:
- **Vista aérea** — foto real del sitio, 640 m de lado. Púlsala para ir al mapa.
- **Relieve 3D** — el terreno 9,6 km alrededor. Arrastra para girarlo y ver si
estás en una hondonada.
- **Los seis criterios** con su puntuación.
- **Los datos**: dB que llegan a la casa más cercana, distancias, arbolado,
pendiente, temperaturas.
- **Aviso** del espacio protegido más cercano.
## Los enlaces de abajo
Bajo el mapa, para la localización elegida: Satélite, Street View, Mapillary,
OSM, Catastro y Cómo llegar.
## Idioma
Selector arriba a la izquierda: castellano, inglés, francés y portugués. Se
recuerda entre visitas. Los nombres de municipio no se traducen, vienen del país.

57
install.ps1 Normal file
View file

@ -0,0 +1,57 @@
# Instalador para Windows (PowerShell).
#
# Solo hace falta para RECONSTRUIR los datos. Para USAR el visor no se instala
# nada: se abre out\visor.html haciendo doble clic.
#
# Si Windows bloquea el script:
# Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass
# .\install.ps1
$ErrorActionPreference = "Stop"
Set-Location -Path $PSScriptRoot
Write-Host "== RAVE SCOUT / EXPLORER ==" -ForegroundColor Cyan
# --- python ---
$py = $null
foreach ($c in @("py -3", "python", "python3")) {
try {
$exe, $arg = $c.Split(" ", 2)
$v = & $exe $arg -c "import sys;print(sys.version_info>=(3,9))" 2>$null
if ($v -eq "True") { $py = $c; break }
} catch { }
}
if (-not $py) {
Write-Host "Falta Python 3.9 o superior." -ForegroundColor Red
Write-Host " Descarga: https://www.python.org/downloads/"
Write-Host " O bien: winget install Python.Python.3.12"
Write-Host " Marca 'Add Python to PATH' durante la instalacion."
exit 1
}
$exe, $arg = $py.Split(" ", 2)
Write-Host ("Python: " + (& $exe $arg -V))
# --- entorno ---
if (-not (Test-Path ".venv")) { & $exe $arg -m venv .venv }
& ".\.venv\Scripts\python.exe" -m pip -q install --upgrade pip
Write-Host "Instalando dependencias..."
& ".\.venv\Scripts\python.exe" -m pip -q install -r requirements.txt
# --- comprobacion ---
$chk = @"
import importlib, sys
faltan = [m for m in ('numpy','scipy','rasterio','osmium','shapely','pyproj',
'requests','PIL') if not importlib.util.find_spec(m)]
if faltan:
print('FALTAN: ' + ', '.join(faltan)); sys.exit(1)
print('Dependencias correctas.')
"@
$chk | & ".\.venv\Scripts\python.exe" -
if ($LASTEXITCODE -ne 0) { exit 1 }
Write-Host ""
Write-Host "Listo." -ForegroundColor Green
Write-Host " Abrir el visor: .\abrir.ps1"
Write-Host " Ver paises: .\.venv\Scripts\python.exe src\regions.py"
Write-Host " Reconstruir Espana: .\.venv\Scripts\python.exe src\stars.py"
Write-Host " Otro pais: `$env:REGION='pt'; .\.venv\Scripts\python.exe src\stars.py"

50
install.sh Executable file
View file

@ -0,0 +1,50 @@
#!/usr/bin/env bash
# Instalador para Linux y macOS.
#
# Solo hace falta para RECONSTRUIR los datos. Para USAR el visor no se instala
# nada: se abre out/visor.html en cualquier navegador.
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")"
echo "== RAVE SCOUT / EXPLORER =="
# --- python ---
PY=""
for c in python3.12 python3.11 python3.10 python3; do
if command -v "$c" >/dev/null 2>&1; then
v=$("$c" -c 'import sys;print(sys.version_info>=(3,9))' 2>/dev/null || echo False)
[ "$v" = "True" ] && { PY="$c"; break; }
fi
done
[ -n "$PY" ] || { echo "Falta Python 3.9 o superior."; echo \
" Debian/Ubuntu: sudo apt install python3 python3-venv"; \
echo " Fedora: sudo dnf install python3"; \
echo " macOS: brew install python"; exit 1; }
echo "Python: $($PY -V)"
if ! "$PY" -c "import venv" >/dev/null 2>&1; then
echo "Falta el módulo venv. En Debian/Ubuntu: sudo apt install python3-venv"; exit 1
fi
# --- entorno ---
[ -d .venv ] || "$PY" -m venv .venv
./.venv/bin/pip -q install --upgrade pip
echo "Instalando dependencias…"
./.venv/bin/pip -q install -r requirements.txt
# --- comprobación ---
./.venv/bin/python - <<'EOF'
import importlib, sys
faltan = [m for m in ("numpy","scipy","rasterio","osmium","shapely","pyproj",
"requests","PIL") if not importlib.util.find_spec(m)]
if faltan:
print("FALTAN:", ", ".join(faltan)); sys.exit(1)
print("Dependencias correctas.")
EOF
echo
echo "Listo."
echo " Abrir el visor: ./abrir.sh"
echo " Ver países: ./.venv/bin/python src/regions.py"
echo " Reconstruir España: make all"
echo " Otro país: REGION=pt make all"

View file

@ -278,7 +278,7 @@ code.inline { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monosp
<thead><tr>
<th>#</th><th>valor</th><th>coordenadas</th><th>municipio</th><th>comunidad</th>
<th>dB</th><th>casa km</th><th>pista m</th><th>agua km</th><th>árbol %</th>
<th>prot km</th><th>Madrid km</th><th>ver</th>
<th>prot km</th><th>ver</th>
</tr></thead>
<tbody>
<tr>
@ -293,7 +293,6 @@ code.inline { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monosp
<td class="nu">0,1</td>
<td class="nu">33</td>
<td class="nu">3,1</td>
<td class="nu">174.9</td>
<td class="lk"><a href="https://www.google.com/maps/@39.36483,-5.24824,1500m/data=!3m1!1e3" target="_blank" rel="noopener">sat</a>
<a href="https://www.google.com/maps/@?api=1&map_action=pano&viewpoint=39.36483,-5.24824" target="_blank" rel="noopener">sv</a></td>
</tr>
@ -309,7 +308,6 @@ code.inline { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monosp
<td class="nu">2,1</td>
<td class="nu">65</td>
<td class="nu">4,6</td>
<td class="nu">156.5</td>
<td class="lk"><a href="https://www.google.com/maps/@39.28557,-4.82193,1500m/data=!3m1!1e3" target="_blank" rel="noopener">sat</a>
<a href="https://www.google.com/maps/@?api=1&map_action=pano&viewpoint=39.28557,-4.82193" target="_blank" rel="noopener">sv</a></td>
</tr>
@ -325,7 +323,6 @@ code.inline { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monosp
<td class="nu">1,3</td>
<td class="nu">61</td>
<td class="nu">1,1</td>
<td class="nu">168.6</td>
<td class="lk"><a href="https://www.google.com/maps/@39.02884,-4.54425,1500m/data=!3m1!1e3" target="_blank" rel="noopener">sat</a>
<a href="https://www.google.com/maps/@?api=1&map_action=pano&viewpoint=39.02884,-4.54425" target="_blank" rel="noopener">sv</a></td>
</tr>
@ -341,7 +338,6 @@ code.inline { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monosp
<td class="nu">1,8</td>
<td class="nu">59</td>
<td class="nu">1,7</td>
<td class="nu">198.0</td>
<td class="lk"><a href="https://www.google.com/maps/@38.69868,-4.38489,1500m/data=!3m1!1e3" target="_blank" rel="noopener">sat</a>
<a href="https://www.google.com/maps/@?api=1&map_action=pano&viewpoint=38.69868,-4.38489" target="_blank" rel="noopener">sv</a></td>
</tr>
@ -357,7 +353,6 @@ code.inline { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monosp
<td class="nu">0,4</td>
<td class="nu">51</td>
<td class="nu">3,9</td>
<td class="nu">190.0</td>
<td class="lk"><a href="https://www.google.com/maps/@39.4601,-5.5653,1500m/data=!3m1!1e3" target="_blank" rel="noopener">sat</a>
<a href="https://www.google.com/maps/@?api=1&map_action=pano&viewpoint=39.4601,-5.5653" target="_blank" rel="noopener">sv</a></td>
</tr>
@ -373,7 +368,6 @@ code.inline { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monosp
<td class="nu">2,7</td>
<td class="nu">83</td>
<td class="nu">0,4</td>
<td class="nu">122.1</td>
<td class="lk"><a href="https://www.google.com/maps/@39.35756,-4.12491,1500m/data=!3m1!1e3" target="_blank" rel="noopener">sat</a>
<a href="https://www.google.com/maps/@?api=1&map_action=pano&viewpoint=39.35756,-4.12491" target="_blank" rel="noopener">sv</a></td>
</tr>
@ -389,7 +383,6 @@ code.inline { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monosp
<td class="nu">4,6</td>
<td class="nu">74</td>
<td class="nu">3,2</td>
<td class="nu">168.6</td>
<td class="lk"><a href="https://www.google.com/maps/@39.18565,-4.88608,1500m/data=!3m1!1e3" target="_blank" rel="noopener">sat</a>
<a href="https://www.google.com/maps/@?api=1&map_action=pano&viewpoint=39.18565,-4.88608" target="_blank" rel="noopener">sv</a></td>
</tr>
@ -405,7 +398,6 @@ code.inline { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monosp
<td class="nu">4,1</td>
<td class="nu">68</td>
<td class="nu">0,5</td>
<td class="nu">254.0</td>
<td class="lk"><a href="https://www.google.com/maps/@38.79136,-5.82379,1500m/data=!3m1!1e3" target="_blank" rel="noopener">sat</a>
<a href="https://www.google.com/maps/@?api=1&map_action=pano&viewpoint=38.79136,-5.82379" target="_blank" rel="noopener">sv</a></td>
</tr>
@ -421,7 +413,6 @@ code.inline { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monosp
<td class="nu">0,1</td>
<td class="nu">17</td>
<td class="nu">4,7</td>
<td class="nu">388.2</td>
<td class="lk"><a href="https://www.google.com/maps/@37.12819,-5.34452,1500m/data=!3m1!1e3" target="_blank" rel="noopener">sat</a>
<a href="https://www.google.com/maps/@?api=1&map_action=pano&viewpoint=37.12819,-5.34452" target="_blank" rel="noopener">sv</a></td>
</tr>
@ -437,7 +428,6 @@ code.inline { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monosp
<td class="nu">1,2</td>
<td class="nu">31</td>
<td class="nu">0,4</td>
<td class="nu">139.5</td>
<td class="lk"><a href="https://www.google.com/maps/@39.31406,-4.51529,1500m/data=!3m1!1e3" target="_blank" rel="noopener">sat</a>
<a href="https://www.google.com/maps/@?api=1&map_action=pano&viewpoint=39.31406,-4.51529" target="_blank" rel="noopener">sv</a></td>
</tr>
@ -453,7 +443,6 @@ code.inline { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monosp
<td class="nu">1,8</td>
<td class="nu">70</td>
<td class="nu">12,9</td>
<td class="nu">119.9</td>
<td class="lk"><a href="https://www.google.com/maps/@40.06814,-2.3802,1500m/data=!3m1!1e3" target="_blank" rel="noopener">sat</a>
<a href="https://www.google.com/maps/@?api=1&map_action=pano&viewpoint=40.06814,-2.3802" target="_blank" rel="noopener">sv</a></td>
</tr>
@ -469,7 +458,6 @@ code.inline { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monosp
<td class="nu">1,8</td>
<td class="nu">73</td>
<td class="nu">3,0</td>
<td class="nu">209.9</td>
<td class="lk"><a href="https://www.google.com/maps/@41.63594,-1.77527,1500m/data=!3m1!1e3" target="_blank" rel="noopener">sat</a>
<a href="https://www.google.com/maps/@?api=1&map_action=pano&viewpoint=41.63594,-1.77527" target="_blank" rel="noopener">sv</a></td>
</tr>
@ -485,7 +473,6 @@ code.inline { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monosp
<td class="nu">1,7</td>
<td class="nu">0</td>
<td class="nu">0,3</td>
<td class="nu">264.9</td>
<td class="lk"><a href="https://www.google.com/maps/@39.94037,-6.75562,1500m/data=!3m1!1e3" target="_blank" rel="noopener">sat</a>
<a href="https://www.google.com/maps/@?api=1&map_action=pano&viewpoint=39.94037,-6.75562" target="_blank" rel="noopener">sv</a></td>
</tr>
@ -501,7 +488,6 @@ code.inline { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monosp
<td class="nu">1,4</td>
<td class="nu">62</td>
<td class="nu">3,1</td>
<td class="nu">158.3</td>
<td class="lk"><a href="https://www.google.com/maps/@40.44825,-5.5637,1500m/data=!3m1!1e3" target="_blank" rel="noopener">sat</a>
<a href="https://www.google.com/maps/@?api=1&map_action=pano&viewpoint=40.44825,-5.5637" target="_blank" rel="noopener">sv</a></td>
</tr>
@ -517,7 +503,6 @@ code.inline { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monosp
<td class="nu">2,4</td>
<td class="nu">52</td>
<td class="nu">3,3</td>
<td class="nu">149.3</td>
<td class="lk"><a href="https://www.google.com/maps/@39.1442,-4.30836,1500m/data=!3m1!1e3" target="_blank" rel="noopener">sat</a>
<a href="https://www.google.com/maps/@?api=1&map_action=pano&viewpoint=39.1442,-4.30836" target="_blank" rel="noopener">sv</a></td>
</tr>
@ -533,7 +518,6 @@ code.inline { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monosp
<td class="nu">0,1</td>
<td class="nu">32</td>
<td class="nu">1,5</td>
<td class="nu">309.5</td>
<td class="lk"><a href="https://www.google.com/maps/@37.67526,-3.03813,1500m/data=!3m1!1e3" target="_blank" rel="noopener">sat</a>
<a href="https://www.google.com/maps/@?api=1&map_action=pano&viewpoint=37.67526,-3.03813" target="_blank" rel="noopener">sv</a></td>
</tr>
@ -549,7 +533,6 @@ code.inline { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monosp
<td class="nu">2,1</td>
<td class="nu">69</td>
<td class="nu">0,7</td>
<td class="nu">138.5</td>
<td class="lk"><a href="https://www.google.com/maps/@39.41756,-4.69774,1500m/data=!3m1!1e3" target="_blank" rel="noopener">sat</a>
<a href="https://www.google.com/maps/@?api=1&map_action=pano&viewpoint=39.41756,-4.69774" target="_blank" rel="noopener">sv</a></td>
</tr>
@ -565,7 +548,6 @@ code.inline { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monosp
<td class="nu">0,1</td>
<td class="nu">35</td>
<td class="nu">1,7</td>
<td class="nu">87.8</td>
<td class="lk"><a href="https://www.google.com/maps/@40.29354,-2.68815,1500m/data=!3m1!1e3" target="_blank" rel="noopener">sat</a>
<a href="https://www.google.com/maps/@?api=1&map_action=pano&viewpoint=40.29354,-2.68815" target="_blank" rel="noopener">sv</a></td>
</tr>
@ -581,7 +563,6 @@ code.inline { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monosp
<td class="nu">0,1</td>
<td class="nu">88</td>
<td class="nu">2,7</td>
<td class="nu">267.6</td>
<td class="lk"><a href="https://www.google.com/maps/@42.69107,-4.73172,1500m/data=!3m1!1e3" target="_blank" rel="noopener">sat</a>
<a href="https://www.google.com/maps/@?api=1&map_action=pano&viewpoint=42.69107,-4.73172" target="_blank" rel="noopener">sv</a></td>
</tr>
@ -597,7 +578,6 @@ code.inline { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monosp
<td class="nu">2,6</td>
<td class="nu">70</td>
<td class="nu">3,4</td>
<td class="nu">153.6</td>
<td class="lk"><a href="https://www.google.com/maps/@39.15173,-4.46609,1500m/data=!3m1!1e3" target="_blank" rel="noopener">sat</a>
<a href="https://www.google.com/maps/@?api=1&map_action=pano&viewpoint=39.15173,-4.46609" target="_blank" rel="noopener">sv</a></td>
</tr>
@ -613,7 +593,6 @@ code.inline { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monosp
<td class="nu">2,6</td>
<td class="nu">12</td>
<td class="nu">6,0</td>
<td class="nu">180.5</td>
<td class="lk"><a href="https://www.google.com/maps/@38.98983,-4.75109,1500m/data=!3m1!1e3" target="_blank" rel="noopener">sat</a>
<a href="https://www.google.com/maps/@?api=1&map_action=pano&viewpoint=38.98983,-4.75109" target="_blank" rel="noopener">sv</a></td>
</tr>
@ -629,7 +608,6 @@ code.inline { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monosp
<td class="nu">0,7</td>
<td class="nu">20</td>
<td class="nu">1,5</td>
<td class="nu">272.5</td>
<td class="lk"><a href="https://www.google.com/maps/@40.06355,-6.87354,1500m/data=!3m1!1e3" target="_blank" rel="noopener">sat</a>
<a href="https://www.google.com/maps/@?api=1&map_action=pano&viewpoint=40.06355,-6.87354" target="_blank" rel="noopener">sv</a></td>
</tr>
@ -645,7 +623,6 @@ code.inline { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monosp
<td class="nu">1,1</td>
<td class="nu">100</td>
<td class="nu">1,0</td>
<td class="nu">211.3</td>
<td class="lk"><a href="https://www.google.com/maps/@39.75725,-1.39878,1500m/data=!3m1!1e3" target="_blank" rel="noopener">sat</a>
<a href="https://www.google.com/maps/@?api=1&map_action=pano&viewpoint=39.75725,-1.39878" target="_blank" rel="noopener">sv</a></td>
</tr>
@ -661,7 +638,6 @@ code.inline { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monosp
<td class="nu">3,1</td>
<td class="nu">63</td>
<td class="nu">4,5</td>
<td class="nu">228.3</td>
<td class="lk"><a href="https://www.google.com/maps/@41.74254,-1.60532,1500m/data=!3m1!1e3" target="_blank" rel="noopener">sat</a>
<a href="https://www.google.com/maps/@?api=1&map_action=pano&viewpoint=41.74254,-1.60532" target="_blank" rel="noopener">sv</a></td>
</tr>
@ -677,7 +653,6 @@ code.inline { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monosp
<td class="nu">4,4</td>
<td class="nu">45</td>
<td class="nu">4,0</td>
<td class="nu">191.5</td>
<td class="lk"><a href="https://www.google.com/maps/@39.17383,-5.2821,1500m/data=!3m1!1e3" target="_blank" rel="noopener">sat</a>
<a href="https://www.google.com/maps/@?api=1&map_action=pano&viewpoint=39.17383,-5.2821" target="_blank" rel="noopener">sv</a></td>
</tr>
@ -693,7 +668,6 @@ code.inline { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monosp
<td class="nu">1,8</td>
<td class="nu">30</td>
<td class="nu">3,6</td>
<td class="nu">185.2</td>
<td class="lk"><a href="https://www.google.com/maps/@38.84744,-4.48759,1500m/data=!3m1!1e3" target="_blank" rel="noopener">sat</a>
<a href="https://www.google.com/maps/@?api=1&map_action=pano&viewpoint=38.84744,-4.48759" target="_blank" rel="noopener">sv</a></td>
</tr>
@ -709,7 +683,6 @@ code.inline { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monosp
<td class="nu">0,1</td>
<td class="nu">71</td>
<td class="nu">0,8</td>
<td class="nu">129.2</td>
<td class="lk"><a href="https://www.google.com/maps/@39.63124,-4.83664,1500m/data=!3m1!1e3" target="_blank" rel="noopener">sat</a>
<a href="https://www.google.com/maps/@?api=1&map_action=pano&viewpoint=39.63124,-4.83664" target="_blank" rel="noopener">sv</a></td>
</tr>
@ -725,7 +698,6 @@ code.inline { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monosp
<td class="nu">1,0</td>
<td class="nu">64</td>
<td class="nu">2,9</td>
<td class="nu">319.1</td>
<td class="lk"><a href="https://www.google.com/maps/@38.2372,-6.17273,1500m/data=!3m1!1e3" target="_blank" rel="noopener">sat</a>
<a href="https://www.google.com/maps/@?api=1&map_action=pano&viewpoint=38.2372,-6.17273" target="_blank" rel="noopener">sv</a></td>
</tr>
@ -741,7 +713,6 @@ code.inline { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monosp
<td class="nu">2,3</td>
<td class="nu">85</td>
<td class="nu">8,1</td>
<td class="nu">164.7</td>
<td class="lk"><a href="https://www.google.com/maps/@39.82924,-1.94614,1500m/data=!3m1!1e3" target="_blank" rel="noopener">sat</a>
<a href="https://www.google.com/maps/@?api=1&map_action=pano&viewpoint=39.82924,-1.94614" target="_blank" rel="noopener">sv</a></td>
</tr>
@ -757,7 +728,6 @@ code.inline { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monosp
<td class="nu">5,7</td>
<td class="nu">92</td>
<td class="nu">0,9</td>
<td class="nu">165.4</td>
<td class="lk"><a href="https://www.google.com/maps/@39.26777,-4.9603,1500m/data=!3m1!1e3" target="_blank" rel="noopener">sat</a>
<a href="https://www.google.com/maps/@?api=1&map_action=pano&viewpoint=39.26777,-4.9603" target="_blank" rel="noopener">sv</a></td>
</tr>
@ -773,7 +743,6 @@ code.inline { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monosp
<td class="nu">0,5</td>
<td class="nu">80</td>
<td class="nu">1,1</td>
<td class="nu">220.3</td>
<td class="lk"><a href="https://www.google.com/maps/@41.25428,-1.32647,1500m/data=!3m1!1e3" target="_blank" rel="noopener">sat</a>
<a href="https://www.google.com/maps/@?api=1&map_action=pano&viewpoint=41.25428,-1.32647" target="_blank" rel="noopener">sv</a></td>
</tr>
@ -789,7 +758,6 @@ code.inline { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monosp
<td class="nu">0,2</td>
<td class="nu">65</td>
<td class="nu">4,3</td>
<td class="nu">266.0</td>
<td class="lk"><a href="https://www.google.com/maps/@38.58789,-5.74676,1500m/data=!3m1!1e3" target="_blank" rel="noopener">sat</a>
<a href="https://www.google.com/maps/@?api=1&map_action=pano&viewpoint=38.58789,-5.74676" target="_blank" rel="noopener">sv</a></td>
</tr>
@ -805,7 +773,6 @@ code.inline { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monosp
<td class="nu">3,4</td>
<td class="nu">69</td>
<td class="nu">6,5</td>
<td class="nu">316.8</td>
<td class="lk"><a href="https://www.google.com/maps/@38.17857,-6.04108,1500m/data=!3m1!1e3" target="_blank" rel="noopener">sat</a>
<a href="https://www.google.com/maps/@?api=1&map_action=pano&viewpoint=38.17857,-6.04108" target="_blank" rel="noopener">sv</a></td>
</tr>
@ -821,7 +788,6 @@ code.inline { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monosp
<td class="nu">3,3</td>
<td class="nu">36</td>
<td class="nu">1,0</td>
<td class="nu">303.0</td>
<td class="lk"><a href="https://www.google.com/maps/@39.21502,-6.90347,1500m/data=!3m1!1e3" target="_blank" rel="noopener">sat</a>
<a href="https://www.google.com/maps/@?api=1&map_action=pano&viewpoint=39.21502,-6.90347" target="_blank" rel="noopener">sv</a></td>
</tr>
@ -837,7 +803,6 @@ code.inline { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monosp
<td class="nu">1,2</td>
<td class="nu">90</td>
<td class="nu">13,7</td>
<td class="nu">295.9</td>
<td class="lk"><a href="https://www.google.com/maps/@42.77336,-5.32078,1500m/data=!3m1!1e3" target="_blank" rel="noopener">sat</a>
<a href="https://www.google.com/maps/@?api=1&map_action=pano&viewpoint=42.77336,-5.32078" target="_blank" rel="noopener">sv</a></td>
</tr>
@ -853,7 +818,6 @@ code.inline { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monosp
<td class="nu">0,9</td>
<td class="nu">0</td>
<td class="nu">0,6</td>
<td class="nu">403.2</td>
<td class="lk"><a href="https://www.google.com/maps/@37.8542,-7.07419,1500m/data=!3m1!1e3" target="_blank" rel="noopener">sat</a>
<a href="https://www.google.com/maps/@?api=1&map_action=pano&viewpoint=37.8542,-7.07419" target="_blank" rel="noopener">sv</a></td>
</tr>
@ -869,7 +833,6 @@ code.inline { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monosp
<td class="nu">2,3</td>
<td class="nu">0</td>
<td class="nu">7,0</td>
<td class="nu">378.9</td>
<td class="lk"><a href="https://www.google.com/maps/@37.15841,-5.13089,1500m/data=!3m1!1e3" target="_blank" rel="noopener">sat</a>
<a href="https://www.google.com/maps/@?api=1&map_action=pano&viewpoint=37.15841,-5.13089" target="_blank" rel="noopener">sv</a></td>
</tr>
@ -885,7 +848,6 @@ code.inline { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monosp
<td class="nu">5,9</td>
<td class="nu">73</td>
<td class="nu">1,9</td>
<td class="nu">346.1</td>
<td class="lk"><a href="https://www.google.com/maps/@41.31303,0.23323,1500m/data=!3m1!1e3" target="_blank" rel="noopener">sat</a>
<a href="https://www.google.com/maps/@?api=1&map_action=pano&viewpoint=41.31303,0.23323" target="_blank" rel="noopener">sv</a></td>
</tr>
@ -901,7 +863,6 @@ code.inline { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monosp
<td class="nu">0,8</td>
<td class="nu">10</td>
<td class="nu">2,1</td>
<td class="nu">141.0</td>
<td class="lk"><a href="https://www.google.com/maps/@39.523,-4.89553,1500m/data=!3m1!1e3" target="_blank" rel="noopener">sat</a>
<a href="https://www.google.com/maps/@?api=1&map_action=pano&viewpoint=39.523,-4.89553" target="_blank" rel="noopener">sv</a></td>
</tr>
@ -917,7 +878,6 @@ code.inline { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monosp
<td class="nu">0,5</td>
<td class="nu">27</td>
<td class="nu">6,3</td>
<td class="nu">333.9</td>
<td class="lk"><a href="https://www.google.com/maps/@38.04833,-6.15128,1500m/data=!3m1!1e3" target="_blank" rel="noopener">sat</a>
<a href="https://www.google.com/maps/@?api=1&map_action=pano&viewpoint=38.04833,-6.15128" target="_blank" rel="noopener">sv</a></td>
</tr>
@ -933,7 +893,6 @@ code.inline { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monosp
<td class="nu">0,1</td>
<td class="nu">47</td>
<td class="nu">0,8</td>
<td class="nu">121.5</td>
<td class="lk"><a href="https://www.google.com/maps/@39.33611,-3.9768,1500m/data=!3m1!1e3" target="_blank" rel="noopener">sat</a>
<a href="https://www.google.com/maps/@?api=1&map_action=pano&viewpoint=39.33611,-3.9768" target="_blank" rel="noopener">sv</a></td>
</tr>
@ -949,7 +908,6 @@ code.inline { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monosp
<td class="nu">0,1</td>
<td class="nu">87</td>
<td class="nu">1,0</td>
<td class="nu">280.4</td>
<td class="lk"><a href="https://www.google.com/maps/@40.22638,-6.99076,1500m/data=!3m1!1e3" target="_blank" rel="noopener">sat</a>
<a href="https://www.google.com/maps/@?api=1&map_action=pano&viewpoint=40.22638,-6.99076" target="_blank" rel="noopener">sv</a></td>
</tr>
@ -965,7 +923,6 @@ code.inline { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monosp
<td class="nu">2,4</td>
<td class="nu">51</td>
<td class="nu">0,5</td>
<td class="nu">231.4</td>
<td class="lk"><a href="https://www.google.com/maps/@40.46339,-0.98534,1500m/data=!3m1!1e3" target="_blank" rel="noopener">sat</a>
<a href="https://www.google.com/maps/@?api=1&map_action=pano&viewpoint=40.46339,-0.98534" target="_blank" rel="noopener">sv</a></td>
</tr>
@ -981,7 +938,6 @@ code.inline { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monosp
<td class="nu">0,2</td>
<td class="nu">61</td>
<td class="nu">2,7</td>
<td class="nu">350.0</td>
<td class="lk"><a href="https://www.google.com/maps/@37.42111,-2.46481,1500m/data=!3m1!1e3" target="_blank" rel="noopener">sat</a>
<a href="https://www.google.com/maps/@?api=1&map_action=pano&viewpoint=37.42111,-2.46481" target="_blank" rel="noopener">sv</a></td>
</tr>
@ -997,7 +953,6 @@ code.inline { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monosp
<td class="nu">4,5</td>
<td class="nu">35</td>
<td class="nu">2,5</td>
<td class="nu">147.0</td>
<td class="lk"><a href="https://www.google.com/maps/@39.10465,-3.99852,1500m/data=!3m1!1e3" target="_blank" rel="noopener">sat</a>
<a href="https://www.google.com/maps/@?api=1&map_action=pano&viewpoint=39.10465,-3.99852" target="_blank" rel="noopener">sv</a></td>
</tr>
@ -1013,7 +968,6 @@ code.inline { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monosp
<td class="nu">2,1</td>
<td class="nu">74</td>
<td class="nu">1,2</td>
<td class="nu">176.9</td>
<td class="lk"><a href="https://www.google.com/maps/@39.99736,-1.71303,1500m/data=!3m1!1e3" target="_blank" rel="noopener">sat</a>
<a href="https://www.google.com/maps/@?api=1&map_action=pano&viewpoint=39.99736,-1.71303" target="_blank" rel="noopener">sv</a></td>
</tr>
@ -1029,7 +983,6 @@ code.inline { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monosp
<td class="nu">2,3</td>
<td class="nu">66</td>
<td class="nu">0,7</td>
<td class="nu">153.2</td>
<td class="lk"><a href="https://www.google.com/maps/@41.68438,-2.95459,1500m/data=!3m1!1e3" target="_blank" rel="noopener">sat</a>
<a href="https://www.google.com/maps/@?api=1&map_action=pano&viewpoint=41.68438,-2.95459" target="_blank" rel="noopener">sv</a></td>
</tr>
@ -1059,8 +1012,6 @@ code.inline { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monosp
riesgo extremo de incendio de junio a septiembre, con restricciones de acceso y
responsabilidad penal si algo prende. El mismo punto no es el mismo sitio en marzo
que en agosto.</li>
<li><h3>Distancia en línea recta, no tiempo de coche</h3>La columna de Madrid es
euclídea. Por carretera y luego pista, el tiempo real puede ser el doble.</li>
</ul>
</div>
</section>

File diff suppressed because one or more lines are too long

8
requirements.txt Normal file
View file

@ -0,0 +1,8 @@
numpy>=1.24
scipy>=1.10
rasterio>=1.3
osmium>=4.0
shapely>=2.0
pyproj>=3.5
requests>=2.28
pillow>=10.0

View file

@ -59,7 +59,7 @@ def main():
"c_sonido", "c_soledad", "c_agua", "c_arbolado", "c_acceso", "c_clima",
"db_en_casa", "d_edificio", "d_carretera", "d_pista", "d_agua",
"d_protegido", "protegido_cerca", "km_protegido", "arbolado_pct",
"roca_pct", "pendiente", "tpi", "cota", "tmax", "tmin", "km_madrid"]
"roca_pct", "pendiente", "tpi", "cota", "tmax", "tmin"]
rows = []
for i, cd in enumerate(cands):
rec = [cd[k] for k in keys]
@ -121,7 +121,10 @@ button,input,select{font:inherit;color:inherit}
border-right:1px solid var(--line);display:flex;flex-direction:column;height:100vh}
#side header{padding:16px 18px 12px;border-bottom:1px solid var(--line)}
#side h1{margin:0;font-size:15px;letter-spacing:.02em}
#side .sub{color:var(--muted);font-size:11.5px;margin-top:3px}
#side .sub{color:var(--muted);font-size:11.5px;margin-top:3px;display:flex;
justify-content:space-between;align-items:center;gap:8px}
#lang{width:auto;padding:2px 5px;font-size:11px;background:var(--panel2);
border:1px solid var(--line);border-radius:4px;color:var(--ink2);cursor:pointer}
.filters{padding:14px 18px;display:flex;flex-direction:column;gap:13px;
border-bottom:1px solid var(--line)}
.f-row{display:flex;flex-direction:column;gap:6px}
@ -161,12 +164,30 @@ select{width:100%;background:var(--panel2);border:1px solid var(--line);
border-radius:5px;padding:6px 11px;font-size:12px;cursor:pointer;color:var(--ink2)}
.toolbar button[aria-pressed=true]{background:var(--accent);border-color:var(--accent);
color:#08131f;font-weight:600}
.legend{position:absolute;bottom:12px;left:12px;background:rgba(21,26,33,.93);
border:1px solid var(--line);border-radius:5px;padding:9px 12px;font-size:11px;
display:flex;gap:13px;align-items:center;z-index:5}
/* Barra inferior: leyenda a la izquierda y, a su derecha, los enlaces a mapas
externos de la localización elegida. Antes había que abrir la ficha y bajar
hasta el final para encontrarlos. */
.bottombar{position:absolute;bottom:12px;left:12px;right:12px;z-index:5;
display:flex;gap:10px;align-items:center;justify-content:space-between;
flex-wrap:wrap;pointer-events:none}
.bottombar>*{pointer-events:auto}
.legend{background:rgba(21,26,33,.93);border:1px solid var(--line);
border-radius:5px;padding:9px 12px;font-size:11px;display:flex;gap:13px;
align-items:center}
.legend i{width:9px;height:9px;border-radius:50%;display:inline-block;margin-right:4px}
.hint{position:absolute;bottom:12px;right:12px;color:var(--muted);font-size:11px;
background:rgba(21,26,33,.9);padding:5px 9px;border-radius:4px;z-index:5}
.maplinks{background:rgba(21,26,33,.93);border:1px solid var(--line);
border-radius:5px;padding:5px 6px;display:flex;gap:4px;align-items:center;
flex-wrap:wrap;max-width:100%}
.maplinks a{display:flex;flex-direction:column;gap:0;padding:5px 9px;
border-radius:4px;text-decoration:none;line-height:1.2;white-space:nowrap}
.maplinks a b{color:var(--accent);font-size:11.5px;font-weight:600}
.maplinks a span{color:var(--muted);font-size:9.5px}
.maplinks a:hover{background:#1d2836}
.ml-sel{padding:5px 10px 5px 6px;border-right:1px solid var(--line);
margin-right:3px;font-size:11px;color:var(--ink2);white-space:nowrap;
display:flex;flex-direction:column;line-height:1.25}
.ml-sel b{font-size:11.5px;color:var(--ink)}
.ml-hint{color:var(--muted);font-size:11px;padding:6px 9px}
/* ---------- mapa de cerca (Leaflet) ---------- */
#leaf{position:absolute;top:0;left:0;width:100%;height:100%;display:none;
@ -174,6 +195,8 @@ select{width:100%;background:var(--panel2);border:1px solid var(--line);
body.cerca #map{display:none}
body.cerca #leaf{display:block}
body.cerca .legend{display:none}
/* los conmutadores del relieve solo tienen sentido en su propio modo */
body.cerca .only-esp{display:none}
#layers{position:absolute;top:52px;left:12px;z-index:6;width:186px;
background:rgba(21,26,33,.95);border:1px solid var(--line);border-radius:6px;
padding:9px 11px;display:none;flex-direction:column;gap:8px;font-size:12px}
@ -211,6 +234,7 @@ body.cerca.sinsel #nosel{display:block}
.d-head h2{margin:0 26px 0 0;font-size:16px}
.d-head .loc{color:var(--muted);font-size:12px;margin-top:2px}
.d-head .stars{font-size:16px;margin-top:7px;letter-spacing:1px}
.d-head .btn-cerca{margin-top:11px}
#close{position:absolute;top:13px;right:14px;background:none;border:0;
color:var(--muted);font-size:20px;cursor:pointer;line-height:1;padding:2px 5px}
#close:hover{color:var(--ink)}
@ -289,13 +313,16 @@ body.cerca.sinsel #nosel{display:block}
<aside id="side">
<header>
<h1>Localizaciones · España</h1>
<div class="sub">__N_CAND__ localizaciones · malla de 100 m</div>
<h1 data-i18n="title">Localizaciones</h1>
<div class="sub"><span id="subtxt"></span>
<select id="lang" aria-label="Idioma"><option value="es">ES</option>
<option value="en">EN</option><option value="fr">FR</option>
<option value="pt">PT</option></select></div>
</header>
<div class="filters">
<div class="f-row">
<label>Estrellas mínimas</label>
<label data-i18n="minstars">Estrellas mínimas</label>
<div class="starbtns" id="starf">
<button data-s="1" aria-pressed="true">1+</button>
<button data-s="2" aria-pressed="false">2+</button>
@ -305,27 +332,23 @@ body.cerca.sinsel #nosel{display:block}
</div>
</div>
<div class="f-row">
<label for="reg">Comunidad</label>
<select id="reg"><option value="">todas</option></select>
<label for="reg" data-i18n="region">Comunidad</label>
<select id="reg"><option value="" data-i18n="all">todas</option></select>
</div>
<div class="f-row">
<label for="fmad">Distancia desde Madrid <b id="vmad">sin límite</b></label>
<input type="range" id="fmad" min="25" max="700" step="25" value="700">
</div>
<div class="f-row">
<label for="fson">Aislamiento sonoro mínimo <b id="vson">0</b></label>
<label for="fson"><span data-i18n="fsound">Aislamiento sonoro mínimo</span> <b id="vson">0</b></label>
<input type="range" id="fson" min="0" max="100" step="5" value="0">
</div>
<div class="f-row">
<label for="fagu">Agua cerca mínimo <b id="vagu">0</b></label>
<label for="fagu"><span data-i18n="fwater">Agua cerca mínimo</span> <b id="vagu">0</b></label>
<input type="range" id="fagu" min="0" max="100" step="5" value="0">
</div>
<div class="f-row">
<label for="farb">Arbolado mínimo <b id="varb">0</b></label>
<label for="farb"><span data-i18n="ftrees">Arbolado mínimo</span> <b id="varb">0</b></label>
<input type="range" id="farb" min="0" max="100" step="5" value="0">
</div>
<div class="f-row">
<label for="facc">Acceso mínimo <b id="vacc">0</b></label>
<label for="facc"><span data-i18n="faccess">Acceso mínimo</span> <b id="vacc">0</b></label>
<input type="range" id="facc" min="0" max="100" step="5" value="0">
</div>
</div>
@ -337,35 +360,38 @@ body.cerca.sinsel #nosel{display:block}
<div id="main">
<canvas id="map"></canvas>
<div id="leaf"></div>
<div id="nosel">Elige una localización en la lista<br>para verla de cerca</div>
<div id="nosel" data-i18n="nosel">Elige una localización en la lista para verla de cerca</div>
<div class="toolbar">
<button id="bEsp" aria-pressed="true">España</button>
<button id="bCerca" aria-pressed="false">Ver de cerca</button>
<button id="bEsp" aria-pressed="true" data-i18n="mrelief">Relieve</button>
<button id="bMapa" aria-pressed="false" data-i18n="mmap">Mapa</button>
<button id="bCerca" aria-pressed="false" data-i18n="mclose">Ver de cerca</button>
<span style="width:9px"></span>
<button id="bRel" aria-pressed="true">Relieve</button>
<button id="bIso" aria-pressed="false">Aislamiento</button>
<button id="bProt" aria-pressed="true">Protegidos</button>
<button id="bFit">Encajar</button>
<button id="bRel" aria-pressed="true" class="only-esp" data-i18n="lshade">Sombreado</button>
<button id="bIso" aria-pressed="false" class="only-esp" data-i18n="liso">Aislamiento</button>
<button id="bProt" aria-pressed="true" class="only-esp" data-i18n="lprot">Protegidos</button>
<button id="bFit" class="only-esp" data-i18n="lfit">Encajar</button>
</div>
<div id="layers">
<div class="grp">
<span class="ttl">Fondo</span>
<label><input type="radio" name="base" value="pnoa" checked> Satélite (PNOA)</label>
<label><input type="radio" name="base" value="mtn"> Topográfico (MTN)</label>
<label><input type="radio" name="base" value="osm"> OpenStreetMap</label>
<span class="ttl" data-i18n="base">Fondo</span>
<label><input type="radio" name="base" value="pnoa" checked> <span data-i18n="bsat">Satélite (PNOA)</span></label>
<label><input type="radio" name="base" value="mtn"> <span data-i18n="btopo">Topográfico (MTN)</span></label>
<label><input type="radio" name="base" value="osm"> <span data-i18n="bosm">OpenStreetMap</span></label>
</div>
<div class="grp">
<span class="ttl">Encima</span>
<label><input type="checkbox" id="ovCat"> Parcelas del catastro</label>
<label><input type="checkbox" id="ovN2k" checked> Red Natura 2000</label>
<label><input type="checkbox" id="ovEnp" checked> Espacios protegidos</label>
<label><input type="checkbox" id="ovPts" checked> Otras localizaciones</label>
<span class="ttl" data-i18n="over">Encima</span>
<label><input type="checkbox" id="ovCat"> <span data-i18n="ocad">Parcelas del catastro</span></label>
<label><input type="checkbox" id="ovN2k" checked> <span data-i18n="on2k">Red Natura 2000</span></label>
<label><input type="checkbox" id="ovEnp" checked> <span data-i18n="oenp">Espacios protegidos</span></label>
<label><input type="checkbox" id="ovPts" checked> <span data-i18n="opts">Otras localizaciones</span></label>
</div>
</div>
<div class="legend" id="legend"></div>
<div class="hint">arrastra para mover · rueda para zoom · clic en un punto</div>
<div class="bottombar">
<div class="legend" id="legend"></div>
<div class="maplinks" id="mapLinks"></div>
</div>
<aside id="detail">
<div class="d-head">
@ -373,10 +399,11 @@ body.cerca.sinsel #nosel{display:block}
<h2 id="dName"></h2>
<div class="loc" id="dLoc"></div>
<div class="stars" id="dStars"></div>
<button class="btn-cerca" id="bVerCercaTop" data-i18n="seeclose">Ver de cerca en el mapa</button>
</div>
<div class="tabs">
<button id="tabSat" aria-pressed="true">Vista aérea</button>
<button id="tab3d" aria-pressed="false">Relieve 3D</button>
<button id="tabSat" aria-pressed="true" data-i18n="tabaer">Vista aérea</button>
<button id="tab3d" aria-pressed="false" data-i18n="tab3d">Relieve 3D</button>
</div>
<img id="ortho" alt="Vista aérea de la localización" src="">
<canvas id="tcanvas"></canvas>
@ -386,10 +413,8 @@ body.cerca.sinsel #nosel{display:block}
<div class="facts" id="dFacts"></div>
<div id="dWarn"></div>
<div style="padding:0 18px">
<button class="btn-cerca" id="bVerCerca">Ver de cerca en el mapa</button>
<button class="btn-cerca" id="bVerCerca" data-i18n="seeclose">Ver de cerca en el mapa</button>
</div>
<div class="links-ttl">Abrir en otros mapas</div>
<div class="links" id="dLinks"></div>
</aside>
</div>
@ -397,6 +422,7 @@ body.cerca.sinsel #nosel{display:block}
<script>
const DATA = __DATA__;
const NT = __N__;
const NCAND = "__N_CAND__";
// ---- desempaquetado ----
const K = {}; DATA.keys.forEach((k,i)=>K[k]=i);
@ -406,9 +432,109 @@ const TER = new Uint8Array(terrainRaw.length);
for(let i=0;i<terrainRaw.length;i++) TER[i]=terrainRaw.charCodeAt(i);
const TSIZE = NT*NT;
// Los nombres vienen de OpenStreetMap y de los ficheros oficiales, o sea de
// terceros: cualquiera puede editar el nombre de un municipio en OSM. Todo lo
// que salga de los datos y acabe en innerHTML pasa por aquí antes.
const ESCMAP={'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'};
const esc = s => String(s==null?'':s).replace(/[&<>"']/g, ch => ESCMAP[ch]);
const SCOL = ["","#8a7a4a","#a8923f","#c9a92f","#e6c235","#ffd95e"];
const stars = n => "".repeat(n)+"".repeat(5-n);
// ---- idiomas ----
// El proyecto sirve para cualquier país del catálogo, así que la interfaz no
// puede estar solo en castellano. Las cadenas de datos (municipios, comunidades)
// vienen del país y no se traducen.
const I18N = {
title:{es:"Localizaciones",en:"Locations",fr:"Emplacements",pt:"Localizações"},
minstars:{es:"Estrellas mínimas",en:"Minimum stars",fr:"Étoiles minimum",pt:"Estrelas mínimas"},
region:{es:"Comunidad",en:"Region",fr:"Région",pt:"Região"},
all:{es:"todas",en:"all",fr:"toutes",pt:"todas"},
fsound:{es:"Aislamiento sonoro mínimo",en:"Min. sound isolation",fr:"Isolement sonore min.",pt:"Isolamento sonoro mín."},
fwater:{es:"Agua cerca mínimo",en:"Min. water nearby",fr:"Eau à proximité min.",pt:"Água perto mín."},
ftrees:{es:"Arbolado mínimo",en:"Min. tree cover",fr:"Couvert arboré min.",pt:"Arvoredo mínimo"},
faccess:{es:"Acceso mínimo",en:"Min. access",fr:"Accès minimum",pt:"Acesso mínimo"},
mrelief:{es:"Relieve",en:"Relief",fr:"Relief",pt:"Relevo"},
mmap:{es:"Mapa",en:"Map",fr:"Carte",pt:"Mapa"},
mclose:{es:"Ver de cerca",en:"Zoom in",fr:"Vue rapprochée",pt:"Ver de perto"},
lshade:{es:"Sombreado",en:"Hillshade",fr:"Ombrage",pt:"Sombreado"},
liso:{es:"Aislamiento",en:"Isolation",fr:"Isolement",pt:"Isolamento"},
lprot:{es:"Protegidos",en:"Protected",fr:"Protégés",pt:"Protegidos"},
lfit:{es:"Encajar",en:"Fit",fr:"Ajuster",pt:"Ajustar"},
base:{es:"Fondo",en:"Base map",fr:"Fond",pt:"Fundo"},
over:{es:"Encima",en:"Overlays",fr:"Calques",pt:"Camadas"},
bsat:{es:"Satélite (PNOA)",en:"Satellite (PNOA)",fr:"Satellite (PNOA)",pt:"Satélite (PNOA)"},
btopo:{es:"Topográfico (MTN)",en:"Topographic (MTN)",fr:"Topographique (MTN)",pt:"Topográfico (MTN)"},
bosm:{es:"OpenStreetMap",en:"OpenStreetMap",fr:"OpenStreetMap",pt:"OpenStreetMap"},
ocad:{es:"Parcelas del catastro",en:"Cadastral parcels",fr:"Parcelles cadastrales",pt:"Parcelas cadastrais"},
on2k:{es:"Red Natura 2000",en:"Natura 2000 network",fr:"Réseau Natura 2000",pt:"Rede Natura 2000"},
oenp:{es:"Espacios protegidos",en:"Protected areas",fr:"Espaces protégés",pt:"Áreas protegidas"},
opts:{es:"Otras localizaciones",en:"Other locations",fr:"Autres emplacements",pt:"Outras localizações"},
tabaer:{es:"Vista aérea",en:"Aerial view",fr:"Vue aérienne",pt:"Vista aérea"},
tab3d:{es:"Relieve 3D",en:"3D relief",fr:"Relief 3D",pt:"Relevo 3D"},
seeclose:{es:"Ver de cerca en el mapa",en:"Zoom in on the map",fr:"Voir de près sur la carte",pt:"Ver de perto no mapa"},
nosel:{es:"Elige una localización en la lista para verla de cerca",en:"Pick a location from the list to zoom in",fr:"Choisissez un emplacement dans la liste",pt:"Escolhe uma localização na lista"},
sound:{es:"Sonido",en:"Sound",fr:"Son",pt:"Som"},
solitude:{es:"Soledad",en:"Solitude",fr:"Solitude",pt:"Solidão"},
access:{es:"Acceso",en:"Access",fr:"Accès",pt:"Acesso"},
water:{es:"Agua",en:"Water",fr:"Eau",pt:"Água"},
trees:{es:"Arbolado",en:"Tree cover",fr:"Couvert arboré",pt:"Arvoredo"},
climate:{es:"Clima",en:"Climate",fr:"Climat",pt:"Clima"},
fdb:{es:"Nivel en la casa más cercana",en:"Level at nearest dwelling",fr:"Niveau à la maison la plus proche",pt:"Nível na casa mais próxima"},
fbuild:{es:"Edificio más cercano",en:"Nearest building",fr:"Bâtiment le plus proche",pt:"Edifício mais próximo"},
froad:{es:"Carretera más cercana",en:"Nearest road",fr:"Route la plus proche",pt:"Estrada mais próxima"},
ftrack:{es:"Pista de acceso",en:"Access track",fr:"Piste d'accès",pt:"Pista de acesso"},
fwat:{es:"Agua más cercana",en:"Nearest water",fr:"Eau la plus proche",pt:"Água mais próxima"},
ftree:{es:"Arbolado alrededor",en:"Tree cover around",fr:"Couvert arboré autour",pt:"Arvoredo à volta"},
frock:{es:"Roca desnuda",en:"Bare rock",fr:"Roche nue",pt:"Rocha nua"},
fslope:{es:"Pendiente",en:"Slope",fr:"Pente",pt:"Declive"},
ftpi:{es:"Hondonada (TPI)",en:"Hollow (TPI)",fr:"Cuvette (TPI)",pt:"Depressão (TPI)"},
ftmax:{es:"Máx. mes más cálido",en:"Max. warmest month",fr:"Max. mois le plus chaud",pt:"Máx. mês mais quente"},
ftmin:{es:"Mín. mes más frío",en:"Min. coldest month",fr:"Min. mois le plus froid",pt:"Mín. mês mais frio"},
far:{es:"lejos",en:"far",fr:"loin",pt:"longe"},
locs:{es:"localizaciones",en:"locations",fr:"emplacements",pt:"localizações"},
loc1:{es:"localización",en:"location",fr:"emplacement",pt:"localização"},
more:{es:"más (afina los filtros)",en:"more (narrow the filters)",fr:"de plus (affinez les filtres)",pt:"mais (afina os filtros)"},
pick:{es:"elige una localización para abrirla en otros mapas",en:"pick a location to open it in other maps",fr:"choisissez un emplacement pour l'ouvrir ailleurs",pt:"escolhe uma localização para abrir noutros mapas"},
lsat:{es:"Satélite",en:"Satellite",fr:"Satellite",pt:"Satélite"},
lsatd:{es:"vista aérea",en:"aerial view",fr:"vue aérienne",pt:"vista aérea"},
lsvd:{es:"entrada desde el asfalto",en:"the turn-off from the road",fr:"l'entrée depuis la route",pt:"entrada a partir do asfalto"},
lmpd:{es:"fotos de pista",en:"track-level photos",fr:"photos de piste",pt:"fotos de pista"},
losmd:{es:"caminos y topónimos",en:"tracks and place names",fr:"chemins et toponymes",pt:"caminhos e topónimos"},
lcad:{es:"Catastro",en:"Cadastre",fr:"Cadastre",pt:"Cadastro"},
lcadd:{es:"de quién es la finca",en:"who owns the land",fr:"à qui appartient le terrain",pt:"de quem é o terreno"},
lgo:{es:"Cómo llegar",en:"Directions",fr:"Itinéraire",pt:"Como chegar"},
lgod:{es:"ruta desde donde estés",en:"route from where you are",fr:"itinéraire depuis chez vous",pt:"rota de onde estiveres"},
aerhint:{es:"ortofoto · 640 m de lado · pulsa para ampliar",en:"orthophoto · 640 m across · tap to zoom",fr:"orthophoto · 640 m de côté · cliquez pour agrandir",pt:"ortofoto · 640 m de lado · toca para ampliar"},
relhint:{es:"relieve 9,6 × 9,6 km · arrastra para girar",en:"relief 9.6 × 9.6 km · drag to rotate",fr:"relief 9,6 × 9,6 km · faites glisser pour tourner",pt:"relevo 9,6 × 9,6 km · arrasta para girar"},
protnear:{es:"Espacio protegido a",en:"Protected area at",fr:"Espace protégé à",pt:"Área protegida a"},
protwarn:{es:"Está fuera, pero comprueba el límite exacto en el visor oficial antes de ir.",en:"It is outside, but check the exact boundary on the official viewer before going.",fr:"Il est en dehors, mais vérifiez la limite exacte avant d'y aller.",pt:"Está fora, mas confirma o limite exato antes de ir."},
grid:{es:"malla de 100 m",en:"100 m grid",fr:"grille de 100 m",pt:"malha de 100 m"},
legprot:{es:"espacio protegido",en:"protected area",fr:"espace protégé",pt:"área protegida"},
noprot:{es:"Sin espacio protegido cartografiado en 12 km. Aun así, comprueba la propiedad del terreno: casi todo esto es finca privada.",en:"No mapped protected area within 12 km. Even so, check land ownership: nearly all of this is private.",fr:"Aucun espace protégé cartographié à moins de 12 km. Vérifiez tout de même la propriété du terrain.",pt:"Sem área protegida cartografada em 12 km. Ainda assim, confirma a propriedade do terreno."},
};
let LANG = localStorage.getItem('rs_lang') ||
(navigator.language||'es').slice(0,2);
if(!['es','en','fr','pt'].includes(LANG)) LANG='es';
const t = k => (I18N[k] && (I18N[k][LANG] || I18N[k].es)) || k;
function applyLang(){
document.documentElement.lang = LANG;
document.querySelectorAll('[data-i18n]').forEach(e=>{
e.textContent = t(e.dataset.i18n);
});
el('lang').value = LANG;
el('subtxt').textContent = NCAND + ' ' + t('locs') + ' · ' + t('grid');
el('legend').innerHTML = [5,4,3,2,1].map(s=>
`<span><i style="background:${SCOL[s]}"></i>${s}</span>`).join("")+
`<span style="color:var(--muted);border-left:1px solid var(--line);padding-left:12px">`+
`<i style="background:#c43f2f;opacity:.75"></i>${t('legprot')}</span>`;
if(selected) select(selected); else renderMapLinks(null);
apply();
setTab(document.getElementById('detail').classList.contains('t3d'));
}
// ---- imágenes ----
const layers = {};
function loadImg(name, b64){
@ -495,16 +621,15 @@ const el = id => document.getElementById(id);
let minStars = 1;
function apply(){
const reg = el('reg').value;
const mad = +el('fmad').value, son=+el('fson').value, agu=+el('fagu').value,
const son=+el('fson').value, agu=+el('fagu').value,
arb=+el('farb').value, acc=+el('facc').value;
visible = C.filter(c =>
c[K.estrellas] >= minStars &&
(!reg || c[K.comunidad]===reg) &&
(mad>=700 || c[K.km_madrid]<=mad) &&
c[K.c_sonido]>=son && c[K.c_agua]>=agu &&
c[K.c_arbolado]>=arb && c[K.c_acceso]>=acc);
visible.sort((a,b)=>b[K.score]-a[K.score]);
el('cnt').textContent = visible.length + (visible.length===1?" localización":" localizaciones");
el('cnt').textContent = visible.length + " " + (visible.length===1?t("loc1"):t("locs"));
const n5 = visible.filter(c=>c[K.estrellas]===5).length;
el('cnt2').textContent = n5 ? n5+" de 5★" : "";
renderList(); draw();
@ -518,7 +643,7 @@ function renderList(){
d.className='item'; d.setAttribute('role','option');
d.setAttribute('aria-selected', selected===c ? 'true':'false');
d.innerHTML = `<span class="st" style="color:${SCOL[c[K.estrellas]]}">${"".repeat(c[K.estrellas])}</span>`+
`<span class="nm">${c[K.municipio]||''}</span>`+
`<span class="nm">${esc(c[K.municipio])||''}</span>`+
`<span class="sc mono">${c[K.score].toFixed(0)}</span>`;
d.onclick = ()=>select(c);
frag.appendChild(d);
@ -527,14 +652,14 @@ function renderList(){
if(visible.length>400){
const m=document.createElement('div');
m.className='item'; m.style.color='var(--muted)';
m.innerHTML=`<span class="nm"> y ${visible.length-400} más (afina los filtros)</span>`;
m.innerHTML=`<span class="nm"> ${visible.length-400} ${t("more")}</span>`;
box.appendChild(m);
}
}
// ---- panel de detalle ----
const CRITS = [["c_sonido","Sonido"],["c_soledad","Soledad"],["c_acceso","Acceso"],
["c_agua","Agua"],["c_arbolado","Arbolado"],["c_clima","Clima"]];
const CRITS = [["c_sonido","sound"],["c_soledad","solitude"],["c_acceso","access"],
["c_agua","water"],["c_arbolado","trees"],["c_clima","climate"]];
function select(c){
selected = c;
el('detail').classList.add('open');
@ -545,66 +670,52 @@ function select(c){
el('dCrits').innerHTML = CRITS.map(([k,lab])=>{
const v = c[K[k]];
return `<div class="crit"><span style="color:var(--ink2)">${lab}</span>`+
return `<div class="crit"><span style="color:var(--ink2)">${t(lab)}</span>`+
`<span class="bar"><i style="width:${v}%"></i></span>`+
`<span class="v mono">${v}</span></div>`;
}).join("");
const f = [
["Nivel en la casa más cercana", c[K.db_en_casa].toFixed(0)+" dB"],
["Edificio más cercano", (c[K.d_edificio]/1000).toFixed(1)+" km"],
["Carretera más cercana", (c[K.d_carretera]/1000).toFixed(1)+" km"],
["Pista de acceso", c[K.d_pista]+" m"],
["Agua más cercana", c[K.d_agua]<10000 ? (c[K.d_agua]/1000).toFixed(1)+" km" : "lejos"],
["Arbolado alrededor", c[K.arbolado_pct]+" %"],
["Roca desnuda", c[K.roca_pct]+" %"],
["Pendiente", c[K.pendiente]+"°"],
["Hondonada (TPI)", c[K.tpi]+" m"],
["Máx. mes más cálido", c[K.tmax].toFixed(0)+" °C"],
["Mín. mes más frío", c[K.tmin].toFixed(0)+" °C"],
["Desde Madrid", c[K.km_madrid]+" km en línea recta"],
[t("fdb"), c[K.db_en_casa].toFixed(0)+" dB"],
[t("fbuild"), (c[K.d_edificio]/1000).toFixed(1)+" km"],
[t("froad"), (c[K.d_carretera]/1000).toFixed(1)+" km"],
[t("ftrack"), c[K.d_pista]+" m"],
[t("fwat"), c[K.d_agua]<10000 ? (c[K.d_agua]/1000).toFixed(1)+" km" : t("far")],
[t("ftree"), c[K.arbolado_pct]+" %"],
[t("frock"), c[K.roca_pct]+" %"],
[t("fslope"), c[K.pendiente]+"°"],
[t("ftpi"), c[K.tpi]+" m"],
[t("ftmax"), c[K.tmax].toFixed(0)+" °C"],
[t("ftmin"), c[K.tmin].toFixed(0)+" °C"],
];
el('dFacts').innerHTML = f.map(([a,b])=>`<div class="fact"><span>${a}</span><b>${b}</b></div>`).join("");
const pk = c[K.protegido_cerca], pkm = c[K.km_protegido];
el('dWarn').innerHTML = pk
? `<div class="warn"><b>Espacio protegido a ${pkm} km:</b> ${pk}.
Está fuera, pero comprueba el límite exacto en el visor oficial antes de ir.</div>`
: `<div class="warn">Sin espacio protegido cartografiado en 12 km. Aun así,
comprueba la propiedad del terreno: casi todo esto es finca privada.</div>`;
? `<div class="warn"><b>${t('protnear')} ${pkm} km:</b> ${esc(pk)}. ${t('protwarn')}</div>`
: `<div class="warn">${t('noprot')}</div>`;
const la=c[K.lat], lo=c[K.lon];
const lnk=(href,t,s)=>`<a href="${href}" target="_blank" rel="noopener">`+
`<b>${t}</b><span>${s}</span></a>`;
el('dLinks').innerHTML =
lnk(`https://www.google.com/maps/@${la},${lo},1500m/data=!3m1!1e3`,
'Google satélite','vista aérea y relieve') +
lnk(`https://www.google.com/maps/@?api=1&map_action=pano&viewpoint=${la},${lo}`,
'Street View','la entrada desde el asfalto') +
lnk(`https://www.mapillary.com/app/?lat=${la}&lng=${lo}&z=15`,
'Mapillary','fotos de pista, si las hay') +
lnk(`https://www.openstreetmap.org/#map=16/${la}/${lo}`,
'OpenStreetMap','caminos y topónimos') +
lnk(`https://www1.sedecatastro.gob.es/CYCBienInmueble/OVCListaBienes.aspx?`+
`del=0&mun=0&RCCompleta=&latitud=${la}&longitud=${lo}`,
'Catastro','de quién es la finca') +
lnk(`https://www.google.com/maps/dir/?api=1&destination=${la},${lo}`,
'Cómo llegar','ruta desde donde estés');
renderMapLinks(c);
// La foto aérea se carga solo al seleccionar, no las 355 de golpe.
el('ortho').src = 'ortho/' + c[K.id] + '.jpg';
if(document.getElementById('detail').classList.contains('t3d')) drawTerrain(c);
else curTile = c;
// Si estamos en el mapa de cerca, que siga a la localización elegida.
if(L2 && document.body.classList.contains('cerca')){
// La marca siempre se mueve; recentrar solo en el modo de cerca. En el modo
// Mapa se está mirando el país entero y no tiene sentido hacer zoom cada vez
// que se pincha un punto.
if(L2){
document.body.classList.remove('sinsel');
L2.setView([c[K.lat],c[K.lon]], Math.max(L2.getZoom(),15));
selMarker.setLatLng([c[K.lat],c[K.lon]]).setStyle({opacity:1,fillOpacity:.28});
selMarker.bringToFront();
if(el('bCerca').getAttribute('aria-pressed') === 'true'){
L2.setView([c[K.lat],c[K.lon]], Math.max(L2.getZoom(),15));
}
}
renderList(); draw();
}
el('close').onclick = ()=>{el('detail').classList.remove('open'); selected=null; renderList(); draw();};
el('close').onclick = ()=>{el('detail').classList.remove('open'); selected=null;
renderMapLinks(null); renderList(); draw();};
// ---- terreno 3D (canvas 2D, algoritmo del pintor) ----
const tc = document.getElementById('tcanvas'), tx = tc.getContext('2d');
@ -665,6 +776,34 @@ function drawTerrain(c){
tx.strokeStyle="#ff7a45"; tx.lineWidth=1.5;
tx.beginPath(); tx.moveTo(cP[0],cP[1]-5); tx.lineTo(cP[0],cP[1]); tx.stroke();
}
// enlaces a mapas externos, en la barra inferior y no dentro de la ficha
function renderMapLinks(c){
const box = el('mapLinks');
if(!c){
box.innerHTML = '<span class="ml-hint">'+t('pick')+'</span>';
return;
}
const la=c[K.lat], lo=c[K.lon];
const lnk=(href,t,s)=>`<a href="${href}" target="_blank" rel="noopener" title="${s}">`+
`<b>${t}</b><span>${s}</span></a>`;
box.innerHTML =
`<span class="ml-sel"><b>${esc(c[K.municipio])||''}</b>`+
`<span style="color:${SCOL[c[K.estrellas]]}">${''.repeat(c[K.estrellas])}</span></span>` +
lnk(`https://www.google.com/maps/@${la},${lo},1500m/data=!3m1!1e3`,
t('lsat'),t('lsatd')) +
lnk(`https://www.google.com/maps/@?api=1&map_action=pano&viewpoint=${la},${lo}`,
'Street View',t('lsvd')) +
lnk(`https://www.mapillary.com/app/?lat=${la}&lng=${lo}&z=15`,
'Mapillary',t('lmpd')) +
lnk(`https://www.openstreetmap.org/#map=16/${la}/${lo}`,
'OSM',t('losmd')) +
lnk(`https://www1.sedecatastro.gob.es/CYCBienInmueble/OVCListaBienes.aspx?`+
`del=0&mun=0&RCCompleta=&latitud=${la}&longitud=${lo}`,
t('lcad'),t('lcadd')) +
lnk(`https://www.google.com/maps/dir/?api=1&destination=${la},${lo}`,
t('lgo'),t('lgod'));
}
// pestañas del panel: la vista aérea es lo primero que se ve, y el 3D solo se
// dibuja si se pide, que es lo caro
function setTab(t3d){
@ -672,9 +811,7 @@ function setTab(t3d){
d.classList.toggle('t3d', t3d);
el('tabSat').setAttribute('aria-pressed', String(!t3d));
el('tab3d').setAttribute('aria-pressed', String(t3d));
el('tHint').textContent = t3d
? 'relieve 9,6 × 9,6 km · arrastra para girar'
: 'ortofoto PNOA · 640 m de lado · pulsa para ampliar';
el('tHint').textContent = t3d ? t('relhint') : t('aerhint');
el('tRange').style.display = t3d ? '' : 'none';
if(t3d && curTile) drawTerrain(curTile);
}
@ -731,7 +868,7 @@ function initLeaf(){
const m = L.circleMarker([c[K.lat],c[K.lon]],{
radius: c[K.estrellas]>=4?6:4, color:'#0a0d12', weight:1.5,
fillColor:SCOL[c[K.estrellas]], fillOpacity:.95});
m.bindTooltip(`${''.repeat(c[K.estrellas])} ${c[K.municipio]||''}`,{direction:'top'});
m.bindTooltip(`${''.repeat(c[K.estrellas])} ${esc(c[K.municipio])||''}`,{direction:'top'});
m.on('click',()=>select(c));
ptsLayer.addLayer(m);
}
@ -744,10 +881,31 @@ function initLeaf(){
selMarker.setStyle({opacity:0,fillOpacity:0});
}
function setMode(m){ // 'esp' | 'mapa' | 'cerca'
document.body.classList.toggle('cerca', m !== 'esp');
el('bEsp').setAttribute('aria-pressed', String(m === 'esp'));
el('bMapa').setAttribute('aria-pressed', String(m === 'mapa'));
el('bCerca').setAttribute('aria-pressed', String(m === 'cerca'));
}
// Encaja todo el país sobre el mapa real. Los límites salen de los propios
// candidatos con margen, no de un rectángulo fijo.
function spainBounds(){
let a=90,b=180,c2=-90,d=-180;
for(const c of C){ a=Math.min(a,c[K.lat]); c2=Math.max(c2,c[K.lat]);
b=Math.min(b,c[K.lon]); d=Math.max(d,c[K.lon]); }
return L.latLngBounds([a-0.5,b-0.7],[c2+0.5,d+0.7]);
}
function goMapa(){
setMode('mapa');
initLeaf();
document.body.classList.remove('sinsel');
setTimeout(()=>{ L2.invalidateSize(); L2.fitBounds(spainBounds()); }, 30);
}
function goCerca(c){
document.body.classList.add('cerca');
el('bEsp').setAttribute('aria-pressed','false');
el('bCerca').setAttribute('aria-pressed','true');
setMode('cerca');
initLeaf();
document.body.classList.toggle('sinsel', !c);
setTimeout(()=>{
@ -756,18 +914,20 @@ function goCerca(c){
L2.setView([c[K.lat],c[K.lon]], 16);
selMarker.setLatLng([c[K.lat],c[K.lon]]).setStyle({opacity:1,fillOpacity:.28});
selMarker.bringToFront();
} else {
L2.fitBounds(spainBounds());
}
},30);
}
function goEspana(){
document.body.classList.remove('cerca');
el('bEsp').setAttribute('aria-pressed','true');
el('bCerca').setAttribute('aria-pressed','false');
setMode('esp');
resize();
}
el('bCerca').onclick=()=>goCerca(selected);
el('bMapa').onclick=goMapa;
el('bEsp').onclick=goEspana;
el('bVerCerca').onclick=()=>{ if(selected) goCerca(selected); };
el('bVerCercaTop').onclick=()=>{ if(selected) goCerca(selected); };
document.querySelectorAll('input[name=base]').forEach(r=>{
r.onchange=()=>{ if(!L2) return;
@ -793,8 +953,7 @@ document.querySelectorAll('#starf button').forEach(b=>{
o.setAttribute('aria-pressed', o===b ? 'true':'false'));
apply(); };
});
const sliders = [['fmad','vmad',v=>v>=700?'sin límite':v+' km'],
['fson','vson',v=>v], ['fagu','vagu',v=>v],
const sliders = [['fson','vson',v=>v], ['fagu','vagu',v=>v],
['farb','varb',v=>v], ['facc','vacc',v=>v]];
sliders.forEach(([id,out,fmt])=>{
const i=el(id); i.oninput=()=>{ el(out).textContent=fmt(+i.value); apply(); };
@ -819,7 +978,10 @@ el('legend').innerHTML = [5,4,3,2,1].map(s=>
Promise.all([loadImg('rel',"__RELIEF__"),loadImg('prot',"__PROT__"),loadImg('iso',"__ISO__")])
.then(()=>{ resize(); fit(); apply(); });
apply(); resize();
el('lang').onchange=()=>{ LANG=el('lang').value;
localStorage.setItem('rs_lang',LANG); applyLang(); };
renderMapLinks(null);
apply(); resize(); applyLang();
</script>
</body>
</html>

View file

@ -7,26 +7,39 @@ que es justo lo que necesitamos para "a cuántos metros está la casa más cerca
Cubrimos península + Baleares. Canarias queda fuera: en 3035 se deforma mucho y
además no es un destino al que se llegue conduciendo.
"""
import os
import sys
from pathlib import Path
import numpy as np
from pyproj import CRS, Transformer
sys.path.insert(0, str(Path(__file__).resolve().parent))
import regions
# El país se elige con la variable de entorno REGION. Por defecto España, que es
# la región con la que se construyó el proyecto y la única con todos los datos
# precalculados.
CODE = os.environ.get("REGION", regions.DEFAULT).lower()
REGION = regions.get(CODE)
IS_DEFAULT = CODE == regions.DEFAULT
ROOT = Path(__file__).resolve().parent.parent
RAW = ROOT / "data" / "raw"
INTERIM = ROOT / "data" / "interim"
OUT = ROOT / "out"
# La región por defecto publica en la raíz, para no romper rutas ya
# documentadas; las demás van a su propio subdirectorio.
INTERIM = ROOT / "data" / "interim" if IS_DEFAULT else ROOT / "data" / "interim" / CODE
OUT = ROOT / "out" if IS_DEFAULT else ROOT / "out" / CODE
for _d in (RAW, INTERIM, OUT):
_d.mkdir(parents=True, exist_ok=True)
PBF = RAW / "spain-latest.osm.pbf"
PBF = RAW / (REGION["osm"][0].rsplit("/", 1)[-1] + "-latest.osm.pbf")
# Ventana geográfica: península + Baleares, con un pequeño margen.
LON_MIN, LON_MAX = -9.60, 4.40
LAT_MIN, LAT_MAX = 35.85, 43.90
# Ventana geográfica de la región, con un pequeño margen.
LON_MIN, LAT_MIN, LON_MAX, LAT_MAX = REGION["bbox"]
CRS_WGS84 = CRS.from_epsg(4326)
CRS_GRID = CRS.from_epsg(3035)
CRS_GRID = CRS.from_user_input(regions.crs_for(CODE))
to_grid = Transformer.from_crs(CRS_WGS84, CRS_GRID, always_xy=True).transform
to_wgs = Transformer.from_crs(CRS_GRID, CRS_WGS84, always_xy=True).transform

View file

@ -3,7 +3,8 @@
# El bucket es público (open data en AWS), no hace falta autenticación.
# Las teselas que caen enteras en el mar no existen y devuelven 404: se ignoran.
set -u
DEST=/home/sito/RAVE_SCOUT/data/raw/dem
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
DEST="$ROOT/data/raw/dem"
mkdir -p "$DEST"
BASE=https://copernicus-dem-90m.s3.amazonaws.com

191
src/regions.py Normal file
View file

@ -0,0 +1,191 @@
"""Catálogo de regiones. El país es un parámetro, no algo cableado.
Se procesa **una malla por país**, no una malla continental. Europa entera a
100 m son mil millones de celdas: 4 GB por capa y unos 73 GB para las dieciocho
del modelo, más de lo que cabe en memoria de una tacada. Por país el problema es
tratable y además paralelizable.
Uso:
REGION=pt python src/extract_osm.py
REGION=pt make all
Cada entrada declara qué hay disponible de verdad para ese país, porque no todo
existe en todas partes:
protegidos 'eea' Red Natura 2000 + designaciones nacionales, vía la Agencia
Europea de Medio Ambiente. Cubre la UE y el EEE.
None No hay fuente libre equivalente. El filtro legal NO
funciona: hay que conseguir la cartografía del país antes
de fiarse de un resultado.
ortofoto 'pnoa' Ortofoto nacional descargable (solo España, IGN).
None Sin foto aérea incrustada; en el visor queda el mapa
deslizante, que funciona en todas partes.
"""
# code: (nombre, bbox lon/lat, extracto(s) de Geofabrik, vecinos para no inflar
# el aislamiento en la frontera, protegidos, ortofoto)
REGIONS = {
# ---------------- Península Ibérica ----------------
"es": dict(name="España", bbox=(-9.60, 35.85, 4.40, 43.90),
osm=["europe/spain"],
neighbours=["europe/portugal", "europe/andorra",
"europe/france/aquitaine", "europe/france/midi-pyrenees",
"europe/france/languedoc-roussillon"],
protected="eea", ortho="pnoa", default=True),
"pt": dict(name="Portugal", bbox=(-9.60, 36.90, -6.10, 42.20),
osm=["europe/portugal"], neighbours=["europe/spain"],
protected="eea", ortho=None),
"ad": dict(name="Andorra", bbox=(1.40, 42.42, 1.79, 42.66),
osm=["europe/andorra"],
neighbours=["europe/spain", "europe/france/midi-pyrenees"],
protected="eea", ortho=None),
# ---------------- Resto de Europa occidental ----------------
"fr": dict(name="Francia", bbox=(-5.20, 41.30, 9.60, 51.10),
osm=["europe/france"],
neighbours=["europe/spain", "europe/italy", "europe/germany",
"europe/belgium", "europe/switzerland"],
protected="eea", ortho=None),
"it": dict(name="Italia", bbox=(6.60, 35.40, 18.60, 47.10),
osm=["europe/italy"],
neighbours=["europe/france", "europe/switzerland", "europe/austria",
"europe/slovenia"],
protected="eea", ortho=None),
"de": dict(name="Alemania", bbox=(5.80, 47.20, 15.10, 55.10),
osm=["europe/germany"],
neighbours=["europe/france", "europe/poland", "europe/czech-republic",
"europe/austria", "europe/netherlands", "europe/belgium"],
protected="eea", ortho=None),
"gb": dict(name="Reino Unido", bbox=(-8.70, 49.80, 1.80, 61.00),
osm=["europe/great-britain"],
neighbours=["europe/ireland-and-northern-ireland"],
protected="eea", ortho=None),
"ie": dict(name="Irlanda", bbox=(-10.70, 51.30, -5.30, 55.50),
osm=["europe/ireland-and-northern-ireland"], neighbours=[],
protected="eea", ortho=None),
"ch": dict(name="Suiza", bbox=(5.90, 45.80, 10.60, 47.90),
osm=["europe/switzerland"],
neighbours=["europe/france", "europe/italy", "europe/germany",
"europe/austria"],
protected="eea", ortho=None),
"at": dict(name="Austria", bbox=(9.50, 46.30, 17.20, 49.10),
osm=["europe/austria"],
neighbours=["europe/germany", "europe/italy", "europe/switzerland",
"europe/czech-republic", "europe/slovakia", "europe/hungary",
"europe/slovenia"],
protected="eea", ortho=None),
"nl": dict(name="Países Bajos", bbox=(3.30, 50.70, 7.30, 53.60),
osm=["europe/netherlands"],
neighbours=["europe/germany", "europe/belgium"],
protected="eea", ortho=None),
"be": dict(name="Bélgica", bbox=(2.50, 49.40, 6.50, 51.60),
osm=["europe/belgium"],
neighbours=["europe/france", "europe/germany", "europe/netherlands",
"europe/luxembourg"],
protected="eea", ortho=None),
# ---------------- Norte y este de Europa ----------------
"no": dict(name="Noruega", bbox=(4.00, 57.90, 31.20, 71.30),
osm=["europe/norway"], neighbours=["europe/sweden", "europe/finland"],
protected="eea", ortho=None),
"se": dict(name="Suecia", bbox=(10.90, 55.30, 24.20, 69.10),
osm=["europe/sweden"], neighbours=["europe/norway", "europe/finland"],
protected="eea", ortho=None),
"fi": dict(name="Finlandia", bbox=(19.10, 59.70, 31.60, 70.10),
osm=["europe/finland"], neighbours=["europe/sweden", "europe/norway"],
protected="eea", ortho=None),
"is": dict(name="Islandia", bbox=(-24.60, 63.20, -13.40, 66.60),
osm=["europe/iceland"], neighbours=[], protected="eea", ortho=None),
"pl": dict(name="Polonia", bbox=(14.10, 49.00, 24.20, 54.90),
osm=["europe/poland"],
neighbours=["europe/germany", "europe/czech-republic",
"europe/slovakia"],
protected="eea", ortho=None),
"cz": dict(name="Chequia", bbox=(12.10, 48.50, 18.90, 51.10),
osm=["europe/czech-republic"],
neighbours=["europe/germany", "europe/poland", "europe/slovakia",
"europe/austria"],
protected="eea", ortho=None),
"gr": dict(name="Grecia", bbox=(19.30, 34.70, 28.30, 41.80),
osm=["europe/greece"], neighbours=["europe/albania"],
protected="eea", ortho=None),
"ro": dict(name="Rumanía", bbox=(20.20, 43.60, 29.70, 48.30),
osm=["europe/romania"], neighbours=["europe/bulgaria", "europe/hungary"],
protected="eea", ortho=None),
"hr": dict(name="Croacia", bbox=(13.40, 42.30, 19.50, 46.60),
osm=["europe/croatia"],
neighbours=["europe/slovenia", "europe/bosnia-herzegovina"],
protected="eea", ortho=None),
# ---------------- Norte de África ----------------
# Sin fuente libre de espacios protegidos equivalente a la europea: el
# filtro legal queda desactivado y hay que conseguir la cartografía del
# país antes de dar por bueno ningún resultado.
"ma": dict(name="Marruecos", bbox=(-13.30, 27.60, -0.90, 36.00),
osm=["africa/morocco"], neighbours=["africa/algeria"],
protected=None, ortho=None),
"dz": dict(name="Argelia", bbox=(-8.70, 18.90, 12.00, 37.10),
osm=["africa/algeria"],
neighbours=["africa/morocco", "africa/tunisia", "africa/libya"],
protected=None, ortho=None),
"tn": dict(name="Túnez", bbox=(7.50, 30.20, 11.60, 37.60),
osm=["africa/tunisia"], neighbours=["africa/algeria", "africa/libya"],
protected=None, ortho=None),
"ly": dict(name="Libia", bbox=(9.30, 19.50, 25.20, 33.20),
osm=["africa/libya"], neighbours=["africa/tunisia", "africa/egypt"],
protected=None, ortho=None),
"eg": dict(name="Egipto", bbox=(24.60, 21.90, 37.00, 31.70),
osm=["africa/egypt"], neighbours=["africa/libya"],
protected=None, ortho=None),
"mr": dict(name="Mauritania", bbox=(-17.10, 14.70, -4.80, 27.30),
osm=["africa/mauritania"], neighbours=["africa/morocco"],
protected=None, ortho=None),
}
# EPSG:3035 (LAEA Europa) solo es válida en Europa: fuera deforma. Para las
# regiones de África se construye una LAEA centrada en la propia región, que es
# equiárea allí donde se usa. Europa mantiene 3035 para no invalidar la malla
# con la que ya está calculada España.
AFRICA = {"ma", "dz", "tn", "ly", "eg", "mr"}
def crs_for(code):
r = get(code)
if code not in AFRICA:
return "EPSG:3035"
lon0 = (r["bbox"][0] + r["bbox"][2]) / 2
lat0 = (r["bbox"][1] + r["bbox"][3]) / 2
return (f"+proj=laea +lat_0={lat0:.4f} +lon_0={lon0:.4f} "
"+x_0=4321000 +y_0=3210000 +datum=WGS84 +units=m +no_defs")
DEFAULT = "es"
GEOFABRIK = "https://download.geofabrik.de/{path}-latest.osm.pbf"
def get(code):
code = (code or DEFAULT).lower()
if code not in REGIONS:
raise SystemExit(
f"Región '{code}' desconocida. Disponibles: "
+ ", ".join(sorted(REGIONS)))
return REGIONS[code]
def urls(code):
"""URLs de Geofabrik del país y de sus vecinos."""
r = get(code)
return ([GEOFABRIK.format(path=p) for p in r["osm"]],
[GEOFABRIK.format(path=p) for p in r["neighbours"]])
if __name__ == "__main__":
print(f"{'código':8s}{'país':18s}{'protegidos':12s}{'ortofoto':10s}bbox")
print("-" * 76)
for k, v in sorted(REGIONS.items(), key=lambda kv: kv[1]["name"]):
p = v["protected"] or "NO HAY"
o = v["ortho"] or ""
b = v["bbox"]
print(f"{k:8s}{v['name'][:17]:18s}{p:12s}{o:10s}"
f"{b[0]:.1f},{b[1]:.1f} .. {b[2]:.1f},{b[3]:.1f}")
print(f"\n{len(REGIONS)} regiones. Por defecto: {DEFAULT}")

View file

@ -46,7 +46,6 @@ def main():
<td class="nu">{km(r['d_agua'])}</td>
<td class="nu">{esc(r['arbolado_pct'])}</td>
<td class="nu">{km(r['d_protegido'])}</td>
<td class="nu">{esc(r['km_madrid'])}</td>
<td class="lk"><a href="{gm}" target="_blank" rel="noopener">sat</a>
<a href="{sv}" target="_blank" rel="noopener">sv</a></td>
</tr>""")
@ -355,7 +354,7 @@ code.inline {{ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monos
<thead><tr>
<th>#</th><th>valor</th><th>coordenadas</th><th>municipio</th><th>comunidad</th>
<th>dB</th><th>casa km</th><th>pista m</th><th>agua km</th><th>árbol %</th>
<th>prot km</th><th>Madrid km</th><th>ver</th>
<th>prot km</th><th>ver</th>
</tr></thead>
<tbody>
{rows}
@ -385,8 +384,6 @@ code.inline {{ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monos
riesgo extremo de incendio de junio a septiembre, con restricciones de acceso y
responsabilidad penal si algo prende. El mismo punto no es el mismo sitio en marzo
que en agosto.</li>
<li><h3>Distancia en línea recta, no tiempo de coche</h3>La columna de Madrid es
euclídea. Por carretera y luego pista, el tiempo real puede ser el doble.</li>
</ul>
</div>
</section>

View file

@ -36,7 +36,6 @@ N_CAND = 60
MIN_SEP = 15000 # separación mínima entre candidatos, para no devolver
# 60 celdas del mismo valle
MADRID = C.to_grid(-3.7038, 40.4168)
def load(name):
@ -163,7 +162,6 @@ def main():
"pendiente_deg": round(float(slope[r, c]), 1),
"tpi_m": round(float(tpi[r, c]), 1),
"edif_5km": int(dens[r, c]),
"km_madrid": round(float(np.hypot(x - MADRID[0], y - MADRID[1]) / 1000), 1),
})
# suprimimos un disco alrededor para el siguiente candidato

View file

@ -51,7 +51,6 @@ MAX_FOREST_ATT = 6.0
MAX_TERRAIN_ATT = 12.0
BLOCK = 200 # celdas = 20 km
MADRID = C.to_grid(-3.7038, 40.4168)
def load(n):
@ -259,7 +258,6 @@ def main():
"cota": int(dem[r, c]) if np.isfinite(dem[r, c]) else 0,
"tmax": round(float(tmax[r, c]), 1),
"tmin": round(float(tmin[r, c]), 1),
"km_madrid": round(float(np.hypot(x - MADRID[0], y - MADRID[1]) / 1000), 1),
"row": r, "col": c,
})