| name | gtfs-browser-parser |
| description | Parser GTFS completo en navegador + catalogo de operadores por ciudad, busqueda por proximidad, fuentes multiples (cache, ZIP subido, localStorage, proxy), integracion mapa y PDF |
| version | 1.1.0 |
| tags | ["gtfs","transporte","parser","navegador","movilidad","isocronas"] |
GTFS Browser Parser — Patrón completo de Integración GTFS
Descripción
Patrón para integrar datos GTFS de transporte público en aplicaciones web del navegador: detección de ciudad y operadores, carga selectiva de paradas cercanas, visualización en mapa Leaflet, informe PDF y exportación GeoJSON.
Origen
Derivado de nap-dashboard (API NAP España) y TimeIneco v0.7 (isocronas de movilidad laboral).
Arquitectura General
User input (address)
→ geocode → ciudad detectada
→ catálogo operadores → usuario selecciona
→ cargar GTFS (cache / upload / proxy / localStorage)
→ findStopsNear(punto, radio 2km) → filtrar por Haversine
→ UI: chips de rutas + marcadores mapa + tabla detalle
→ PDF: seccion "Rutas de transporte publico disponibles"
→ Export: GeoJSON de paradas cercanas
Estrategia de Carga: Filtrar en Origen
REGLAS DE ORO:
- No descargar todo el GTFS — un feed de ciudad puede pesar 50-200 MB. Solo extrae paradas cercanas al punto de interés del usuario.
- El usuario elige el operador — muestra catálogo de operadores por ciudad, el usuario selecciona uno, solo entonces descargas/cargas su GTFS.
- Múltiples fuentes, por orden de prioridad:
- Cache simulado (JSON embebido para demo)
- localStorage (datos previamente cargados)
- ZIP subido por usuario (el método más práctico)
- Proxy servidor (descarga desde URL pública)
Patrón: Catálogo de Operadores por Ciudad
const EMPRESAS = {
madrid: [
{ id: 'emt-madrid', nombre: 'EMT Madrid', modo: 'bus', lineas: 217,
cacheable: true,
gtfsUrl: 'https://opendata.emtmadrid.es/GTFS/Madrid' },
{ id: 'metro-madrid', nombre: 'Metro de Madrid', modo: 'metro', lineas: 13,
cacheable: false }
],
barcelona: [
{ id: 'tmb-bus', nombre: 'TMB (Autobus)', modo: 'bus', lineas: 107, cacheable: false }
],
};
function detectarCiudad(displayName) {
}
Patrón: Búsqueda por Proximidad (Haversine)
Solo extraer paradas dentro de un radio del punto de origen (default 2 km):
function findStopsNear(lat, lng, radiusKm = 2) {
const results = [];
for (const stop of stops) {
const dist = haversine(lat, lng, stop.stop_lat, stop.stop_lon);
if (dist <= radiusKm) {
const routeIds = stopRoutes[stop.stop_id] || [];
results.push({ stop, distanciaKm: dist, routes: routeIds });
}
}
results.sort((a, b) => a.distanciaKm - b.distanciaKm);
return results;
}
function haversine(lat1, lon1, lat2, lon2) {
const R = 6371;
const dLat = (lat2 - lat1) * Math.PI / 180;
const dLon = (lon2 - lon1) * Math.PI / 180;
const a = Math.sin(dLat/2)**2 +
Math.cos(lat1*Math.PI/180) * Math.cos(lat2*Math.PI/180) * Math.sin(dLon/2)**2;
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
Patrón: Ruteo Inferido (sin stop_times)
Cuando no hay stop_times.txt ni trips.txt, inferir rutas por coincidencia semántica de nombres:
for (const route of data.routes) {
const routeWords = route.route_long_name.toLowerCase();
for (const stop of data.stops) {
for (const word of routeWords.split(/[\s/]+/)) {
if (word.length > 3 && stop.stop_name.toLowerCase().includes(word)) {
stopRoutes[stop.stop_id].add(route.route_id);
}
}
}
}
const centralStops = stops.filter(s =>
s.stop_name.includes('Sol') || s.stop_name.includes('Gran Via') || ...);
Patrón: Múltiples Fuentes de Datos
1. Cache simulado (JSON embebido)
function cargarDesdeCache(cacheData, operador, ciudad) {
construirIndiceStopRoutes(cacheData);
guardarEstado(operador, ciudad);
persistirEnLocalStorage(ciudad, operador, data);
}
2. ZIP subido por usuario
async function cargarDesdeZip(file, operador, ciudad) {
const zip = await JSZip.loadAsync(file);
const stops = parsearCSV(await leerCSVZip(zip, 'stops.txt'));
const routes = parsearCSV(await leerCSVZip(zip, 'routes.txt'));
const trips = parsearCSV(await leerCSVZip(zip, 'trips.txt').catch(()=>''));
const stopTimes = parsearCSV(await leerCSVZip(zip, 'stop_times.txt').catch(()=>''));
}
3. Proxy servidor (para CORS)
if (req.url.startsWith('/gtfs-download') && req.method === 'POST') {
const { url } = JSON.parse(body);
https.get(url, { headers: { 'User-Agent': 'App/1.0' } }, (proxyRes) => {
const chunks = [];
proxyRes.on('data', chunk => chunks.push(chunk));
proxyRes.on('end', () => {
res.end(Buffer.concat(chunks));
});
});
}
Patrón: Integración con Mapa Leaflet
const iconoParada = L.divIcon({
html: '<div style="width:10px;height:10px;background:#a855f7;border:2px solid #fff;border-radius:50%;box-shadow:0 1px 4px rgba(0,0,0,0.3)"></div>',
iconSize: [10, 10], iconAnchor: [5, 5], className: ''
});
for (const item of stopsNear) {
L.marker([item.stop.stop_lat, item.stop.stop_lon], { icon: iconoParada })
.bindPopup(`${item.stop.stop_name} (${item.distanciaKm.toFixed(2)} km)<br>${item.routes.length} rutas`)
.addTo(capaParadas);
}
Patrón: Integración con PDF (jsPDF)
Sección en informes PDF con:
- Tabla de paradas cercanas (nombre, distancia, líneas que sirven)
- Líneas destacadas con paradas que cubren
- Operador, fuente de datos y modo predominante
if (gtfsData && gtfsData.totalStops > 0) {
}
Patrón: Exportación GeoJSON
function exportarParadasGeoJSON() {
const features = stops.map(s => ({
type: 'Feature',
geometry: { type: 'Point', coordinates: [s.stop_lon, s.stop_lat] },
properties: { stop_id: s.stop_id, stop_name: s.stop_name }
}));
return {
type: 'FeatureCollection',
features,
properties: { fuente: `TimeIneco GTFS — ${operador}`, totalStops: features.length }
};
}
Patrón: Resumen para UI
function getRouteSummary(stopsNear) {
const rutasUnicas = new Map();
for (const item of stopsNear) {
for (const r of item.routes) {
if (!rutasUnicas.has(r.id)) rutasUnicas.set(r.id, { ...r, paradas: [] });
rutasUnicas.get(r.id).paradas.push(item.stop.stop_name);
}
}
return {
operador, ciudad,
totalStops: stopsNear.length,
totalRoutes: rutasUnicas.size,
rutas: [...rutasUnicas.values()],
modoPredominante, datosFuente
};
}
CSV Parser (con soporte de comillas)
function parsearCSV(text) {
const lines = text.trim().split('\n');
if (lines.length < 2) return [];
const headers = lines[0].split(',').map(h => h.trim());
const result = [];
for (let i = 1; i < lines.length; i++) {
const values = parsearLineaCSV(lines[i]);
if (values.length !== headers.length) continue;
const row = {};
for (let j = 0; j < headers.length; j++) row[headers[j]] = values[j] || '';
result.push(row);
}
return result;
}
function parsearLineaCSV(line) {
const result = []; let current = ''; let inQuotes = false;
for (let i = 0; i < line.length; i++) {
if (line[i] === '"') inQuotes = !inQuotes;
else if (line[i] === ',' && !inQuotes) { result.push(current.trim()); current = ''; }
else current += line[i];
}
result.push(current.trim());
return result;
}
Implementación Recomendada en Hermes
- Usar
JSZip (CDN en index.html) para descompresión ZIP del lado cliente
- Catálogo de operadores como constante JS (con cacheable flag)
- Motor GTFS:
gtfs-engine.js con estado global, findStopsNear, getRouteSummary
- Panel NAP:
nap.js como facade de UI con manejo de eventos delegados
- Mapa: marcadores Leaflet con divIcon y popups informativos
- PDF: jsPDF + autoTable, colores #a855f7 para cabeceras GTFS
- Cache en localStorage con prefijo
timeineco_gtfs_
- Evento
gtfs:loaded para sincronizar carga de GTFS con mapa
Scripts del skill
scripts/server-gtfs.py — Servidor mínimo con /api/zips para auto-carga de ZIPs. python server-gtfs.py desde la raiz del proyecto.
Patrón: Auto-carga de ZIPs desde servidor local (v3.0)
NUEVO en 2026-06-23: En vez de arrastrar ZIPs manualmente, el visor se conecta a un servidor mínimo que lista los ZIPs disponibles en data/ y los carga automáticamente.
Servidor mínimo (server.py)
import http.server, json, os
from pathlib import Path
class GTFSServer(http.server.SimpleHTTPRequestHandler):
def do_GET(self):
if self.path == '/api/zips':
self.serve_zips_list()
return
super().do_GET()
def serve_zips_list(self):
zips = []
for root, dirs, files in os.walk(DATA_DIR):
for f in files:
if f.endswith('.zip'):
full_path = Path(root) / f
rel_path = full_path.relative_to(BASE_DIR)
zips.append({
"name": f, "path": str(rel_path).replace("\\", "/"),
"size": full_path.stat().st_size,
"size_human": f"{full_path.stat().st_size / 1024 / 1024:.1f} MB"
})
zips.sort(key=lambda x: x["name"])
self.send_response(200)
self.send_header('Content-Type', 'application/json')
self.send_header('Access-Control-Allow-Origin', '*')
self.end_headers()
self.wfile.write(json.dumps(zips, indent=2).encode())
Auto-carga en el cliente
async function autoLoadZips() {
let zipsList = [];
try { const resp = await fetch('/api/zips'); if (resp.ok) zipsList = await resp.json(); } catch (e) {}
if (zipsList.length === 0) return;
const MAX_ZIPS = 25;
const toLoad = zipsList.slice(0, MAX_ZIPS);
for (const zf of toLoad) {
const resp = await fetch('/' + zf.path);
const blob = await resp.blob();
const zip = await JSZip.loadAsync(blob);
await processGTFS(zip, zf.name);
}
}
Launcher para Windows (.bat)
@echo off
chcp 65001 >nul
python --version >nul 2>&1 || (echo [ERROR] Python no encontrado & pause & exit /b 1)
cd visor
python server.py
Patrón: Panel de horarios desplegable
Al hacer clic en una parada → panel deslizante desde abajo con rutas y horarios.
function showSchedulePanel(stop) {
const routesData = stop.routes.map(rid => allRoutes[rid]).filter(Boolean);
const panel = document.getElementById('schedulePanel');
panel.innerHTML = htmlContent;
panel.classList.add('open');
}