# 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.

- Skill: `ntizar/gtfs-client-side-viz` (Agent Skill, multi-file: 3 files)
- Install (CLI): `npx skillmds@latest add ntizar/gtfs-client-side-viz`
- Raw SKILL.md: https://api.skillmd.com/api/skills/ntizar/gtfs-client-side-viz/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: AI & ML
- Author: ntizar (https://skillmd.com/u/ntizar)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/ntizar/gtfs-client-side-viz

---


# 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`

---

**Hecho con ❤️ por David Antizar**

