Routing & Isocronas — Patrón de Herramienta
Cuándo cargar esta skill
Cuando el usuario pida: isocronas, mapas de accesibilidad, cálculo de rutas, transporte público con horarios, planes de movilidad, "hasta dónde llego en X minutos", routing multi-modal, GTFS, NAP transportes.
Concepto
Herramienta web que calcula isocronas y rutas de movilidad desde cualquier punto: coche, bicicleta, peatón y transporte público. Pones origen + destino + horario objetivo, obtienes un informe con isocronas y rutas de bus disponibles.
Arquitectura clave: Sistema de plugins para motores de routing intercambiables. Cada backend (ORS, OTP, NAP) implementa la misma interfaz.
Patrones de UI (dos variantes)
Variante A: Punto de interés (simple)
Cuando el usuario quiere "hasta dónde llego desde X" — un solo punto, no formulario de ruta:
┌─────────────────────────────────────────────┐
│ Header oscuro (título + subtítulo) │
├──────────┬──────────────────────────────────┤
│ Sidebar │ Mapa (CARTO light tiles) │
│ │ │
│ Modo │ [click en mapa → punto] │
│ (4 btns) │ │
│ │ │
│ Tiempo │ │
│ (slider) │ │
│ │ │
│ Dirección│ │
│ (input) │ │
│ │ │
│ Calcular │ │
│ │ │
│ PDF │ │
│ │ │
│ Resultados│ │
│ (KPIs) │ │
└──────────┴──────────────────────────────────┘
- 4 botones de modo: coche 🚗, bici 🚲, andando 🚶, bus 🚌
- Slider de tiempo: 5-60 min con presets rápidos (10, 15, 30, 45, 60)
- Input de dirección: con debounce 800ms + click en mapa para poner punto
- Sidebar limpia: fondo blanco, bordes sutiles, sin gradientes
- Mapa: CARTO light tiles, Canvas renderer
Variante B: Origen + Destino (completa)
Para planes de movilidad laboral con horarios GTFS:
Origen (casa) + Destino (oficina) + Horario → Isocronas + Rutas bus
Diseño visual — Reglas críticas
David odia el "look de IA" (dark, neón, glass, gradientes).
Para herramientas de movilidad:
- ✅ Header oscuro (
#1a1a2e) + sidebar blanca + mapa CARTO light - ✅ Botones con bordes sutiles, colores por modo (azul=bici, naranja=coche, verde=andando, púrpura=bus)
- ✅ Tipografía system font (-apple-system, BlinkMacSystemFont, Segoe UI)
- ✅ KPIs en grid 2x2 con fondo gris claro
- ❌ NUNCA gradientes Aurora, glassmorphism, efectos neón
- ❌ NUNCA fondo oscuro en la app completa
- ❌ NUNCA decoraciones innecesarias
CSS base: background: #f8f9fa, color: #1a1a2e, font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif
Arquitectura de Plugins
// js/plugins.js
const PLUGINS = {
ors: ORSRouter, // OpenRouteService (coche/bici/peatón)
otp: OTPRouter, // OpenTripPlanner (transit con transbordos)
nap: GTFSNapRouter // NAP/GTFS España (horarios reales)
};
// Registrar nuevo plugin
registerPlugin('name', {
resolve(origin, dest, mode) { ... },
getIsochrones(point, time, mode) { ... }
});
Para añadir un nuevo motor:
- Crear
js/routing-{name}.js - Implementar
resolve()ygetIsochrones() - Registrar:
registerPlugin('name', router)
Interfaz IRouter
class IRouter {
// Calcular ruta entre origen y destino
async resolve(origin, dest, mode) {
// origin: {lat, lng, name}
// dest: {lat, lng, name}
// mode: 'car' | 'bike' | 'walk' | 'transit'
// Returns: {distance, duration, geometry, steps, mode}
}
// Calcular isocrona desde un punto
async getIsochrones(point, time, mode) {
// point: {lat, lng}
// time: segundos
// Returns: {geojson, area, success}
// geojson: FeatureCollection (Polygon)
// area: km² (number)
// success: boolean
// error: string (solo si success=false)
}
}
⚠️ Shape consistente: El return de getIsochrones debe tener SIEMPRE la misma forma. Si el backend real falla o no hay API key, devolver { geojson: simulatedData, area: estimatedArea, success: true } en vez de tirar error.
Stack tecnológico
| Componente | Tecnología | Justificación |
|---|---|---|
| Mapa | Leaflet (Canvas renderer) | Ligero, sin framework, ya probado |
| Isocronas | OpenRouteService API | Gratis, 3 modos, desnivel incluido |
| Routing TP | OpenTripPlanner | Transbordos reales, GTFS |
| Geocodificación | Nominatim (OSM) | Gratis, no requiere key |
| jsPDF + autoTable + html2canvas | Generación cliente con captura de mapa | |
| CSS | Simple/clean (NO Aurora glass) | Header oscuro + sidebar blanca + CARTO light |
| JS | Vanilla ES modules | Sin bundler, un solo HTML |
OpenRouteService (ORS) v2
CRITICAL: The v2 API changed from the v1 format shown in old docs. The correct endpoint and body format are below.
Dos patrones de acceso a ORS
Patrón A: Server-side proxy (recomendado para apps privadas/produción)
NO llamar a ORS directamente desde el navegador si la API key es del servidor — se expondría. Usar proxy:
// server.mjs — proxy endpoint
if (req.method === 'POST' && req.url.startsWith('/isochrone')) {
const ORS_KEY = process.env.ORS_API_KEY;
if (!ORS_KEY) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'ORS_API_KEY no configurada', fallback: true }));
return;
}
let body = '';
req.on('data', chunk => body += chunk);
req.on('end', () => {
const { profile, locations, range } = JSON.parse(body);
const bodyObj = { locations: [locations], range, range_type: 'time', attributes: ['area'] };
// ⚠️ NO incluir 'interval' para rango único — ORS lo rechaza con 400
if (range.length > 1) bodyObj.interval = range[0];
const options = {
hostname: 'api.openrouteservice.org',
path: `/v2/isochrones/${profile}`,
method: 'POST',
headers: {
'Authorization': ORS_KEY,
'Content-Type': 'application/json; charset=utf-8',
'Accept': 'application/json, application/geo+json'
}
};
const proxyReq = https.request(options, (proxyRes) => {
let data = '';
proxyRes.on('data', chunk => data += chunk);
proxyRes.on('end', () => { res.writeHead(proxyRes.statusCode, { 'Content-Type': 'application/json' }); res.end(data); });
});
proxyReq.on('error', (err) => { res.writeHead(502); res.end(JSON.stringify({ error: err.message, fallback: true })); });
proxyReq.write(JSON.stringify(bodyObj));
proxyReq.end();
});
return;
}
Request body (correct v2 format)
POST https://api.openrouteservice.org/v2/isochrones/{profile}
Headers:
Authorization: {api_key}
Content-Type: application/json; charset=utf-8
Accept: application/json, application/geo+json
Body: {
"locations": [[lng, lat]], // single pair, NOT [{lat, lng}]
"range": [900], // seconds (15 min)
"range_type": "time",
"attributes": ["area"] // returns area in m²
// DO NOT include "interval" for single-range (see quirk below)
}
⚠️ ORS v2 API quirks
intervalquirk (CRITICAL): Adding"interval": [900]with a single-range request causes ORS to respond400: Parameter 'interval' has incorrect value or format.Never includeintervalfor single-range requests. Only use it when auto-generating multiple ranges (e.g. range: [900], interval: 300).Error response format: ORS returns errors as string or object. Parse defensively:
const errMsg = typeof errData.error === 'string' ? errData.error : errData.error?.message || errData.error?.error || resp.statusText;Rate limiting (429): Free ORS tier ≈ 1 req/s. With 12 isochrones (4×3), parallel
Promise.all()triggers 429. Solution: stagger sequentially with 300-1000ms delay between each request."Access to this API has been disallowed" (403): The key exists but lacks isochrone permissions. Some ORS keys work for routing (
/v2/directions) but NOT for isochrones (/v2/isochrones). This is a permissions issue, not a format issue. Diagnose: call/isochronefrom server and check healthzors_apifield. Ifors_api: false, the key is invalid or lacks permissions. Fix: create a new key at openrouteservice.org (free tier includes isochrones if registered). Old keys from v1 era may not have isochrone scope.No transit profile in ORS — Use
driving-caras approximation for bus, metro, and tram. These are NOT accurate — they show road travel range, not transit network range. Label them clearly in the UI as "aproximación por carretera" and note that real transit data comes from GTFS/NAP.Area from ORS:
features[0].properties.areais in m². Divide by 1,000,000 for km².
Pattern: Async fallback con stagger
const ORS_PROFILES = {\n car: 'driving-car', bike: 'cycling-regular',\n foot: 'foot-walking', bus: 'driving-car',\n metro: 'driving-car', tram: 'driving-car'\n};
export async function calcularIsocronaAsync(lng, lat, modo, minutos) {
try { // Intentar ORS real
const resp = await fetch('/isochrone', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ profile: ORS_PROFILES[modo], locations: [lng, lat], range: [minutos * 60] }),
signal: AbortSignal.timeout(15000)
});
if (!resp.ok) {
const errData = await resp.json().catch(() => ({}));
if (errData.fallback) throw new Error('ORS no disponible (sin key)');
const errMsg = typeof errData.error === 'string' ? errData.error
: errData.error?.message || errData.error?.error || resp.statusText;
throw new Error(`ORS HTTP ${resp.status}: ${errMsg}`);
}
const data = await resp.json();
const areaKm2 = (data.features?.[0]?.properties?.area || 0) / 1_000_000;
return { geojson: data, areaKm2, real: true };
} catch (err) {
console.warn(`⚠️ ORS fallback ${modo} ${minutos}min: ${err.message}`);
}
return { ...calcularIsocronaSim(lng, lat, modo, minutos), real: false };
}
export async function calcularTodasAsync(punto, modos, tiempos) {
const resultados = [];
for (const modo of modos) {
for (const min of tiempos) {
const r = await calcularIsocronaAsync(punto.lng, punto.lat, modo, min).catch(
e => ({ modo, minutos: min, geojson: null, areaKm2: 0, error: e.message, real: false })
);
resultados.push({ modo, minutos: min, ...r });
await new Promise(r => setTimeout(r, 300)); // stagger
}
}
return resultados;
}
Health check con validación de key
// server.mjs /healthz
res.end(JSON.stringify({
status: 'ready', uptime: process.uptime(),
checks: { ors_api: typeof ORS_KEY === 'string' && ORS_KEY.length > 20 }
}));
// ↑ Más robusto que !!ORS_KEY (detecta strings vacíos)
Patrón B: Client-side directo (para herramientas públicas con key del usuario)
Cuándo usar: Herramientas públicas tipo Pages donde el usuario provee su propia API key de ORS (free tier 2000 req/día). La key se almacena en localStorage del usuario, no en el código.
Ventaja: Sin servidor. Despliegue 100% estático (GitHub Pages, Netlify, etc.) Riesgo: La key es visible en DevTools. Aceptable para keys free-tier de uso personal.
ISOTime (github.com/Ntizar/ISOTime) es un ejemplo funcional de este patrón:
- HTML único + ES modules, sin bundler
- ORS API v2 llamado directamente desde
fetch()conAuthorization: key - Key en
localStorage(modal de setup首次, luego se lee) - Fallback simulado si no hay key (polígono con jitter)
- Export GeoJSON + SHP binario en-browser (JSZip)
- Tiles IGN WMTS (EPSG:3857) como alternativa a CARTO light
// Patrón ISOTime: acceso directo ORS desde el navegador
async function calcularIsocrona(lng, lat, modo, minutos) {
const apiKey = localStorage.getItem('ors_api_key');
if (!apiKey) return calcularIsocronaSim(lng, lat, modo, minutos);
const profile = { car: 'driving-car', walk: 'foot-walking', bike: 'cycling-regular' }[modo];
const resp = await fetch(`https://api.openrouteservice.org/v2/isochrones/${profile}`, {
method: 'POST',
headers: {
'Authorization': apiKey,
'Content-Type': 'application/json; charset=utf-8'
},
body: JSON.stringify({
locations: [[lng, lat]],
range: [minutos * 60],
range_type: 'time',
attributes: ['area']
})
});
if (!resp.ok) return calcularIsocronaSim(lng, lat, modo, minutos);
const data = await resp.json();
const areaKm2 = (data.features?.[0]?.properties?.area || 0) / 1_000_000;
return { geojson: data, areaKm2, real: true };
}
Ver: references/isotime-client-side-pattern.md para arquitectura completa, estructura de archivos, y patrones de export.
Simulación de isocronas (fallback)
Generar círculos irregulares con jitter cuando ORS no está disponible:
function calcularIsocronaSim(lng, lat, modo, minutos) {
const m = CONFIG.MODOS[modo];
const radioM = (m.speedKmh / 3.6) * minutos * 60;
const PTS = 48, coords = [];
for (let i = 0; i <= PTS; i++) {
const ang = (i / PTS) * 2 * Math.PI;
const jitter = 1 - (0.12 * (Math.sin(i * 7.3) * 0.5 + 0.5));
const r = radioM * jitter;
const dLat = (r * Math.cos(ang)) / 111320;
const dLng = (r * Math.sin(ang)) / (111320 * Math.cos(lat * Math.PI / 180));
coords.push([lng + dLng, lat + dLat]);
}
return {
geojson: { type: 'FeatureCollection', features: [{
type: 'Feature', geometry: { type: 'Polygon', coordinates: [coords] },
properties: { modo, minutos, simulado: true }
}]},
areaKm2: calcularAreaPoligonoKm2(coords, lat)
};
}
function calcularAreaPoligonoKm2(coords, refLat) {
let area = 0; const n = coords.length;
for (let i = 0; i < n; i++) {
const j = (i + 1) % n;
area += coords[i][0] * coords[j][1] - coords[j][0] * coords[i][1];
}
const cosLat = Math.cos((refLat ?? coords.reduce((s, c) => s + c[1], 0) / n) * Math.PI / 180);
return Math.abs(area) / 2 * (111.32 * 111.32 * cosLat);
}
Patrón: Wrapper consistente getIsochrone()
CRÍTICO: La función getIsochrone() DEBE devolver SIEMPRE el mismo shape { geojson, area, success }, tanto si usa la API real como el fallback simulado.
// ❌ MAL: devuelve shapes diferentes según el camino
export async function getIsochrone(lng, lat, profile, rangeSeconds) {
if (!API_KEY) return getSimulatedIsochrone(lng, lat, profile, rangeSeconds);
// ... fetch ORS ...
return { geojson: data, area, success: true };
}
// → `computeAllIsochrones` espera `.geojson` y `.area`, pero
// simulated devuelve el GeoJSON raw → `.geojson` es undefined
// ✅ BIEN: siempre mismo shape
export async function getIsochrone(lng, lat, profile, rangeSeconds) {
if (!API_KEY) {
const geojson = getSimulatedIsochrone(lng, lat, profile, rangeSeconds);
const coords = geojson.features[0].geometry.coordinates[0];
const area = calculatePolygonAreaKm2(coords, lat);
return { geojson, area, success: true };
}
// ... fetch ORS real ...
return { geojson: data, area, success: true };
}
Regla de oro: cualquier wrapper de API externa debe envolver SIEMPRE la respuesta en un shape consistente, independientemente de si la llamada fue real, simulada, o en error.
Config: evaluación perezosa (getter)
La key de ORS NO debe evaluarse al cargar el módulo, porque si el módulo ES se cachea en el navegador, la key queda congelada en el valor del primer load. Usar un getter:
const CONFIG = {
ORS: {
get key() { // ← getter, evaluado CADA VEZ que se accede
if (typeof window !== 'undefined' && window.__ENV?.ORS_API_KEY) {
return window.__ENV.ORS_API_KEY;
}
return '';
}
}
};
El servidor inyecta window.__ENV = { ORS_API_KEY: "..." } en el HTML antes del módulo. Con el getter, el valor se lee en tiempo de ejecución, no al cargar.
Endpoint routing:
GET https://api.openrouteservice.org/v2/directions/{o_lng},{o_lat};{d_lng},{d_lat}
Params: ?profile=driving-car
API Key gratis: https://openrouteservice.org/sign-up (2.000 req/día)
Desnivel: ORS ya lo considera internamente:
cycling-regularpenaliza subidascycling-mountainpenaliza másfoot-walkingconsidera pendiente
OpenTripPlanner (OTP)
Para routing con transbordos reales.
Endpoints:
POST /otp/routers/default/plan — routing general
POST /otp/routers/default/isochrone — isocronas
POST /otp/routers/default/arriveby — llegar a hora X
POST /otp/routers/default/departby — salir a hora X
Docker:
docker run -d -p 8080:8080 \
-v /path/to/config:/opt/otp/config \
opentripplanner/otp:latest \
--build
Datos: Descargar OSM extract de Overpass API + GTFS de cada ciudad.
GTFS + NAP (Transporte Público España)
NAP es un catálogo de datasets GTFS, no un motor de routing.
📊 Volumen REAL de datos (verificado 2026-06-23)
161 conjuntos de datos (160 activos, 1 obsoleto). 0.65 GB total (661.9 MB).
| Métrica | Valor |
|---|---|
| Conjuntos | 161 |
| Tamaño total | 0.65 GB |
| Viajes totales | 2,088,524 |
| Rutas totales | 24,252 |
| Paradas totales | 191,065 |
| Organizaciones | 122 |
| Actualización | Diaria (varios datasets se actualizan TODOS los días) |
Distribución por tipo:
- Autobús: 137 conjuntos
- Ferroviario: 28 conjuntos
- Marítimo: 3 conjuntos
- Aéreo: 1 conjunto
Top 5 por tamaño:
- Xunta de Galicia → 136 MB (133K viajes, 6.5K rutas, 26K paradas)
- CRTM Madrid interurbanos → 72 MB
- Cataluña completa → 66 MB
- Cataluña simplificada → 57 MB
- Tenerife TITSA → 22 MB
Distribución de tamaños:
- 102 datasets < 1 MB
- 35 datasets entre 1-5 MB
- 10 datasets entre 5-10 MB
- 10 datasets entre 10-50 MB
- 3 datasets entre 50-100 MB
- 1 dataset > 100 MB
Actualización diaria: Algunos datasets se actualizan TODOS los días:
- Cataluña completa: 426 versiones en
1 año (1.2/día) - Tenerife TITSA: 854 versiones (~2.3/día)
- Comunidad Valenciana interurbano: 1,540 versiones (~4.2/día)
Históricos: Media de 743 versiones por dataset (pero muchas son duplicados). Si se guardan las 3 últimas versiones por dataset: ~2-3 GB adicionales.
Estrategia recomendada: Full dump inicial 0.7 GB + delta semanal ~100-500 MB + históricos recientes ~2 GB = **3-4 GB total estable**.
📋 Top 10 datasets por tamaño (IDs reales)
| Dataset | Tamaño | ID | Viajes | Rutas | Paradas |
|---|---|---|---|---|---|
| Xunta de Galicia | 136.4 MB | 1386 | 133,153 | 6,584 | 26,004 |
| CRTM Madrid interurbanos | 72.2 MB | 1160 | 55,219 | 354 | 8,402 |
| Cataluña completa | 66.1 MB | 1536 | 210,708 | 2,092 | 29,050 |
| Cataluña simplificada | 56.6 MB | 1535 | 194,727 | 1,605 | 23,246 |
| Cataluña interurbano | 21.9 MB | 1163 | 26,148 | 939 | 8,942 |
| Tenerife TITSA | 21.5 MB | 1130 | 72,653 | 178 | 3,815 |
| Bizkaibus | 20.8 MB | 1061 | 38,042 | 93 | 2,335 |
| CRTM Madrid urbano | 20.0 MB | 934 | 87,005 | 236 | 4,911 |
| Comunidad Valenciana | 17.4 MB | 1325 | 10,646 | 381 | 5,225 |
| EMT Madrid | 16.1 MB | 896 | 81,798 | 236 | 4,924 |
Flujo completo
1. Listar conjuntos GTFS → GET /api/v2/conjunto-dato?regionId=X
2. Filtrar por tipo → solo "Autobús urbano"
3. Descargar fichero GTFS → GET /api/v2/fichero/{id}/descarga
4. Parsear GTFS localmente en JS
5. Calcular paradas cercanas a origen y destino
6. Calcular rutas que conecten ambas zonas
7. Filtrar por horario objetivo (7:30-9:30 / 16:30-18:30)
8. Mostrar resultados
Motor de horarios laborales
// Filtrar por horario de llegada al trabajo (7:30-9:30)
const morningArrivals = stopTimes.filter(st => {
const time = parseTime(st.arrival_time); // "07:45:00" → 7.75 horas
return time >= 7.5 && time <= 9.5;
});
// Filtrar por horario de salida del trabajo (16:30-18:30)
const eveningDepartures = stopTimes.filter(st => {
const time = parseTime(st.departure_time);
return time >= 16.5 && time <= 18.5;
});
Archivos GTFS necesarios
| Archivo | Qué contiene | Uso |
|---|---|---|
stops.txt |
Paradas (id, lat, lon, nombre) | Calcular paradas cercanas |
routes.txt |
Rutas (id, short_name, agency, type) | Filtrar por tipo |
trips.txt |
Viajes (route_id, trip_id, direction_id) | Ida vs vuelta |
stop_times.txt |
Horarios por parada | El más importante |
calendar.txt |
Fechas de servicio | Días laborables |
Limitación importante
GTFS no es routing real. Dice "el bus X llega a la parada Y a las 7:45", pero no calcula transbordos. Para routing real con transbordos se necesita OpenTripPlanner o Valhalla con GTFS.
📦 Repositorio GTFSSpain — Datos offline completos
Patrón para tener TODO el transporte público español en local:
- Repo privado:
github.com/Ntizar/GTFSSpain - Estructura:
data/(GTFS ZIPs, gitignored) +metadata/(JSON ligero, en git) +descargar-nap.py(script) - Tamaño: ~0.65 GB GTFS actuales + ~3 GB con históricos
- Actualización: cron semanal vía Hermes cron job (domingo 06:00 UTC, delta mode)
Script de descarga: descargar-nap.py en el repo
python3 descargar-nap.py— full dumppython3 descargar-nap.py --delta— solo actualizados (últimas 24h)python3 descargar-nap.py --dry-run— preview sin descargar
Patrón de descarga NAP (API v2):
GET /api/v2/conjunto-dato→ lista TODOS los conjuntos (161 datasets, ~9 MB response)- Para cada conjunto:
GET /api/v2/conjunto-dato/{id}→ metadatos + ficheros - Para cada fichero GTFS:
GET /api/v2/fichero/{id}/descarga→ JSON conenlaceDescarga(S3 temporal 900s) GET {enlaceDescarga}→ descarga el ZIP real
Importante: Solo descargar ficheros con nombreTipoFichero conteniendo "GTFS" (filtrar GTFS-ZIP). Los tipos RT, NetEx, SIRI son datos en tiempo real, no ZIPs descargables.
Los enlaces S3 caducan en 900 segundos (15 min). Hay que descargar rápido.
Estructura local de datos:
data/
00896_Autobus_urbano_de_Madrid/
metadata.json # metadatos del conjunto
2060_GTFS-ZIP.zip # fichero GTFS
01386_Autobuses_Xunta_Galicia/
metadata.json
2083_GTFS-ZIP.zip
...
Cron job: gtfsspain-update — ejecuta descargar-nap.py --delta cada domingo 06:00 UTC.
NAP API — GTFS auto-download (server-side)
Cuando la app necesita GTFS de operadores de transporte público españoles, no se puede cargar el GTFS desde el frontend directamente porque:
- El frontend no tiene API key de NAP (transportes.gob.es)
- Los archivos GTFS pueden ser 50-200MB (el navegador no puede cargarlos vía fetch directo)
- CORS del NAP no permite acceso desde dominios de apps
Solución: Servidor proxy con tres endpoints:
| Endpoint | Función | Llamada desde |
|---|---|---|
/nap-download-gtfs |
NAP API: lista datasets → encuentra GTFS → descarga | Frontend (JS) |
/gtfs-download-proxy |
Proxy directo: descarga un GTFS por URL | Frontend (JS) |
/nap-datasets |
Proxy informativo: lista todos los conjuntos GTFS de un operador | Frontend (JS) |
Flujo completo:
flowchart TD
A[Usuario selecciona operador] --> B{Frontend pide\n/nap-download-gtfs}
B --> C[Server: GET /api/conjunto-dato]
C --> D{¿Hay API key?}
D -->|Sí| E[Descarga GTFS real]
D -->|No| F[Devuelve null + fallback]
E --> G[Stream directo al cliente]
F --> H[Cliente: intenta URL directa]
H --> I[Cliente: cache en localStorage]
Código server.mjs:
// Endpoint 1: NAP proxy con 2 pasos
if (req.url.startsWith('/nap-download-gtfs')) {
const parts = req.url.split('?');
const params = new URLSearchParams(parts[1]);
const datasetId = params.get('datasetId');
// Paso 1: Obtener info del dataset
const datasetURL = `${NAP_BASE_URL}/api/v2/conjunto-dato/${datasetId}`;
const resp1 = await fetch(datasetURL, { headers: { 'ApiKey': NAP_KEY } });
const datasetInfo = await resp1.json();
// Buscar el fichero GTFS dentro del dataset
const gtfsFile = datasetInfo.ficheros?.find(f =>
f.tipo === 'GTFS' || f.nombre?.endsWith('.zip')
);
if (!gtfsFile) return { error: 'No GTFS file found' };
// Paso 2: Descargar el fichero GTFS
const downloadURL = `${NAP_BASE_URL}/api/v2/fichero/${gtfsFile.id}/descarga`;
const resp2 = await fetch(downloadURL, {
headers: { 'ApiKey': NAP_KEY }
});
// Stream al cliente
const arrayBuffer = await resp2.arrayBuffer();
res.writeHead(200, {
'Content-Type': 'application/zip',
'Content-Length': arrayBuffer.byteLength
});
res.end(Buffer.from(arrayBuffer));
}
Fallback para cuando no hay NAP key:
- Probar 3 fuentes: cache → NAP proxy → URL directa → localStorage
- Si ninguna funciona, mostrar mensaje "Sube tu GTFS manualmente" con botón de upload
NAP operadores de Madrid (IDs reales)
| Operador | NAP dataset ID | Líneas | GTFS |
|---|---|---|---|
| EMT Madrid | 2111 | 217 | ✅ Auto |
| Metro Madrid | 2113 | 13 | ✅ Auto |
| Renfe Cercanías | 1738 | 9 | ✅ Auto |
| CRTM | 286 | 400 | ✅ Auto |
Estructura del proyecto (moderna)
project/
├── index.html # HTML único (frontend)
├── css/
│ └── style.css # Estilos específicos
├── js/
│ ├── config.js # Configuración centralizada (velocidades, colores, modos, tiempos, opacidad)
│ ├── utils.js # geocode(), reverseGeocode(), formatNum(), formatKm2(), debounce
│ ├── map.js # Leaflet Canvas + marcadores + renderizado isocronas + capturarMapa() para PDF
│ ├── isochrones.js # Motor async: ORS real con fallback simulación (stagger 300ms)
│ ├── pdf.js # PDF con jsPDF + autoTable + html2canvas (captura de mapa)
│ ├── shp.js # Descarga SIG: GeoJSON, CSV, SHP (.shp+.shx+.dbf+.prj empaquetados en ZIP)
│ ├── nap.js # Catálogo de transporte público: operadores por ciudad detectada
│ ├── clip.js # Sea clipping: detección de ciudades costeras + preparación para recorte
│ └── main.js # Orquestador: geocode → isocronas → render → PDF → descargas
├── server.mjs # Servidor estático + proxy Nominatim + proxy ORS
├── PLAN.md
└── README.md
vs. la versión antigua (plugin-based con ors.js, gtfs.js, plugins.js separados):
La arquitectura moderna unifica ORS + GTFS en un solo isochrones.js con async/await y fallback automático. El sistema de plugins se reemplazó por un patrón de try/catch por request: cada isócrona intenta ORS real, y si falla, usa simulación local. Esto es más simple y robusto que la interfaz IRouter.
Geocodificación Nominatim
// Geocodificación directa
const resp = await fetch(
`${NOMINATIM.baseUrl}/search?format=json&q=${encodeURIComponent(query)}&limit=1`,
{ headers: { 'User-Agent': 'Time/2.0' } }
);
// Geocodificación inversa
const resp = await fetch(
`${NOMINATIM.baseUrl}/reverse?format=json&lat=${lat}&lon=${lon}`,
{ headers: { 'User-Agent': 'Time/2.0' } }
);
Rate limit: 1 request/segundo. Usar debounce en inputs.
Pitfalls
- GTFS no es routing — solo horarios. Para transbordos reales necesitas OTP o Valhalla con GTFS
- NAP solo España — para otros países necesitas GTFS directo de cada operador o Transitland API
- Nominatim rate limit — 1 req/segundo. Siempre debounce los inputs
- ORS API key en frontend — visible para el usuario. Para producción, usar proxy backend
- Leaflet Canvas + interactividad — eventos de mouse son por bounding box, no por forma exacta. Para polígonos pequeños, usar
toleranceenL.canvas({tolerance: 5}) - GTFS stop_times.txt es el archivo más grande — puede ser 100MB+. Parsear con streaming o filtrar por ruta antes de cargar
- ORS wrapper: shape consistente —
getIsochrone()debe devolver SIEMPRE{geojson, area, success}. Si el fallback simulado devuelve raw GeoJSON (sin wrapper), el consumidor recibeundefined.geojsony las isocronas no se renderizan. Ver sección ORS > Patrón de wrapper. - Nominatim geocoding devuelve
lon, nolng— Nomination usalonpara longitud. Si en tu código desestructuras como{lng}obtendrásundefined. La forma correcta es devolverlng: parseFloat(data[0].lon)en el wrapper de geocoding. Este es un gotcha recurrente con OSM/Nominatim. - Config: no evaluar env vars al cargar módulo — ES modules se cachean por URL en el navegador. Si
CONFIG.ORS.keyse evalúa al cargar el módulo, ese valor queda congelado aunque el servidor inyecte unwindow.__ENVdiferente en el HTML. Usar getter para evaluación perezosa (ver sección ORS > Config). - Cache de ES modules en desarrollo — Los ES modules importados estáticamente se cachean en el navegador POR URL. Cambiar el contenido del archivo en el servidor NO fuerza recarga si la URL es la misma. Soluciones:
- Añadir
?v=Na todos los imports:import { x } from './modulo.js?v=2'(cascade: el HTML cargamain.js?v=2, que a su vez importa./map.js?v=2, etc.) - Configurar el servidor con
Cache-Control: no-cache, no-store, must-revalidatepara JS (no es suficiente solo: el cache ES module es distinto del HTTP cache) - Navegar a un dominio COMPLETAMENTE diferente (https://example.com) entre pruebas.
about:blankno limpia el módulo cache
- Añadir
- ORS simulado para desarrollo — si no hay API key, generar círculos simples como fallback. No confundir con datos reales
- ORS
intervalquirk — NUNCA incluirintervalen el body cuando se solicita un solo rango. ORS v2 lo rechaza con 400 aunque el valor sea correcto.intervalsolo se usa para generar múltiples rangos automáticamente (ej. range:[900], interval:300 genera isocronas a 300, 600 y 900s) - Stagger sequential > Promise.all — Con 12 isocronas (4×3), lanzar todas en paralelo con
Promise.all()provoca 429 Rate Limit en el free tier de ORS (1 req/s). Usar bucle secuencial con3.6s con 300ms stagger), evita los fallos por rate limitawait delay(300-1000ms)entre cada request. Aunque tarda más ( - NaN deploy: health check with ORS key validation —
!!process.env.ORS_API_KEYdevuelvetrueincluso para strings vacíos. Usartypeof key === 'string' && key.length > 20. NaN tarda 1-5 min en detectar cambios de GitHub y redeployar. El uptime en healthz confirma si se redeployó - Cache-buster de ES modules — El script
<script>document.querySelectorAll('script[src*="main.js"]')...</script>NO funciona para cache-busting porque se ejecuta ANTES de que el<script type="module">exista en el DOM. Solución: hardcodear?v=Ndirectamente en el src del módulo en el HTML - SHP generation in browser — No hay librería CDN que genere .shp directamente. Construir el binario manualmente: file header (100B big-endian), record (8B header + Polygon=5 content). Empaquetar .shp+.shx+.dbf+.prj en ZIP via JSZip. Ver session-2026-06-19.md para el patrón completo.
- html2canvas for Leaflet map capture — Las tiles deben permitir CORS (CARTO light_all sí). Usar
useCORS: true, scale: 2en las opciones. El contenedor del mapa debe estar visible en el viewport antes de capturar. - NAP city detection —
detectarCiudad()usastring.includes()sobre eldisplay_namede Nominatim. Las direcciones largas o con nombres compuestos pueden fallar. Extender el catálogo manualmente para nuevas ciudades. - ES Module import mismatch = failure silencioso — Si un
import { nombre }NO coincide exactamente (case-sensitive) con la exportación del módulo destino, ES module falla sin ningún mensaje de error visible. No aparece enwindow.onerror, no hay stack trace, no hay 404. El síntoma es que la página carga pero nada funciona — el módulo principal nunca se ejecuta. Debug: verificar cada import contra su export real. Versystematic-debugging→references/es-module-silent-failure.md.
Caso real: Llamar a DEMO.cargarGTFS() desde main.js cuando demographics.js solo exporta cargarDatos(). El módulo demographics.js se carga pero su ejecución se aborta silenciosamente porque cargarGTFS no existe. El resto de imports que dependen de main.js nunca se ejecutan. Síntoma: página en blanco sin errores en consola. Solución: revisar cada import vs su export, y usar console.log('module loaded') al inicio de cada módulo para saber cuáles se ejecutan.
- DOCX UMD library: usar
window.docx, noimport('docx')— Cuando se carga la libreríadocxmediante<script src="docx.umd.js">, se inyecta como globalwindow.docx. NO se puede usarawait import('docx')porque:
- El UMD wrapper detecta que ya está en un entorno de módulos (ES modules) y se registra a sí mismo como
define('docx', ...)en lugar de como global import('docx')resuelve a una copia vacía del módulo — las funciones existen pero son stubs que no hacen nada- El resultado:
generate({...})se ejecuta sin errores pero no produce documento, ysave()no descarga nada - Solución: Siempre usar
window.docxpara acceder a la librería UMD:const { Document, Packer, Paragraph } = window.docx; - Para nuevas librerías: Verificar si vía CDN es UMD (global) o ESM. Si es UMD, usar
window.LibName. Si es ESM (type="module"), usarimport.
Git branch naming (GitHub vs local): Cuando creas un repo GitHub con
curl -X POST /user/repos, GitHub usamaincomo default branch. Perogit initcreamaster. Si haces push amainy falla con "refspec main does not match any", es que el repo remoto tienemainpero tu local tienemaster. Solución:git push origin masteren vez degit push origin main.Private repo + CDN = 404: jsDelivr, unpkg, y otros CDN públicos NO sirven archivos de repositorios privados de GitHub. Si tu CSS/JS está en un repo privado y lo referencias vía
cdn.jsdelivr.net/gh/owner/repo@branch/file, obtendrás 404 silencioso. Solución: copiar el archivo al proyecto local (ej.css/kaizen.css) y servirlo desde el mismo servidor.Kaizen sidebar
position:fixedrompe el layout del mapa: El Kaizen Design System usa.kz-sidebar { position: fixed }que saca el sidebar del flow normal. Si el mapa usawidth: 100%o CSS Grid, se solapa con el sidebar o se corta. Solución: el mapa también debe serposition: fixedconleft: var(--kz-sidebar-width); right: 0; top: 0; bottom: 0;. NO usar CSS Grid nimargin-left— ambos causan doble compensación. En mobile (<768px): sidebar como bottom-sheet (position: fixed; bottom: 0), mapaleft: 0.GTFS compact cache: auto-load sin JSZip: En vez de parsear ZIPs GTFS en el navegador con JSZip (lento, RAM-intensive), pre-procesar los GTFS en JSON compacto (
{stops, routes, route_trip_counts, stop_trip_map}) y servirlos desde el servidor. Auto-cargar al detectar la ciudad. Ventajas: ~150-350KB por ciudad (vs 50-200MB ZIP), carga instantánea, sin dependencia JSZip. Endpoint:GET /gtfs-cache/:city. Verreferences/gtfs-compact-cache.md.Subagentes en paralelo + archivos compartidos = duplicación silenciosa: Cuando se delegan 3 tareas en paralelo y dos subagentes modifican el mismo archivo (ej:
nap.js), ambos pueden añadir la misma función (renderSeccionParadas), resultando en declaración duplicada. El error de sintaxis es invisible ennode --check(válido en módulos independientes) pero rompe la app en el navegador (Identifier 'x' has already been declared). Debug:import('/js/main.js').catch(e => e.message)en la consola del navegador. Prevención: Si dos subagentes necesitan modificar el mismo archivo, hacerlos en serie o dar a cada uno una sección/clase distinta del archivo.Convex hull produce círculos de mierda para isócronas — David lo probó y corrigió: "Hace cálculos de mierda… solo hace un círculo roro alrededor". El convex hull conecta los puntos más exteriores alcanzables, pero ignora la forma real de la isócrona. Solución: usar boundary detection por dirección (72 direcciones × 8 radios, interpolación lineal entre último alcanzable y primero que excede). Ver sección "Isochrone Boundary Detection".
GitHub Pages ES module cache — hard refresh NO basta — Después de push a GitHub Pages, el browser puede seguir sirviendo JS antiguo aunque hagas hard refresh. Causa: ES modules se cachean POR URL, separado del HTTP cache. Solución completa: (1) Añadir
?v=Nal<script src="js/main.js?v=N">en el HTML, (2) Navegar aindex.html?t=hash(no solo/), (3) Para testing, cambiar a dominio completamente diferente entre pruebas.?v=Nen el HTML NO propaga a los imports estáticos del módulo — cadaimport也需要 su propio?v=N.
GTFS Transit Routing — Motor de Rutas con Transbordo (absorbido de gtfs-transit-routing)
Concepto
Motor de routing que usa datos GTFS reales (stop_times.txt, trips.txt, calendar.txt) para calcular rutas con transbordos (0, 1 o 2), horarios reales y ranking por tiempo total.
Arquitectura
origen + destino + horario → findStopsNear() → buildTransitGraph() → BFS(maxTransfers=2) → filterBySchedule() → rank()
Algoritmo BFS con transbordos
function bfsWithTransfers(startStop, endStop, maxTransfers, adjacency, tripStops, tripInfo) {
// BFS con estado: {stop_id, transfers, current_route, arrival_time, path}
// newTransfers = current_route && edge.route_id !== current_route ? transfers+1 : transfers
// visited map para evitar ciclos
}
Filtrado por horario laboral
function filterBySchedule(routes, horarioObjetivo) {
// morningWindow: 7:30-9:30, eveningWindow: 16:30-18:30
}
Ranking
Por tiempo, transbordos, o directa (prefiere sin transbordo).
Pitfalls de GTFS routing
- GTFS sin stop_times: El grafo transit no se puede construir. Fallback a BFS simple.
- Cruce de medianoche:
(to - from + 86400) % 86400 - Performance: BFS explota combinatoriamente. Limitar
maxTransfers=2y usarvisitedmap. - Paradas de transbordo: Dos paradas físicamente cercanas pero con IDs distintos. Considerar <100m como conectables.
- Direccionalidad:
trips.direction_idpuede ser 0 o 1. Verificar dirección deseada. - GTFS no es routing: Solo horarios. Para transbordos reales necesitas OTP o Valhalla con GTFS.
Visor HTML con JSZip — GTFS en el navegador
Patrón para construir un visor de transporte público que funcione 100% en el navegador sin servidor, parseando ZIPs GTFS locales con JSZip.
Cuándo usarlo
Cuando necesitas buscar paradas ce
…(truncated)