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.

معلومات المصدر

المستودع
Ntizar/MasterMind
آخر نشاط في المصدر
٤ سبتمبر ٢٠٢٦ في ١٠:٢٧
لغة SKILL.md المكتشفة
الإسبانية
النجوم
٢
التفرعات
٠

خيارات التثبيت

يُحدَّد Prompt الذي يراجع المصدر أولًا بشكل افتراضي. يمكنك التبديل إلى أمر مباشر أو تنزيل نسخة محلية.

مراجعة ملفات المصدر

اقرأ SKILL.md وأي ملفات مرافقة يعرضها SkillsMP قبل أن تقرر التثبيت.

مستكشف الملفات
3 ملفات

عرض SKILL.md

SKILL.md
تعليمات المصدر · معاينة للقراءة فقط
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` ---
عرض على GitHub
ملف SKILL.md هذا كبير جدا، لذلك يعرض SkillsMP القسم الاول فقط هنا. عرض على GitHub