Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/danizd/urbanviable/llms.txt

Use this file to discover all available pages before exploring further.

UrbanViable is composed of three independent layers that never need to run at the same time: an ETL pipeline that executes on a developer workstation to produce a static .mbtiles file; a two-container Docker stack that serves that file as vector tiles alongside the React application; and a MapLibre GL JS frontend that receives raw normalised data inside the tiles and converts it into a live heatmap entirely on the client GPU. There is no application server, no scoring API, and no database.

System overview

┌──────────────────────────────────────────────────────────────┐
│                   SERVIDOR ORACLE CLOUD ARM                  │
│                                                              │
│  ┌──────────────────────┐    ┌──────────────────────────┐    │
│  │  urbanviable-tiles   │    │   urbanviable-web        │    │
│  │  (TileServer GL)     │    │   (Nginx Alpine)         │    │
│  │  Puerto interno:8080 │    │   Puertos: 80, 443       │    │
│  │                      │    │                          │    │
│  │  Sirve .pbf via HTTP │    │  - Archivos estáticos    │    │
│  │  desde .mbtiles      │    │    de React (build/)     │    │
│  │  CORS configurado    │    │  - Proxy /tiles/ →       │    │
│  └──────────────────────┘    │    urbanviable-tiles     │    │
│                              └──────────────────────────┘    │
│         galicia_scouting.mbtiles                             │
│         (volumen compartido Docker)                          │
└──────────────────────────────────────────────────────────────┘
              │                         │
    GET .pbf  │       HTTPS             │ GET /
              │                         │
         ─────┴─────────────────────────┴─────
                        INTERNET
         ─────┬─────────────────────────┬─────
              │                         │
              ▼                         ▼
        Teselas vectoriales       Aplicación React
        (datos norm. crudos)      MapLibre GL JS
              │                         │
              └───────────┬─────────────┘

                  ┌───────▼────────┐
                  │  GPU CLIENTE   │
                  │                │
                  │  score =       │
                  │  Σ(var×peso)   │
                  │  ÷ Σ(pesos)    │
                  │                │
                  │  → Mapa calor  │
                  └────────────────┘
Only urbanviable-web (Nginx) exposes ports to the outside world. urbanviable-tiles is reachable only from within the Docker bridge network, via the internal hostname urbanviable-tiles:8080. The React app is served as pre-built static files from /usr/share/nginx/html; Nginx proxies any request matching /tiles/* to TileServer GL.

Docker containers

ContainerImageExposed portsInternal portPurpose
urbanviable-tilesmaptiler/tileserver-gl:latest8080 → 80808080Serves .pbf vector tiles from galicia_scouting.mbtiles
urbanviable-webnginx:1.25-alpine3002 → 8080Reverse proxy + React static file server
Both containers belong to the urbanviable-net bridge network. urbanviable-web declares depends_on: urbanviable-tiles to ensure the tile server is healthy before Nginx starts accepting traffic. TileServer GL exposes a health-check endpoint at GET /health used by Docker to gate the readiness probe.

Docker network

networks:
  urbanviable-net:
    driver: bridge
Internal service-to-service communication uses Docker DNS names:
http://urbanviable-tiles:8080/data/galicia-scouting/{z}/{x}/{y}.pbf

Docker volumes

Volume (host path)Container mountAccessPurpose
./tiles_data/data (tileserver)read-writeStores galicia_scouting.mbtiles and config.json
./tiles_data/data (nginx)read-onlyAllows Nginx to serve the raw .mbtiles file if needed
./nginx/conf.d/etc/nginx/conf.dread-onlyNginx virtual-host and proxy configuration
./frontend/build/usr/share/nginx/htmlread-onlyReact production build (output of npm run build)
./certs/etc/letsencryptread-onlyLet’s Encrypt SSL certificates

TileServer GL configuration

TileServer GL is started with --config /data/config.json. The configuration file registers the MBTiles tileset under the key galicia-scouting:
{
  "options": {
    "paths": {
      "root": "/data",
      "mbtiles": "/data"
    }
  },
  "data": {
    "galicia-scouting": {
      "mbtiles": "galicia_scouting.mbtiles"
    }
  }
}
Tile URL pattern: http://urbanviable-tiles:8080/data/galicia-scouting/{z}/{x}/{y}.pbf Tileset metadata (useful for debugging): http://urbanviable-tiles:8080/data/galicia-scouting.json

ETL pipeline

The ETL is a separate local process, not a Docker service. It runs on a developer workstation once per data refresh cycle (typically annual) and produces the single artifact that feeds the entire system.
[1. EXTRACCIÓN]
Python descarga:
  ├── Shapefile secciones censales Galicia  ← CNIG (ZIP, EPSG:25829)
  ├── Atlas de Renta de los Hogares         ← INE (Excel)
  └── Padrón Municipal por sección          ← INE (CSV)
     + OSM (osmium) → POI counts
     + Catastro CONSTRU → building use & age

[2. TRANSFORMACIÓN]
GeoPandas/Pandas:
  ├── Filtra Galicia (provincias 15, 27, 32, 36)
  ├── Reproyecta geometrías a WGS84 (EPSG:4326)
  ├── Join por campo CUSEC (10 dígitos)
  ├── Calcula densidad = poblacion / area_km2
  ├── Normalización Min-Max → columnas _norm
  └── Exporta 7 columnas exactas → galicia_scouting.geojson

[3. GENERACIÓN DE TESELAS]
Tippecanoe:
  └── galicia_scouting.geojson → galicia_scouting.mbtiles
      Zoom 6-14, layer "secciones"
      ~15-40 MB resultado final

[4. DESPLIEGUE]
  └── Copiar .mbtiles a ./tiles_data/
      TileServer GL recarga en caliente
The ETL writes a provenance manifest at etl/data/processed/last_update.json:
{
  "updated_at": "2026-01-01T03:00:00Z",
  "year_data": 2023,
  "sections_count": 3847,
  "sources": {
    "geometries": "CNIG secciones censales 2023",
    "renta": "INE Atlas de Renta 2021",
    "padron": "INE Padrón Municipal 2023"
  }
}
Nginx serves this file as a static asset; the frontend fetches it to display the data vintage in the UI (equivalent to a GET /api/v1/layers/status call in a traditional REST architecture).

User interaction flow

There is no server-side scoring. The MapLibre GL JS expression model computes score = Σ(var_norm × peso) / Σ(pesos_activos) entirely on the client GPU. Every _norm column in the vector tile features is a raw Min-Max normalised float [0, 1] written by the ETL. The weights come exclusively from the sidebar sliders; nothing is stored or pre-computed on the server.
Usuario abre /scouting


[1] MapLibre solicita solo las teselas visibles en pantalla
    GET /tiles/galicia-scouting/{z}/{x}/{y}.pbf
    → Carga instantánea (lazy, solo el viewport actual)


[2] Teselas contienen polígonos con propiedades pre-calculadas:
    { cusec: "1500101001", renta_norm: 0.72, densidad_norm: 0.45,
      jovenes_norm: 0.38, mayores_norm: 0.61,
      renta_abs: 24500, poblacion_abs: 3200 }


[3] Mapa inicial: todos los polígonos en gris (pesos = 0)


[4] Usuario mueve un slider → React actualiza estado `weights`


[5] useMapStyle hook detecta cambio →
    Construye array de expresión MapLibre con literales numéricos →
    map.setPaintProperty('secciones-fill', 'fill-color', expr)


[6] GPU recalcula color de ~3.800 polígonos:
    score = Σ(var_norm × peso) / Σ(pesos_activos)  ← siempre [0,1]
    Rojo intenso = score alto, gris = score bajo
    ⚡ < 16ms (60fps), CERO peticiones al servidor


[7] Usuario hace clic en polígono →
    MapLibre emite evento 'click' con propiedades del feature →
    React muestra Tooltip:
    "Sección 1503001002 | Renta: 24.500€ | Habitantes: 3.200"
Key points:
  • Steps 1–3 involve one HTTP request per viewport tile (cached by the browser after first load, governed by Cache-Control headers set by TileServer GL).
  • Steps 4–6 produce zero network requests. React serialises the current weight map into a MapLibre expression literal and calls setPaintProperty. The GPU re-shades all visible polygons in a single render pass.
  • Step 7 reads feature properties already in GPU memory — no second fetch.

Network flow in production

Internet → Cloudflare (DNS + proxy) → Oracle Cloud VM :443
  → Nginx (SSL termination + reverse proxy)
    → /tiles/*  → urbanviable-tiles:8080  (internal Docker network)
    → /*        → archivos estáticos React (/usr/share/nginx/html)
Cloudflare sits in front of the Oracle Cloud ARM instance and handles DDoS protection, CDN caching of static assets, and DNS. All traffic reaches the VM over HTTPS on port 443. Nginx terminates SSL using Let’s Encrypt certificates mounted from ./certs, then routes tile requests internally to TileServer GL and serves the React bundle directly from disk for everything else.

Performance reference

MetricExpected value
.mbtiles file size15–40 MB
Initial map load< 2 s (only viewport tiles are fetched)
Heatmap colour update after slider move< 16 ms (60 fps, GPU)
Server requests per slider move0
Tile cachingAutomatic via HTTP Cache-Control headers

Infrastructure reference

The production server runs on Oracle Cloud Infrastructure Always Free tier:
AspectDetail
ProviderOracle Cloud Infrastructure (OCI) — Always Free
ArchitectureARM (Ampere A1)
RAM24 GB
Storage200 GB block storage
OSUbuntu 22.04 LTS (ARM64)

ETL Overview

Detailed walkthrough of the Python transformation steps, Min-Max normalisation logic, and Tippecanoe tile configuration.

Docker Deployment

Step-by-step guide to deploying the two-container stack on a fresh Oracle Cloud ARM instance with SSL via Let’s Encrypt.

Build docs developers (and LLMs) love