Skip to main content

gtfs-client-side-viz

Visualización de GTFS a escala en el cliente sin backend. Inspirado en gabrielAHN/gtfs-viz (⭐50). Renderiza rutas, paradas y horarios directamente en el navegador.

Jump to install

Source facts

Repository
Ntizar/MasterMind
Last source activity
September 4, 2026 at 10:27
Detected SKILL.md language
Spanish
Stars
2
Forks
0

Install options

The review-first prompt is selected by default. You can switch to a direct command or download a local copy.

Review the source files

Read SKILL.md and any companion files shown by SkillsMP before deciding whether to install.

File Explorer
3 files

Showing SKILL.md

SKILL.md
Source instructions · Read-only preview
name
gtfs-client-side-viz
version
1.0.0
description
Visualización de GTFS a escala en el cliente sin backend. Inspirado en gabrielAHN/gtfs-viz (⭐50). Renderiza rutas, paradas y horarios directamente en el navegador.
tags
["gtfs","visualization","client-side","browser","transit","leaflet"]
# 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 ```javascript // 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**: ```javascript // 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()`: ```javascript 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: ```css .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: ```javascript // 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): ```javascript 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.org` que 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` ---
View on GitHub
This SKILL.md is very large, so SkillsMP previews the first section here. View on GitHub