- 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](https://github.com/Ntizar/nap-dashboard) (API NAP España) y [TimeIneco](https://timeineco-ntizar-ntizar.apps.nan.builders/) 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:**
1. **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.
2. **El usuario elige el operador** — muestra catálogo de operadores por ciudad, el usuario selecciona uno, solo entonces descargas/cargas su GTFS.
3. **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
```javascript
const EMPRESAS = {
madrid: [
{ id: 'emt-madrid', nombre: 'EMT Madrid', modo: 'bus', lineas: 217,
cacheable: true, // cacheable = datos simulados embebidos
gtfsUrl: 'https://opendata.emtmadrid.es/GTFS/Madrid' },
{ id: 'metro-madrid', nombre: 'Metro de Madrid', modo: 'metro', lineas: 13,
cacheable: false } // requiere subida de ZIP
],
barcelona: [
{ id: 'tmb-bus', nombre: 'TMB (Autobus)', modo: 'bus', lineas: 107, cacheable: false }
],
// ~12+ ciudades espanolas con operadores catalogados
};
function detectarCiudad(displayName) {
// 1. Buscar en EMPRESAS
// 2. Fallback: matchear contra lista de ~50 ciudades
// 3. Si no hay match, mostrar opcion de subir GTFS manualmente
}
```
## Patrón: Búsqueda por Proximidad (Haversine)
Solo extraer paradas dentro de un radio del punto de origen (default 2 km):
```javascript
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:
```javascript
// Modo 1: Coincidencia de palabras entre route_long_name y stop_name
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);
}
}
}
}
// Modo 2: Fallback — paradas céntricas reciben rutas base
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)
```javascript
function cargarDesdeCache(cacheData, operador, ciudad) {
// cacheData: { routes, stops, stop_times?, trips?, shapes?, _meta }
construirIndiceStopRoutes(cacheData);
guardarEstado(operador, ciudad);
persistirEnLocalStorage(ciudad, operador, data);
}
```
### 2. ZIP subido por usuario
```javascript
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(()=>''));
// Construir indice stop→routes via trips+stopTimes
// Cargar en estado y cachear en localStorage
}
```
### 3. Proxy servidor (para CORS)
```javascript
// server.mjs
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)); // Streamear ZIP al cliente
});
});
}
```
## Patrón: Integración con Mapa Leaflet
```javascript
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
```javascript
if (gtfsData && gtfsData.totalStops > 0) {
// Tabla con autoTable: cabecera morada (#a855f7)
// Columnas: Parada | Distancia | Lineas
// Max 12 paradas, 10 lineas destacadas
}
```
## Patrón: Exportación GeoJSON
```javascript
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
```javascript
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)
```javascript
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
1. Usar `JSZip` (CDN en index.html) para descompresión ZIP del lado cliente
2. Catálogo de operadores como constante JS (con cacheable flag)
3. Motor GTFS: `gtfs-engine.js` con estado global, findStopsNear, getRouteSummary
4. Panel NAP: `nap.js` como facade de UI con manejo de eventos delegados
5. Mapa: marcadores Leaflet con divIcon y popups informativos
6. PDF: jsPDF + autoTable, colores #a855f7 para cabeceras GTFS
7. Cache en localStorage con prefijo `timeineco_gtfs_`
8. 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)
```python
# visor/server.py — servidor mínimo HTTP con /api/zips
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
```javascript
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)
```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.
```javascript
function showSchedulePanel(stop) {
const routesData = stop.routes.map(rid => allRoutes[rid]).filter(Boolean);
// Filtros de ruta como botones clickeables
// Cards expandibles por ruta con tabla de horarios
const panel = document.getElementById('schedulePanel');
panel.innerHTML = htmlContent;
panel.classList.add('open');
}
```
Ver no GitHub