| 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
const PLUGINS = {
ors: ORSRouter,
otp: OTPRouter,
nap: GTFSNapRouter
};
registerPlugin('name', {
resolve(origin, dest, mode) { ... },
getIsochrones(point, time, mode) { ... }
});
Para añadir un nuevo motor:
- Crear
js/routing-{name}.js
- Implementar
resolve() y getIsochrones()
- Registrar:
registerPlugin('name', router)
Interfaz IRouter
class IRouter {
async resolve(origin, dest, mode) {
}
async getIsochrones(point, time, mode) {
}
}
⚠️ 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:
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'] };
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
-
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).
-
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 /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.
-
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.
-
Area from ORS: features[0].properties.area is 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 {
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));
}
}
return resultados;
}
Health check con validación de key
res.end(JSON.stringify({
status: 'ready', uptime: process.uptime(),
checks: { ors_api: typeof ORS_KEY === 'string' && ORS_KEY.length > 20 }
}));
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
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({