| 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.0.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.
Endpoint (server-side proxy)
DO NOT call ORS directly from the browser — the API key would be exposed. Always use a server-side 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": The key exists but lacks isochrone permissions. Free keys may need upgrade. Health check should validate beyond existence.
-
No bus profile: Use driving-car as approximation.
-
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 = {
car: 'driving-car', bike: 'cycling-regular',
foot: 'foot-walking', bus: 'driving-car'
};
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 }
}));
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);
}