Visualización GTFS Client-Side
Resumen
Renderizar archivos GTFS completos (rutas, paradas, horarios) directamente en el navegador sin necesidad de backend. Parsea ZIP, procesa CSV y renderiza en Leaflet/Canvas.
Cuándo usar
- Visor de GTFS sin servidor
- Explorar feeds GTFS descargados localmente
- Dashboard de transporte que carga GTFS bajo demanda
Patrón de uso
// 1. Cargar ZIP GTFS en el navegador
import JSZip from 'jszip';
async function loadGTFS(zipUrl) {
const response = await fetch(zipUrl);
const zip = await JSZip.loadAsync(await response.arrayBuffer());
// Parsear archivos CSV del ZIP
const stopsCSV = await zip.file('stops.txt').async('text');
const routesCSV = await zip.file('routes.txt').async('text');
const stopTimesCSV = await zip.file('stop_times.txt').async('text');
const tripsCSV = await zip.file('trips.txt').async('text');
const shapesCSV = await zip.file('shapes.txt').async('text');
return {
stops: parseCSV(stopsCSV),
routes: parseCSV(routesCSV),
stopTimes: parseCSV(stopTimesCSV),
trips: parseCSV(tripsCSV),
shapes: parseCSV(shapesCSV)
};
}
// 2. Renderizar en Leaflet
const map = L.map('map').setView([40.4, -3.7], 12);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png').addTo(map);
// Renderizar shapes de rutas
gtfs.shapes.forEach(shape => {
const latlngs = gtfs.shapes
.filter(s => s.shape_id === shape.shape_id)
.sort((a, b) => a.shape_pt_sequence - b.shape_pt_sequence)
.map(s => [parseFloat(s.shape_pt_lat), parseFloat(s.shape_pt_lon)]);
L.polyline(latlngs, { color: routeColor, weight: 3 }).addTo(map);
});
// Renderizar paradas
gtfs.stops.forEach(stop => {
L.circleMarker([stop.stop_lat, stop.stop_lon], {
radius: 4, fillColor: '#2563eb', fillOpacity: 0.8
}).addTo(map).bindPopup(stop.stop_name);
});
// 3. Filtrar horarios por ruta y hora
function getSchedule(routeId, hour) {
const trips = gtfs.trips.filter(t => t.route_id === routeId);
return gtfs.stopTimes
.filter(st => trips.some(t => t.trip_id === st.trip_id))
.filter(st => st.arrival_time.startsWith(hour))
.sort((a, b) => a.arrival_time.localeCompare(b.arrival_time));
}
Elegir proveedor de tiles: Leaflet vs Mapbox
REGLA: Tiles 100% gratis sin API = tile.openstreetmap.org. CartoDB/CARTO tiles aunque parezcan gratis tienen rate limits y pueden bloquear. Mapbox requiere token y tiene tier gratuito limitado.
| Proveedor | URL | Gratis? | Token? | Nota |
|---|---|---|---|---|
| OpenStreetMap | tile.openstreetmap.org/{z}/{x}/{y}.png |
✅ Siempre | No | Recomendado para todo |
| CARTO Dark | basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png |
Parcial | No | Rate limits, puede bloquear |
| Mapbox | api.mapbox.com/... |
Tier free | Sí | 50K loads/mes,后 pago |
Para proyectos David: Siempre OSM tiles. Si el usuario quiere dark mode, usar un CSS filter brightness(0.6) invert(1) contrast(3) hue-rotate(200deg) sobre OSM, o el plugin leaflet-tilelayer-colorfilter.
Añadir personajes 3D al mapa (sin reemplazar)
Cuando el usuario pide "hacer los buses en 3D sobre el mapa", NO significa reemplazar el mapa con Three.js. Significa mantener el mapa y añadir elementos 3D encima. Reemplazar el mapa frustra al usuario — pierde búsqueda, popups, controles, y el contexto geográfico.
Enfoque recomendado: Leaflet + SVG divIcon con rotación
Usar L.divIcon con SVG inline para personajes que giran según la dirección de movimiento:
// Cálculo de bearing geográfico entre dos puntos [lng, lat]
function bearing(a, b) {
const lon1 = a[0] * Math.PI / 180, lat1 = a[1] * Math.PI / 180
const lon2 = b[0] * Math.PI / 180, lat2 = b[1] * Math.PI / 180
const dLon = lon2 - lon1
const y = Math.sin(dLon) * Math.cos(lat2)
const x = Math.cos(lat1) * Math.sin(lat2) - Math.sin(lat1) * Math.cos(lat2) * Math.cos(dLon)
return ((Math.atan2(y, x) * 180 / Math.PI) + 360) % 360
}
// Pac-Man SVG rotado según bearing
function pacmanIcon(color = '#FFD700', bearingDeg = 0, size = 26) {
return L.divIcon({
className: 'pac-icon',
html: `<div style="
width:${size}px;height:${size}px;
transform:rotate(${bearingDeg}deg);
filter:drop-shadow(0 0 6px ${color}99);
">
<svg viewBox="0 0 100 100" width="${size}" height="${size}">
<circle cx="50" cy="50" r="46" fill="${color}"/>
<path d="M50,50 L98,22 A46,46 0 0,1 98,78 Z" fill="#000"/>
<circle cx="62" cy="35" r="6" fill="#000"/>
</svg>
</div>`,
iconSize: [size, size],
iconAnchor: [size/2, size/2],
})
}
// Fantasma SVG con domo + ondas + ojos
function ghostIcon(color = '#FF0000', bearingDeg = 0, size = 26) {
return L.divIcon({
className: 'ghost-icon',
html: `<div style="
width:${size}px;height:${size}px;
transform:rotate(${bearingDeg}deg);
filter:drop-shadow(0 0 6px ${color}88);
">
<svg viewBox="0 0 100 100" width="${size}" height="${size}">
<path d="M10,95 Q10,70 20,70 Q30,70 30,95 Q30,70 40,70 Q50,70 50,95
Q50,70 60,70 Q70,70 70,95 Q70,70 80,70 Q90,70 90,95
L90,40 Q90,10 50,10 Q10,10 10,40 Z" fill="${color}"/>
<circle cx="35" cy="40" r="8" fill="white"/>
<circle cx="65" cy="40" r="8" fill="white"/>
<circle cx="38" cy="42" r="4" fill="#2244AA"/>
<circle cx="68" cy="42" r="4" fill="#2244AA"/>
</svg>
</div>`,
iconSize: [size, size],
iconAnchor: [size/2, size/2],
})
}
// Calcular bearing desde las coordenadas de la ruta
const sh = route.shapes[0]
const idx = Math.floor(progress * (sh.coordinates.length - 1))
const bear = bearing(sh.coordinates[idx], sh.coordinates[Math.min(idx+1, sh.coordinates.length-1)])
// Crear icono rotado
const icon = useGhost
? ghostIcon('#FF0000', bear)
: pacmanIcon('#FFD700', bear)
L.marker([lat, lng], { icon }).addTo(map)
Por qué SVG divIcon > Canvas 2D icons
- Rotación nativa:
transform: rotate(Xdeg)en CSS — Canvas 2D requiere rotar todo el contexto - Calidad: SVG escala perfecto a cualquier zoom, Canvas pixela
- Interactividad: CSS hover/transition funciona directamente
- Flexibilidad: Se puede animar con CSS keyframes (boca abierta/cerrada)
Canvas 2D (solo para Mapbox)
Si el proyecto usa Mapbox (requiere token), usar Canvas 2D + map.addImage():
function createPacmanIcon(size = 48, color = '#FFD700') {
const c = document.createElement('canvas')
c.width = size; c.height = size
const ctx = c.getContext('2d')
const cx = size / 2, cy = size / 2, r = size / 2 - 2
ctx.fillStyle = color
ctx.beginPath()
ctx.moveTo(cx, cy)
ctx.arc(cx, cy, r, 0.25 * Math.PI, 1.75 * Math.PI)
ctx.closePath()
ctx.fill()
ctx.fillStyle = '#000'
ctx.beginPath()
ctx.arc(cx + 2, cy - r * 0.38, size * 0.06, 0, Math.PI * 2)
ctx.fill()
return ctx.getImageData(0, 0, size, size)
}
map.addImage('pacman', createPacmanIcon(48, '#FFD700'))
Ver references/mapbox-css-3d-overlay.md para el patrón completo Mapbox.
Efectos 3D con CSS
Para profundidad visual sin Three.js:
.pac-icon, .ghost-icon {
transition: transform 0.15s, filter 0.15s;
}
.pac-icon:hover, .ghost-icon:hover {
transform: scale(1.3) rotateY(15deg);
filter: brightness(1.3) drop-shadow(0 0 12px var(--glow));
}
Aislar ruta seleccionada
Cuando el usuario selecciona una línea, ocultar las demás para que el mapa no se sature:
// Al seleccionar una ruta:
if (sel) {
// Ocultar todas las líneas de ruta
routeLayer.eachLayer(layer => map.removeLayer(layer))
// Mostrar solo la ruta seleccionada (brillante)
sel.shapes.forEach(sh => {
const latlngs = sh.coordinates.map(c => [c[1], c[0]])
L.polyline(latlngs, { color: '#FFD700', weight: 10, opacity: 0.25 }).addTo(hlLayer)
L.polyline(latlngs, { color: '#FFD700', weight: 3, opacity: 0.9 }).addTo(hlLayer)
})
// Enfocar mapa en la ruta
const bounds = L.latLngBounds(sel.shapes.flatMap(sh =>
sh.coordinates.map(c => [c[1], c[0]])
))
map.fitBounds(bounds, { padding: [60, 60], duration: 600 })
}
// Al deseleccionar:
if (!sel) {
routeLayer.eachLayer(layer => layer.addTo(map))
hlLayer.clearLayers()
}
Hora real con timezone
Para mostrar la hora de una ciudad específica (no la del navegador):
function ciudadNow(timeZone = 'Europe/Madrid') {
const now = new Date()
const parts = new Intl.DateTimeFormat('en-GB', {
hour: 'numeric', minute: 'numeric', second: 'numeric',
hour12: false, timeZone,
}).formatToParts(now)
const h = parseInt(parts.find(p => p.type === 'hour').value)
const m = parseInt(parts.find(p => p.type === 'minute').value)
const s = parseInt(parts.find(p => p.type === 'second').value)
return h * 3600 + m * 60 + s
}
// Actualizar cada segundo (sin aceleración)
useEffect(() => {
const interval = setInterval(() => setSimTime(ciudadNow()), 1000)
return () => clearInterval(interval)
}, [])
Pitfalls
- NO reemplazar el mapa: Si el usuario tiene un mapa funcional y pide "3D", es overlay, no reemplazo. Perder búsqueda, popups, controles y contexto geográfico frustra al usuario SIEMPRE.
- NO usar CartoDB tiles como "gratis": Aunque no piden token, tienen rate limits. Si el usuario dice "algo libre", usar
tile.openstreetmap.orgque es garantizado gratis. - Rotación de personajes: SVG divIcon con
transform: rotate(bearing deg)es la forma más limpia. Canvas 2D no soporta rotación CSS nativa. - Aislamiento de ruta: Si el usuario selecciona una línea, ocultar las demás. Mostrar 200+ rutas a la vez satura el mapa y lo hace lento.
- ZIP parsing: JSZip carga todo el ZIP en memoria. Feeds grandes (>50MB) pueden ser lentos.
- CSV parsing: Usar PapaParse para streams grandes. No usar split('\n') — hay saltos de línea dentro de strings.
- Shapes: No todos los feeds tienen shapes.txt. Si no, generar ruta desde stop_times secuencial.
- Memory: Feeds grandes pueden agotar memoria del navegador. Considerar Web Workers.
- Time format: GTFS usa HH:MM:SS (puede pasar de 24:00:00 para servicios nocturnos).
Referencias
- gtfs-viz: https://github.com/gabrielAHN/gtfs-viz
- JSZip: https://stuk.github.io/jszip/
- PapaParse: https://www.papaparse.com/
- Patrón Mapbox + Canvas 3D icons:
references/mapbox-css-3d-overlay.md - Patrón Leaflet + SVG divIcon con rotación:
references/leaflet-svg-divicon-pattern.md
Hecho con ❤️ por David Antizar