- name
- routing-isochrones
- description
- Patrones para construir herramientas de isocronas y routing: OpenRouteService, OpenTripPlanner, GTFS/NAP, geocodificación Nominatim. Arquitectura de plugins para motores de routing intercambiables.
- version
- 1.2.0
- author
- David Antizar
- tags
- ["routing","isochrones","gtfs","openrouteservice","opentripplanner","nominatim","leaflet","vanilla-js","mobility"]
# 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
```javascript
// 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:**
1. Crear `js/routing-{name}.js`
2. Implementar `resolve()` y `getIsochrones()`
3. Registrar: `registerPlugin('name', router)`
### Interfaz IRouter
```javascript
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 |
| PDF | 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:
```javascript
// 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
1. **`interval` quirk (CRITICAL):** Adding `"interval": [900]` with a single-range request causes ORS to respond `400: Parameter 'interval' has incorrect value or format.` Never include `interval` for single-range requests. Only use it when auto-generating multiple ranges (e.g. range: [900], interval: 300).
2. **Error response format:** ORS returns errors as string or object. Parse defensively:
```javascript
const errMsg = typeof errData.error === 'string' ? errData.error
: errData.error?.message || errData.error?.error || resp.statusText;
```
3. **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.
4. **"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 `/isochrone` from server and check healthz `ors_api` field. If `ors_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.
5. **No transit profile in ORS** — Use `driving-car` as 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.
6. **Area from ORS:** `features[0].properties.area` is in **m²**. Divide by 1,000,000 for km².
### Pattern: Async fallback con stagger
```javascript
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
```javascript
// 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()` con `Authorization: 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
```javascript
// 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({
在 GitHub 查看