// Data.jsx — lines, stations (rich), news, reports, crossings // Crossings map — which station letters intersect what other lines const CROSSINGS = { // Línea A 'Perú': ['D','E'], 'Piedras': ['C'], 'Lima': ['C'], 'Plaza Miserere': ['H'], // Línea B 'L. N. Alem': ['E'], 'Carlos Pellegrini': ['C','D'], 'Pueyrredón': ['H','D'], 'Callao-B': ['D'], 'Federico Lacroze': ['U'], // Línea C 'Diagonal Norte': ['B','D'], 'Avenida de Mayo': ['A'], 'Independencia': ['E'], 'Retiro': ['E'], // Línea D 'Catedral': ['A','E'], '9 de Julio': ['B','C'], 'Callao': ['B'], // Línea E 'Bolívar': ['A','D'], 'Jujuy': ['H'], 'Plaza de los Virreyes': ['P'], // Línea H 'Corrientes': ['B'], 'Once': ['A'], // Línea U (Urquiza) 'Lacroze': ['B'], }; // Amenities per station id const STATION_DEFAULT = { baths: false, elevator: false, escalator: true, wifi: true, accessible: false, office: false, atm: false }; // Rich station records (others fall back to defaults + derived crossings) const STATION_DETAIL = { 'Plaza de Mayo': { baths: true, elevator: true, accessible: true, wifi: true, office: true, atm: true, escalator: true, openings: 4, singleDir: 0 }, 'Perú': { baths: true, elevator: true, accessible: true, wifi: true, escalator: true, openings: 3, singleDir: 1 }, 'Sáenz Peña': { baths: false, escalator: true, wifi: true, openings: 2, singleDir: 2 }, 'Congreso': { baths: true, elevator: false, accessible: false, escalator: true, openings: 4, singleDir: 1 }, 'Plaza Miserere': { baths: true, elevator: true, accessible: true, office: true, escalator: true, openings: 4, singleDir: 0 }, 'San Pedrito': { baths: true, elevator: true, accessible: true, office: true, atm: true, escalator: true, openings: 2, singleDir: 0 }, 'L. N. Alem': { baths: true, elevator: true, accessible: true, escalator: true, openings: 3, singleDir: 1 }, 'Carlos Pellegrini':{ baths: true, elevator: true, accessible: true, escalator: true, wifi: true, openings: 6, singleDir: 0 }, 'Pueyrredón': { baths: true, elevator: true, accessible: true, escalator: true, openings: 4, singleDir: 1 }, 'Medrano': { baths: false, escalator: true, openings: 2, singleDir: 1 }, 'Federico Lacroze':{ baths: true, elevator: true, accessible: true, office: true, atm: true, escalator: true, openings: 4, singleDir: 0 }, 'J. M. de Rosas': { baths: true, elevator: true, accessible: true, office: true, escalator: true, openings: 2, singleDir: 0 }, 'Retiro': { baths: true, elevator: true, accessible: true, office: true, atm: true, escalator: true, openings: 5, singleDir: 0 }, 'Constitución': { baths: true, elevator: true, accessible: true, office: true, atm: true, escalator: true, openings: 4, singleDir: 0 }, 'Catedral': { baths: true, elevator: true, accessible: true, escalator: true, openings: 3, singleDir: 0 }, '9 de Julio': { baths: false, elevator: false, escalator: true, openings: 4, singleDir: 2 }, 'Congreso de Tucumán': { baths: true, elevator: true, accessible: true, office: true, escalator: true, openings: 3, singleDir: 0 }, 'Plaza Italia': { baths: true, elevator: true, accessible: true, escalator: true, openings: 4, singleDir: 0 }, 'Bolívar': { baths: true, elevator: true, accessible: true, escalator: true, openings: 3, singleDir: 0 }, 'Plaza de los Virreyes': { baths: true, elevator: true, accessible: true, office: true, escalator: true, openings: 2, singleDir: 0 }, 'Facultad de Derecho': { baths: true, elevator: true, accessible: true, office: true, escalator: true, openings: 2, singleDir: 0 }, 'Hospitales': { baths: true, elevator: true, accessible: true, office: true, escalator: true, openings: 2, singleDir: 0 }, 'Corrientes': { baths: false, elevator: true, accessible: true, escalator: true, openings: 4, singleDir: 0 }, 'Once': { baths: true, elevator: true, accessible: true, office: true, atm: true, escalator: true, openings: 5, singleDir: 0 }, }; // Stations closed today — fuente futura: parsing de anuncios de Emova (X/Twitter). // Mientras no haya integración real, se deja vacío para no mostrar datos inventados. const CLOSED_TODAY = []; const CLOSED_REASONS = {}; // Stations with extended-hours on Línea B (service 2 extra hours, stops at fewer stations) const B_EXTENDED_STATIONS = ['L. N. Alem', 'Carlos Pellegrini', 'Callao', 'Pueyrredón', 'Medrano', 'Federico Lacroze']; const LINES = [ { id: 'A', name: 'Línea A', head1: 'Plaza de Mayo', head2: 'San Pedrito', travelTime: 25, color1: '#58BBD9', color2: '#0078B1', stations: ['Plaza de Mayo','Perú','Piedras','Lima','Sáenz Peña','Congreso','Pasco','Alberti','Plaza Miserere','Loria','Castro Barros','Río de Janeiro','Acoyte','Primera Junta','Puán','Carabobo','San José de Flores','San Pedrito'] }, { id: 'B', name: 'Línea B', head1: 'L. N. Alem', head2: 'J. M. de Rosas', travelTime: 28, color1: '#E13A40', color2: '#A72126', stations: ['L. N. Alem','Florida','Carlos Pellegrini','Uruguay','Callao','Pasteur','Pueyrredón','Carlos Gardel','Medrano','Ángel Gallardo','Malabia','Dorrego','Federico Lacroze','Tronador','De los Incas','Echeverría','J. M. de Rosas'] }, { id: 'C', name: 'Línea C', head1: 'Retiro', head2: 'Constitución', travelTime: 12, color1: '#2285BB', color2: '#09517D', stations: ['Retiro','General San Martín','Lavalle','Diagonal Norte','Avenida de Mayo','Moreno','Independencia','San Juan','Constitución'] }, { id: 'D', name: 'Línea D', head1: 'Catedral', head2: 'Congreso de Tucumán', travelTime: 22, color1: '#008B6E', color2: '#006515', stations: ['Catedral','9 de Julio','Tribunales','Callao','Facultad de Medicina','Pueyrredón','Agüero','Bulnes','Scalabrini Ortiz','Plaza Italia','Palermo','Ministro Carranza','Olleros','José Hernández','Juramento','Congreso de Tucumán'] }, { id: 'E', name: 'Línea E', head1: 'Retiro', head2: 'Plaza de los Virreyes', travelTime: 24, color1: '#87307E', color2: '#52267D', stations: ['Retiro','Catalinas','Correo Central','Bolívar','Belgrano','Independencia','San José','Entre Ríos','Pichincha','Jujuy','Urquiza','Boedo','Avenida La Plata','José M. Moreno','Emilio Mitre','Medalla Milagrosa','Varela','Plaza de los Virreyes'] }, { id: 'H', name: 'Línea H', head1: 'Facultad de Derecho', head2: 'Hospitales', travelTime: 20, color1: '#FFCC00', color2: '#D4A200', stations: ['Facultad de Derecho','Las Heras','Santa Fe','Córdoba','Corrientes','Once','Venezuela','Humberto 1°','Inclán','Caseros','Parque Patricios','Hospitales'] }, { id: 'U', name: 'Línea Urquiza', head1: 'Lacroze', head2: 'General Lemos', travelTime: 55, color1: '#FF8C3A', color2: '#C85A0F', stations: ['Lacroze','J. B. Justo','Tronador','Los Incas','Artigas','Devoto','Lourdes','El Libertador','Pueyrredón','L. M. Campos','Hipólito Yrigoyen','V. Ocampo','Martín Coronado','Pablo Podestá','Lemos'] }, { id: 'P', name: 'Premetro', head1: 'Pl. de los Virreyes', head2: 'Gral. Savio', travelTime: 38, color1: '#FFC85C', color2: '#E79A14', stations: ['Pl. de los Virreyes','Intendente Saguier','Balbastro','Mariano Acosta','Somellera','Ana María Janer','Fátima','Fernández de la Cruz','Presidente Illia','Parque de la Ciudad','Cecilia Grierson','Escalada','Pola','Larrazábal','Nicolás Descalzi','Gabino Ezeiza','Gral. Savio'] }, ]; const LINE_STATUS = { A: { state: 'normal', nextTrain: 3, freq: '4-6 min', freqSecs: 300, note: null }, B: { state: 'delay', nextTrain: 8, freq: '8-10 min', freqSecs: 540, note: 'Demoras por obras en Medrano' }, C: { state: 'normal', nextTrain: 2, freq: '3-5 min', freqSecs: 240, note: null }, D: { state: 'alert', nextTrain: 12, freq: '10-14 min', freqSecs: 720, note: 'Servicio limitado entre Callao y Plaza Italia' }, E: { state: 'normal', nextTrain: 4, freq: '5-7 min', freqSecs: 360, note: null }, H: { state: 'normal', nextTrain: 5, freq: '6-8 min', freqSecs: 420, note: null }, U: { state: 'normal', nextTrain: 7, freq: '8-12 min', freqSecs: 600, note: null }, P: { state: 'delay', nextTrain: 14, freq: '12-18 min', freqSecs: 900, note: 'Formaciones reducidas' }, }; // NEWS vacío — solo se muestran anuncios reales del CMS (notices.html) y los automáticos const NEWS = []; // User-generated reports — empty until real data comes from Supabase const REPORTS_SEED = []; const INCIDENT_TYPES = [ // delay, cut, overcrowd temporalmente desactivados { id: 'vandal', label: 'Vandalismo', labelEn: 'Vandalism', icon: 'warning' }, { id: 'access', label: 'Accesibilidad', labelEn: 'Accessibility', icon: 'accessibility' }, { id: 'safety', label: 'Seguridad', labelEn: 'Safety', icon: 'eye' }, { id: 'cleanliness', label: 'Limpieza', labelEn: 'Cleanliness', icon: 'shuffle' }, { id: 'other', label: 'Otro', labelEn: 'Other', icon: 'info' }, ]; // Gamification leaderboard const LEADERBOARD = [ { name: 'lucas_42', points: 482, rank: 1, avatar: 'L', badge: '🏆' }, { name: 'mariana.ok', points: 367, rank: 2, avatar: 'M', badge: '🥈' }, { name: 'juanma.ba', points: 291, rank: 3, avatar: 'J', badge: '🥉' }, { name: 'vos (Juan P.)', points: 164, rank: 12, avatar: 'J', badge: null, isMe: true }, { name: 'belu_subte', points: 155, rank: 13, avatar: 'B', badge: null }, ]; const SCHEMATIC = { viewBox: '0 0 1000 760', lines: { A: { color: '#0078B1', path: [[90,420],[200,420],[300,380],[420,380],[540,380],[640,380],[740,380],[840,380],[910,380]] }, B: { color: '#E13A40', path: [[560,420],[560,340],[560,280],[520,240],[460,200],[360,160],[260,120],[160,100]] }, C: { color: '#2285BB', path: [[490,180],[490,260],[490,340],[490,420],[490,500],[490,560]] }, D: { color: '#008B6E', path: [[590,420],[540,380],[480,340],[420,300],[360,260],[300,220],[240,180],[180,140],[120,110]] }, E: { color: '#87307E', path: [[510,180],[540,240],[560,320],[540,420],[480,480],[420,520],[340,560],[260,600],[180,640]] }, H: { color: '#F5B301', path: [[680,140],[680,220],[680,300],[640,380],[600,460],[600,540],[600,620]] }, U: { color: '#C85A0F', path: [[260,120],[200,80],[130,60],[60,40]], dashed: true }, P: { color: '#E79A14', path: [[180,640],[230,680],[290,700],[350,715],[410,725]], dashed: true }, } }; // Push notification defaults const PUSH_DEFAULTS = { // per-line push alerts — all OFF by default; user opts in per-line lines: { A:false, B:false, C:false, D:false, E:false, H:false, U:false, P:false }, // per-line schedule config (used when line is toggled on) lineConfig: { A:{days:[1,2,3,4,5],hourFrom:7,hourTo:22}, B:{days:[1,2,3,4,5],hourFrom:7,hourTo:22}, C:{days:[1,2,3,4,5],hourFrom:7,hourTo:22}, D:{days:[1,2,3,4,5],hourFrom:7,hourTo:22}, E:{days:[1,2,3,4,5],hourFrom:7,hourTo:22}, H:{days:[1,2,3,4,5],hourFrom:7,hourTo:22}, U:{days:[1,2,3,4,5],hourFrom:7,hourTo:22}, P:{days:[1,2,3,4,5],hourFrom:7,hourTo:22} }, // important alerts (strikes, full outages) — ON by default, user can disable in Settings generalAlerts: { enabled: true, days: [1,2,3,4,5,6,0] }, // news/updates — ON by default, user can disable in Settings news: { enabled: true, days: [1,2,3,4,5,6,0], hourFrom: 7, hourTo: 22 }, }; // ─── Announcements (feeds externos) ────────────────────────────────────────── // Modelo simple. Cada anuncio simula un post de una fuente pública: // id — id estable (para dismiss) // kind — 'status' | 'event' | 'strike' | 'weather' | 'advisory' // lineId — letra de línea si el post empieza con #LineaX (status), si no null // text — cuerpo del post (texto plano) // images — array de URLs (puede estar vacío) // url — link público a la fuente // source — { id: 'emova'|'smn'|'gcba'|'app'|'otro', label, handle? } // createdAt — ISO timestamp // // Los 'status' (#LineaX + texto) se consideran info de servicio normal y NO // se muestran como banner — alimentan el resumen de cada línea. El resto sí // se muestra como banner desestimable. // // Cadencia de chequeo (a implementar en edge function Supabase): // • Emova (X): una vez por día en horario valle (ej. 04:30 ART), y un // segundo chequeo a las 05:30 cuando arranca el servicio. // Cambios de estado ya se capturan por el feed de infosubte // (cron 30s), así que el scrape de X sirve solo para anuncios // largos: paros, eventos, obras programadas. // • SMN: chequeo cada 3 h durante el día (feed público de alertas). // • App news: manual (publicación desde dashboard interno). // // Por ahora NO hay push notifications — se muestran al abrir la app hasta que // el dominio definitivo esté activo y Firebase/APNs estén configurados. const SOURCES = { emova: { id: 'emova', label: 'Emova', handle: '@emova_ba', linkLabel: 'Ver en X.com' }, smn: { id: 'smn', label: 'SMN', linkLabel: 'Ver alerta SMN' }, gcba: { id: 'gcba', label: 'GCBA', linkLabel: 'Ver en buenosaires.gob.ar' }, app: { id: 'app', label: 'alSubte', linkLabel: 'Ver detalle' }, otro: { id: 'otro', label: 'Fuente', linkLabel: 'Ver fuente' }, }; const ANNOUNCEMENTS = [ { id: 'app-2026-04-21-welcome', kind: 'advisory', lineId: null, text: '¡Bienvenido a alSubte! Ahora podés ver el estado en vivo de todas las líneas, alertas de clima y eventos de la red.', images: [], url: 'https://alsubte.com.ar/', source: SOURCES.app, createdAt: new Date(Date.now() - 1 * 3600 * 1000).toISOString(), }, { id: 'emova-2026-04-20-extended-b', kind: 'event', lineId: 'B', text: 'Horario extendido en Línea B: este viernes abierta hasta las 01:00 para mayor conectividad. Últimos trenes con paradas reducidas en estaciones clave.', images: [], url: 'https://x.com/emova_ba/status/1781000000001', source: SOURCES.emova, createdAt: new Date(Date.now() - 2 * 86400 * 1000).toISOString(), }, { id: 'app-2026-04-15-newfeature', kind: 'advisory', lineId: null, text: 'Nueva función: ahora podés guardar tus líneas favoritas y recibir alertas personalizadas. Accede desde Configuración → Notificaciones.', images: [], url: 'https://alsubte.com.ar/', source: SOURCES.app, createdAt: new Date(Date.now() - 7 * 86400 * 1000).toISOString(), }, ]; // Etiqueta humana por tipo de anuncio const ANNOUNCEMENT_KIND = { strike: { label: 'Paro / gremial', icon: 'warning', tone: 'error' }, event: { label: 'Evento', icon: 'star', tone: 'info' }, weather: { label: 'Clima', icon: 'zap', tone: 'warn' }, advisory: { label: 'Aviso', icon: 'info', tone: 'info' }, status: { label: 'Estado', icon: 'check', tone: 'ok' }, }; // ¿Anuncio "trascendental" que merece banner principal? (todo menos status) function isMajorAnnouncement(a) { return a && a.kind !== 'status'; } // Anuncios vigentes — recientes (≤ maxAgeH horas) y no descartados. // dismissed = Set de ids que el usuario cerró. function getActiveAnnouncements(dismissed = new Set(), maxAgeH = 24) { const cutoff = Date.now() - maxAgeH * 3600 * 1000; return ANNOUNCEMENTS.filter(a => isMajorAnnouncement(a) && !dismissed.has(a.id) && new Date(a.createdAt).getTime() >= cutoff ).sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)); } // Histórico para sección "Novedades": últimos N (default 3) ≤ maxAgeDays días. function getAnnouncementHistory(n = 3, maxAgeDays = 3) { const cutoff = Date.now() - maxAgeDays * 86400 * 1000; return ANNOUNCEMENTS .filter(a => new Date(a.createdAt).getTime() >= cutoff) .sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)) .slice(0, n); } // localStorage helpers para tracking de dismissals const _DISMISS_KEY = 'alsubte:dismissedAnnouncements'; function loadDismissed() { try { return new Set(JSON.parse(localStorage.getItem(_DISMISS_KEY) || '[]')); } catch { return new Set(); } } function saveDismissed(set) { try { localStorage.setItem(_DISMISS_KEY, JSON.stringify([...set])); } catch {} } // "hace 3 h", "hace 25 min", etc. function timeAgoLabel(iso) { const diffMin = Math.max(0, Math.round((Date.now() - new Date(iso).getTime()) / 60000)); if (diffMin < 1) return 'recién'; if (diffMin < 60) return `hace ${diffMin} min`; const h = Math.round(diffMin / 60); if (h < 24) return `hace ${h} h`; const d = Math.round(h / 24); return `hace ${d} día${d > 1 ? 's' : ''}`; } // ─── Service hours per line (decimal hours, 24h local Buenos Aires) ────────── // Aproximación basada en el viejo cambiahoras.js. day type: 'wd' (lun-vie), // 'sat' (sábado), 'sun' (domingo y feriados). open siempre matutino, close // siempre nocturno (>open). Para B con horario extendido, 'closeExtD0' aplica // al sentido head1→head2 (L.N. Alem→J.M. de Rosas) y 'closeExtD1' al sentido // head2→head1 (J.M. de Rosas→L.N. Alem), solo para B_EXTENDED_STATIONS. // 'fri' cubre solo viernes (horario extendido viernes→sábado madrugada). // 'sat' cubre sábado (horario extendido sábado→domingo madrugada). const SERVICE_HOURS = { A: { wd: { open: 5.5, close: 22.83 }, fri: { open: 5.5, close: 22.83 }, sat: { open: 6.0, close: 22.83 }, sun: { open: 8.0, close: 22.33 } }, B: { wd: { open: 5.5, close: 22.83 }, fri: { open: 5.5, close: 22.83, closeExtD0: 1.5 /* 01:30 madrugada sábado */, closeExtD1: 1.0 /* 01:00 madrugada sábado */ }, sat: { open: 6.0, close: 22.83, closeExtD0: 1.5 /* 01:30 madrugada domingo */, closeExtD1: 1.0 /* 01:00 madrugada domingo */ }, sun: { open: 8.0, close: 22.33 } }, C: { wd: { open: 5.5, close: 22.83 }, fri: { open: 5.5, close: 22.83 }, sat: { open: 6.0, close: 22.83 }, sun: { open: 8.0, close: 22.33 } }, D: { wd: { open: 5.5, close: 22.83 }, fri: { open: 5.5, close: 22.83 }, sat: { open: 6.0, close: 22.83 }, sun: { open: 8.0, close: 22.33 } }, E: { wd: { open: 5.5, close: 22.83 }, fri: { open: 5.5, close: 22.83 }, sat: { open: 6.0, close: 22.83 }, sun: { open: 8.0, close: 22.33 } }, H: { wd: { open: 5.5, close: 22.83 }, fri: { open: 5.5, close: 22.83 }, sat: { open: 6.0, close: 22.83 }, sun: { open: 8.0, close: 22.33 } }, U: { wd: { open: 5.0, close: 23.0 }, fri: { open: 5.0, close: 23.0 }, sat: { open: 5.5, close: 23.0 }, sun: { open: 6.0, close: 22.5 } }, P: { wd: { open: 5.5, close: 22.0 }, fri: { open: 5.5, close: 22.0 }, sat: { open: 6.0, close: 22.0 }, sun: { open: 8.0, close: 21.5 } }, }; // Feriados: cache en memoria (llenado por loadHolidays, persistido en localStorage) let _HOLIDAYS = new Set(); // Set<'YYYY-MM-DD'> const _HOLIDAY_CACHE_KEY = 'alsubte:holidays'; const _HOLIDAY_CACHE_TTL = 7 * 86400 * 1000; // 7 días function _ymd(d) { const y = d.getFullYear(); const m = String(d.getMonth() + 1).padStart(2, '0'); const day = String(d.getDate()).padStart(2, '0'); return `${y}-${m}-${day}`; } function isHoliday(date = new Date()) { return _HOLIDAYS.has(_ymd(date)); } // Cargar feriados — primero desde cache (localStorage) y luego refresh // asincrónico contra Supabase RPC `ref.get_holidays()`. Si falla, fallback a // la API pública de argentinadatos.com (CORS habilitado). async function loadHolidays() { // 1. Hidratar desde localStorage si está fresco try { const raw = localStorage.getItem(_HOLIDAY_CACHE_KEY); if (raw) { const cached = JSON.parse(raw); if (Date.now() - (cached.at || 0) < _HOLIDAY_CACHE_TTL) { _HOLIDAYS = new Set(cached.dates || []); } } } catch {} // 2. Refresh en background const year = new Date().getFullYear(); try { const sb = getSupabase(); if (sb) { const { data, error } = await sb.schema('ref').rpc('get_holidays', { p_year: year }); if (!error && Array.isArray(data) && data.length) { _HOLIDAYS = new Set(data.map(r => r.holiday_date)); try { localStorage.setItem(_HOLIDAY_CACHE_KEY, JSON.stringify({ at: Date.now(), dates: [..._HOLIDAYS] })); } catch {} return _HOLIDAYS; } } } catch (e) { console.warn('[holidays] supabase failed', e); } // 3. Fallback público try { const res = await fetch(`https://api.argentinadatos.com/v1/feriados/${year}`); if (res.ok) { const data = await res.json(); _HOLIDAYS = new Set(data.map(h => h.fecha)); try { localStorage.setItem(_HOLIDAY_CACHE_KEY, JSON.stringify({ at: Date.now(), dates: [..._HOLIDAYS] })); } catch {} } } catch (e) { console.warn('[holidays] fallback failed', e); } return _HOLIDAYS; } function _dayType(d) { // Feriado nacional → trato como domingo (tabla 'sun'). if (isHoliday(d)) return 'sun'; const dow = d.getDay(); if (dow === 0) return 'sun'; if (dow === 6) return 'sat'; if (dow === 5) return 'fri'; return 'wd'; } function decimalHourToLabel(h) { h = ((h % 24) + 24) % 24; const hh = Math.floor(h); const mm = Math.round((h - hh) * 60); return `${String(hh).padStart(2,'0')}:${String(mm).padStart(2,'0')}`; } function _nowDecimalHour(d) { return d.getHours() + d.getMinutes()/60 + d.getSeconds()/3600; } // Returns service window for a line on given date. // extended: true → usa el cierre extendido (línea B en estaciones extendidas) // dir: 0 = head1→head2, 1 = head2→head1 (para B con cierres asimétricos por dirección) function getServiceWindow(lineId, date = new Date(), extended = false, dir = null) { const cfg = SERVICE_HOURS[lineId]; if (!cfg) return null; const w = cfg[_dayType(date)]; let close = w.close; if (extended) { const raw = (dir === 0 && w.closeExtD0 != null) ? w.closeExtD0 : (dir === 1 && w.closeExtD1 != null) ? w.closeExtD1 : (w.closeExt != null) ? w.closeExt : null; if (raw != null) close = raw < w.open ? raw + 24 : raw; } return { open: w.open, close }; } // Dado un dayConfig, devuelve el cierre efectivo (max de extendidos o close normal), // ajustado a >24 si cae en la madrugada siguiente. function _effectiveClose(dayCfg) { const ext = [dayCfg.closeExtD0, dayCfg.closeExtD1, dayCfg.closeExt].filter(x => x != null); if (!ext.length) return dayCfg.close; const maxRaw = Math.max(...ext); return maxRaw < dayCfg.open ? maxRaw + 24 : maxRaw; } // Returns { state, closesAt, opensAt, opensDay, minsUntilClose } // state: 'open' | 'closing-soon' | 'closed' function getServiceState(lineId, date = new Date()) { const cfg = SERVICE_HOURS[lineId]; if (!cfg) return { state: 'open' }; const now = _nowDecimalHour(date); const today = cfg[_dayType(date)]; const closeT = _effectiveClose(today); // Caso normal: dentro del turno de hoy [open, closeT) if (now >= today.open && now < closeT) { const mins = (closeT - now) * 60; return { state: mins <= 30 ? 'closing-soon' : 'open', closesAt: decimalHourToLabel(closeT), minsUntilClose: mins }; } // Caso madrugada: son las 00:xx–02:xx y estamos dentro del horario extendido de AYER // (ej. sábado 01:00 cae dentro del extendido del viernes hasta 01:30) if (now < today.open) { const yest = new Date(date); yest.setDate(yest.getDate() - 1); const yestCfg = cfg[_dayType(yest)]; const yestClose = yestCfg ? _effectiveClose(yestCfg) : null; if (yestClose != null && yestClose > 24 && now < (yestClose - 24)) { const mins = (yestClose - 24 - now) * 60; return { state: mins <= 30 ? 'closing-soon' : 'open', closesAt: decimalHourToLabel(yestClose), minsUntilClose: mins }; } } // Cerrado → próxima apertura let nextDate = new Date(date); let opensDay = 'hoy'; if (now >= today.open) { // ya pasó el cierre de hoy nextDate.setDate(nextDate.getDate() + 1); opensDay = 'mañana'; } const nextCfg = cfg[_dayType(nextDate)]; return { state: 'closed', opensAt: decimalHourToLabel(nextCfg.open), opensDay, closedSince: decimalHourToLabel(closeT), }; } // ¿Todas las líneas pasaron el cierre? Devuelve {allClosed, nextOpensAt, nextOpenDay} function getAllClosedInfo(date = new Date()) { const lineIds = Object.keys(SERVICE_HOURS); const states = lineIds.map(id => ({ id, svc: getServiceState(id, date) })); const allClosed = states.every(s => s.svc.state === 'closed'); if (!allClosed) return { allClosed: false }; // Próxima apertura: el más temprano "opensAt" entre todas las líneas (mismo opensDay) let best = null; states.forEach(s => { const t = s.svc.opensAt; if (!best || t < best.opensAt) best = { opensAt: t, opensDay: s.svc.opensDay, id: s.id }; }); return { allClosed: true, ...best }; } // Decimal hour at which the last train passes a given station (one-way trip // that departed from terminal at `closeT - travelTimeHours`). // dir: 0 = head1→head2 (idx ascendente), 1 = head2→head1 (idx descendente). // extended: true → usa closeExtD0/closeExtD1 (para estaciones extendidas de línea B). function getLastTrainTime(lineId, idx, stationCount, travelTimeMin, dir = 0, date = new Date(), extended = false) { const w = getServiceWindow(lineId, date, extended, dir); if (!w) return null; const travelH = travelTimeMin / 60; const interH = travelH / Math.max(1, stationCount - 1); const fromTerminal = (dir === 0 ? idx : (stationCount - 1 - idx)); // último tren sale del terminal alrededor de close - travelTime return w.close - travelH + fromTerminal * interH; } // ─── Supabase shared client ─────────────────────────────────────────────────── const _SB_URL = 'https://eqrbxxjgelkjqniwpwst.supabase.co'; const _SB_ANON = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImVxcmJ4eGpnZWxranFuaXdwd3N0Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzU0NDE2NjIsImV4cCI6MjA5MTAxNzY2Mn0.vn2GT7OjZZjzuEuvvZy73h0TVDCLayq7V1ixfih9NYM'; let _sbClient = null; function getSupabase() { if (!_sbClient && window.supabase) _sbClient = window.supabase.createClient(_SB_URL, _SB_ANON); return _sbClient; } // ─── Live status context ────────────────────────────────────────────────────── const STALE_MS = 120_000; // datos > 2 min se consideran obsoletos const LiveCtx = React.createContext({ lines: LINE_STATUS, lastFetch: null }); function LiveStatusProvider({ children }) { const [live, setLive] = React.useState(null); const [lastFetch, setLastFetch] = React.useState(null); const [, force] = React.useState(0); async function fetchLive() { try { const sb = getSupabase(); if (!sb) return; const { data, error } = await sb.rpc('get_live_status'); if (error || !data?.length) return; const next = {}; data.forEach(row => { next[row.line_code] = { state: row.service_state === 'normal' ? 'normal' : row.service_state === 'delay' ? 'delay' : 'alert', nextTrain: row.next_train_min ?? LINE_STATUS[row.line_code]?.nextTrain ?? 5, freq: row.freq_label || LINE_STATUS[row.line_code]?.freq || '–', freqSecs: row.freq_secs ?? LINE_STATUS[row.line_code]?.freqSecs ?? 300, note: row.estado_texto || null, }; }); setLive(next); setLastFetch(Date.now()); } catch(e) { console.warn('[LiveStatus] fetch failed', e); } } React.useEffect(() => { fetchLive(); loadHolidays().then(() => force(n => n + 1)); // Cargar anuncios programados desde CMS const loadCMSAnnouncements = async () => { try { const sb = getSupabase(); if (sb) { const { data, error } = await sb.rpc('cms.get_active_announcements'); if (!error && Array.isArray(data)) { const SOURCES_MAP = { emova: { id: 'emova', label: 'Emova', handle: '@emova_ba', linkLabel: 'Ver en X.com' }, smn: { id: 'smn', label: 'SMN', linkLabel: 'Ver alerta SMN' }, gcba: { id: 'gcba', label: 'GCBA', linkLabel: 'Ver en buenosaires.gob.ar' }, app: { id: 'app', label: 'alSubte', linkLabel: 'Ver detalle' }, sponsor: { id: 'sponsor', label: 'Colaborador', linkLabel: 'Ver fuente' }, otro: { id: 'otro', label: 'Fuente', linkLabel: 'Ver fuente' }, }; data.forEach(a => { a.source = SOURCES_MAP[a.source] || SOURCES_MAP.otro; }); window.CMS_ANNOUNCEMENTS = data; } } } catch (e) { console.warn('[CMS announcements] load failed', e); } }; loadCMSAnnouncements(); const timer = setInterval(fetchLive, 60_000); // tick cada 30s para que isStale se recalcule sin esperar al próximo fetch const tick = setInterval(() => force(n => n + 1), 30_000); return () => { clearInterval(timer); clearInterval(tick); }; }, []); const value = React.useMemo(() => { const lines = {}; if (live) Object.keys(LINE_STATUS).forEach(k => { lines[k] = { ...LINE_STATUS[k], ...(live[k] || {}) }; }); else Object.assign(lines, LINE_STATUS); return { lines, lastFetch }; }, [live, lastFetch]); return {children}; } function useLiveStatus() { const ctx = React.useContext(LiveCtx); return (ctx && ctx.lines) || LINE_STATUS; } function useLiveFreshness() { const ctx = React.useContext(LiveCtx); const lastFetch = ctx?.lastFetch ?? null; const isStale = !lastFetch || (Date.now() - lastFetch) > STALE_MS; return { lastFetch, isStale }; } // ─── Weather hook (shared between mobile and desktop) ──────────────────────── const WX_CODES = { 0: { icon: '☀️', desc: 'Despejado' }, 1: { icon: '🌤️', desc: 'Mayormente despejado' }, 2: { icon: '⛅', desc: 'Parcialmente nublado' }, 3: { icon: '☁️', desc: 'Nublado' }, 45: { icon: '🌫️', desc: 'Niebla' }, 48: { icon: '🌫️', desc: 'Niebla con escarcha' }, 51: { icon: '🌦️', desc: 'Llovizna leve' }, 53: { icon: '🌦️', desc: 'Llovizna' }, 55: { icon: '🌧️', desc: 'Llovizna intensa' }, 61: { icon: '🌧️', desc: 'Lluvia leve' }, 63: { icon: '🌧️', desc: 'Lluvia' }, 65: { icon: '🌧️', desc: 'Lluvia intensa' }, 71: { icon: '❄️', desc: 'Nieve leve' }, 73: { icon: '❄️', desc: 'Nieve' }, 75: { icon: '❄️', desc: 'Nieve intensa' }, 77: { icon: '🌨️', desc: 'Granizo fino' }, 80: { icon: '🌦️', desc: 'Chubascos leves' }, 81: { icon: '🌦️', desc: 'Chubascos' }, 82: { icon: '⛈️', desc: 'Chubascos fuertes' }, 85: { icon: '🌨️', desc: 'Nevada leve' }, 86: { icon: '🌨️', desc: 'Nevada' }, 95: { icon: '⛈️', desc: 'Tormenta' }, 96: { icon: '⛈️', desc: 'Tormenta con granizo' }, 99: { icon: '⛈️', desc: 'Tormenta con granizo' }, }; function useWeather() { const [wx, setWx] = React.useState(null); React.useEffect(() => { fetch( 'https://api.open-meteo.com/v1/forecast' + '?latitude=-34.6037&longitude=-58.3816' + '¤t=temperature_2m,apparent_temperature,weather_code' + '&timezone=America%2FArgentina%2FBuenos_Aires' ) .then(r => r.json()) .then(d => { const code = d.current.weather_code; const info = WX_CODES[code] || { icon: '🌡️', desc: 'Variable' }; setWx({ temp: Math.round(d.current.temperature_2m), feels: Math.round(d.current.apparent_temperature), code, ...info }); }) .catch(() => {}); }, []); return wx; } // ─── Real-train hook (shared by map + line detail) ──────────────────────────── // Maps DB station names to the names used in LINES.stations[] const _REAL_TRAIN_ALIASES = { 'plaza de miserere': 'plaza miserere', 'leandro n. alem': 'l. n. alem', 'c. pellegrini': 'carlos pellegrini', 'almagro - medrano': 'medrano', 'juan manuel de rosas': 'j. m. de rosas', 'juan manuel de rosas - villa urquiza': 'j. m. de rosas', 'san martin': 'general san martín', 'r.scalabrini ortiz': 'scalabrini ortiz', 'jose maria moreno': 'josé m. moreno', }; function _normStationName(s) { return (s || '') .normalize('NFD').replace(/[\u0300-\u036f]/g, '') .toLowerCase() .replace(/\bav\.\s*/g, 'avenida ') .replace(/\s*-\s*.+$/, '') .trim(); } function _canonicalStation(s) { const n = _normStationName(s); return _REAL_TRAIN_ALIASES[n] || n; } // Pre-build stationIdx lookup per line from LINES config const _STATION_IDX_BY_LINE = {}; for (const _line of LINES) { _STATION_IDX_BY_LINE[_line.id] = {}; _line.stations.forEach((name, idx) => { _STATION_IDX_BY_LINE[_line.id][_canonicalStation(name)] = idx; }); } // Shared poll state — one fetch serves all hook instances const _REAL_TRAIN_STATE = { rows: [], lastFetch: 0, listeners: new Set(), timer: null }; async function _pollRealTrains() { try { const sb = getSupabase(); if (!sb) return; const { data, error } = await sb.rpc('get_trips_with_schedule'); if (error || !data) return; _REAL_TRAIN_STATE.rows = data; _REAL_TRAIN_STATE.lastFetch = Date.now(); _REAL_TRAIN_STATE.listeners.forEach(fn => fn()); } catch (e) { /* silent */ } } // Returns { trains, nextArrivalSecs(stationIdx, directionId) } for `lineId`. // Each train has { id, direction_id, stationIdx (fractional), arrivalEpoch, departureEpoch }. // `stationIdx` is a fractional position in LINES[].stations for that line. function useRealTrains(lineId) { const [, setTick] = React.useState(0); React.useEffect(() => { const listener = () => setTick(t => t + 1); _REAL_TRAIN_STATE.listeners.add(listener); if (_REAL_TRAIN_STATE.listeners.size === 1) { _pollRealTrains(); _REAL_TRAIN_STATE.timer = setInterval(_pollRealTrains, 30000); } const animTimer = setInterval(listener, 1000); return () => { _REAL_TRAIN_STATE.listeners.delete(listener); clearInterval(animTimer); if (_REAL_TRAIN_STATE.listeners.size === 0 && _REAL_TRAIN_STATE.timer) { clearInterval(_REAL_TRAIN_STATE.timer); _REAL_TRAIN_STATE.timer = null; } }; }, []); const TRANSIT = 60; // seconds between adjacent stops (estimate) const idxMap = _STATION_IDX_BY_LINE[lineId] || {}; const rows = _REAL_TRAIN_STATE.rows.filter(r => r.line_code === lineId); const now = Date.now() / 1000; // Group by trip_id to derive prev-stop for each physical train const byTrip = {}; rows.forEach(r => { (byTrip[r.trip_id] ||= []).push(r); }); const trains = []; const nextArrival = {}; // `${dir}_${idx}` → earliest future arrival_epoch Object.values(byTrip).forEach(stops => { const isAsc = stops[0]?.is_ascending !== false; const sorted = [...stops].sort((a, b) => a.stop_num - b.stop_num); sorted.forEach((stop, i) => { const prevStop = isAsc ? (i > 0 ? sorted[i - 1] : null) : (i < sorted.length - 1 ? sorted[i + 1] : null); const nextIdx = idxMap[_canonicalStation(stop.stop_name)]; if (nextIdx === undefined) return; const prevIdx = prevStop ? idxMap[_canonicalStation(prevStop.stop_name)] : undefined; let stationIdx; if (now < stop.arrival_epoch && prevIdx !== undefined) { const prevDep = stop.arrival_epoch - TRANSIT; const t = Math.max(0, Math.min((now - prevDep) / TRANSIT, 1)); stationIdx = prevIdx + (nextIdx - prevIdx) * t; } else { stationIdx = nextIdx; // dwelling, or held post-departure } trains.push({ id: `${stop.trip_id}_${stop.stop_id}`, direction_id: stop.direction_id, stationIdx, nextStationIdx: nextIdx, arrivalEpoch: stop.arrival_epoch, departureEpoch: stop.departure_epoch, }); const key = `${stop.direction_id}_${nextIdx}`; if (stop.arrival_epoch > now && (nextArrival[key] === undefined || stop.arrival_epoch < nextArrival[key])) { nextArrival[key] = stop.arrival_epoch; } }); }); function nextArrivalSecs(stationIdx, directionId) { const eta = nextArrival[`${directionId}_${stationIdx}`]; return eta ? Math.max(0, Math.round(eta - now)) : null; } return { trains, nextArrivalSecs, lastFetch: _REAL_TRAIN_STATE.lastFetch, hasData: rows.length > 0 }; } // ─── Emova live device status ───────────────────────────────────────────────── // Mapeo: nuestro nombre de estación (de LINES.stations) → idEstacion de la API Emova. // Es line-specific porque algunas estaciones comparten nombre entre líneas // (Callao B=204 vs D=403; Pueyrredón B=206 vs D=405; Retiro C=300 vs E=500; etc.) const EMOVA_STATION_ID = { A: { 'Plaza de Mayo': 0, 'Perú': 1, 'Piedras': 2, 'Lima': 3, 'Sáenz Peña': 4, 'Congreso': 5, 'Plaza Miserere': 6, 'Loria': 7, 'Castro Barros': 8, 'Acoyte': 9, 'Primera Junta': 10, 'Puán': 11, 'Carabobo': 12, 'San José de Flores': 13, 'San Pedrito': 14, }, B: { 'L. N. Alem': 200, 'Florida': 201, 'Carlos Pellegrini': 202, 'Uruguay': 203, 'Callao': 204, 'Pasteur': 205, 'Pueyrredón': 206, 'Carlos Gardel': 207, 'Medrano': 208, 'Ángel Gallardo': 209, 'Malabia': 210, 'Dorrego': 211, 'Federico Lacroze': 212, 'Tronador': 213, 'De los Incas': 214, 'Echeverría': 215, 'J. M. de Rosas': 216, }, C: { 'Retiro': 300, 'General San Martín': 301, 'Lavalle': 302, 'Diagonal Norte': 303, 'Avenida de Mayo': 304, 'Moreno': 305, 'Independencia': 306, 'San Juan': 307, 'Constitución': 308, }, D: { 'Catedral': 400, '9 de Julio': 401, 'Tribunales': 402, 'Callao': 403, 'Facultad de Medicina': 404, 'Pueyrredón': 405, 'Agüero': 406, 'Bulnes': 407, 'Plaza Italia': 408, 'Palermo': 409, 'Ministro Carranza': 410, 'Olleros': 411, 'José Hernández': 412, 'Juramento': 413, 'Congreso de Tucumán': 414, }, E: { 'Retiro': 500, 'Catalinas': 501, 'Correo Central': 502, 'Bolívar': 503, 'Belgrano': 504, 'Independencia': 505, 'San José': 506, 'Entre Ríos': 507, 'Pichincha': 508, 'Jujuy': 509, 'Urquiza': 510, 'Boedo': 511, 'Avenida La Plata': 512, 'José M. Moreno': 513, 'Emilio Mitre': 514, 'Medalla Milagrosa': 515, 'Varela': 516, }, H: { 'Facultad de Derecho': 600, 'Las Heras': 601, 'Santa Fe': 602, 'Córdoba': 603, 'Corrientes': 604, 'Once': 605, 'Venezuela': 606, 'Humberto 1°': 607, 'Inclán': 608, 'Caseros': 609, 'Parque Patricios': 610, 'Hospitales': 611, }, }; // ─── Historial de eventos de estado (infosubte) ─────────────────────────────── // Parsea los snapshots de ops.infosubte y construye un log de cambios de estado // por línea para los últimos N días. Se carga una vez por sesión al abrir // ScreenMain o ScreenNews — independiente de si el usuario tiene push activo. const _INFOSUBTE_LINE_MAP = { 'Línea A': 'A', 'Línea B': 'B', 'Línea C': 'C', 'Línea D': 'D', 'Línea E': 'E', 'Línea H': 'H', 'Línea P': 'P', 'Línea U': 'U', }; const _NORMAL_RE = /presta su servicio completo|normalizando su frecuencia|servicio normal|sin novedades|servicio finalizado/i; function _isNormalEstado(s) { return !s || _NORMAL_RE.test(s); } function _parseInfosubteSnapshot(xmlRaw) { try { const doc = new DOMParser().parseFromString(xmlRaw, 'text/xml'); const result = {}; for (const el of doc.querySelectorAll('Linea')) { const nombre = el.querySelector('nombre')?.textContent?.trim() || ''; const estado = el.querySelector('estado')?.textContent?.trim() || ''; const code = _INFOSUBTE_LINE_MAP[nombre]; if (code) result[code] = estado; } return result; } catch { return {}; } } function _buildStatusEvents(rows) { // rows sorted by created_at ASC — each row is a DISTINCT state (deduplicated by the backend) const lineStates = {}; // last known estado per line (to detect change) const openEvents = {}; // currently "open" (unresolved) event per line const events = []; for (let i = 0; i < rows.length; i++) { const row = rows[i]; const snap = _parseInfosubteSnapshot(row.xml_raw); for (const [line, estado] of Object.entries(snap)) { const prev = lineStates[line] ?? null; if (estado === prev) continue; // no change for this line lineStates[line] = estado; if (!_isNormalEstado(estado)) { // Si había una alerta abierta para esta línea, cerrarla antes de abrir la nueva if (openEvents[line]) { openEvents[line].endedAt = row.created_at; openEvents[line].isCurrent = false; } // New alert — push event and mark as open const ev = { lineId: line, text: estado, startedAt: row.created_at, endedAt: null, isCurrent: true }; events.push(ev); openEvents[line] = ev; } else if (openEvents[line]) { // Resolved — close the open event openEvents[line].endedAt = row.created_at; openEvents[line].isCurrent = false; delete openEvents[line]; } } } return events.reverse(); // most recent first } let _statusEventsCache = null; let _statusEventsPromise = null; async function loadStatusEvents(days = 5) { if (_statusEventsCache !== null) return _statusEventsCache; if (_statusEventsPromise) return _statusEventsPromise; _statusEventsPromise = (async () => { try { const sb = getSupabase(); if (!sb) { _statusEventsCache = []; return []; } const { data, error } = await sb.rpc('get_infosubte_events', { p_days: days }); _statusEventsCache = (!error && data) ? _buildStatusEvents(data) : []; } catch { _statusEventsCache = []; } return _statusEventsCache; })(); return _statusEventsPromise; } // ─── Emova device cache ─────────────────────────────────────────────────────── // Cache en memoria por sesión — se carga una vez al abrir el primer popup de estación let _emovaCache = null; // null = no cargado, [] = sin datos, [...]= datos let _emovaPromise = null; // evita requests paralelos async function loadEmovaStatus() { if (_emovaCache !== null) return _emovaCache; if (_emovaPromise) return _emovaPromise; _emovaPromise = (async () => { try { const sb = getSupabase(); if (!sb) { _emovaCache = []; return _emovaCache; } const { data, error } = await sb.rpc('emova_get_devices'); _emovaCache = error ? [] : (data || []); } catch { _emovaCache = []; } return _emovaCache; })(); return _emovaPromise; } // Devuelve los dispositivos de una estación, o null si el mapping no existe. // lineCode: 'A','B',... stationName: exactamente como aparece en LINES.stations function getEmovaDevices(lineCode, stationName) { if (_emovaCache === null) return null; // aún cargando const stationId = EMOVA_STATION_ID[lineCode]?.[stationName]; if (stationId == null) return null; // estación sin dispositivos o sin mapeo return _emovaCache.filter(d => d.station_id === stationId); } // ─── Station info (addresses + descriptions from ves.ar/alsubte) ──────────── // Keys: station name, or 'LINE:name' for stations that share a name across lines. const STATION_INFO = { // ── Línea A ────────────────────────────────────────────────────────────── 'Plaza de Mayo': {addr:'Hipólito Yrigoyen 300', desc:'Estación terminal de andén central, inaugurada el 1° de diciembre de 1913 bajo la Plaza de Mayo. Restaurada en 2013 con murales de Martín Ron y Triángulo Dorado para el centenario de la red.'}, 'Perú': {addr:'Av. de Mayo 500', desc:'Inaugurada el 1° de diciembre de 1913 en Monserrat. Declarada monumento histórico nacional en 1997. Presenta galería de arte, muestra fotográfica y el mural "El Mundo según Mafalda" de Quino.'}, 'Piedras': {addr:'Av. de Mayo 900', desc:"Abierta el 1° de diciembre de 1913, declarada monumento histórico en 1997. En 2013 incorporó 12 fotografías intervenidas por el arquitecto y pintor Miguel D'Arienzo."}, 'Lima': {addr:'Av. de Mayo 1101', desc:'Inaugurada el 1° de diciembre de 1913, declarada monumento histórico en 1997. Exhibe murales cerámicos "Gente de Buenos Aires" de Horacio Altuna y "Músicos de Buenos Aires" de Hermenegildo Sábat, más diez obras de Gustavo Godoy.'}, 'Sáenz Peña': {addr:'Av. de Mayo 1400', desc:'Nombrada en honor al 12° presidente argentino, declarada monumento histórico en 1997. En 2013 incorporó 16 obras de estilo pop de Marta Minujín.'}, 'Congreso': {addr:'Av. Rivadavia 1800', desc:'Inaugurada el 1° de diciembre de 1913 en Balvanera. Declarada monumento histórico en 1997. Alberga el mural cerámico "El Nacimiento de la Patria" de Carlos Nine.'}, 'Pasco': {addr:'Av. Rivadavia 2200', desc:'Inaugurada el 1° de diciembre de 1913. Declarada monumento histórico en 1997. Solo se detienen trenes en dirección Plaza de Mayo; el andén sur está fuera de servicio desde 1951.'}, 'Alberti': {addr:'Av. Rivadavia 2400', desc:'Inaugurada el 1° de diciembre de 1913, declarada monumento histórico en 1997. Solo opera en dirección Carabobo; el andén norte fue clausurado en 1951.'}, 'Plaza Miserere': {addr:'Av. Rivadavia 2100', desc:'Inaugurada el 1° de diciembre de 1913, declarada monumento histórico en 1997. Restaurada integralmente en 2013; conserva la fachada original del 11 de septiembre en el vestíbulo central.'}, 'Loria': {addr:'Av. Rivadavia 3400', desc:'Abierta el 1° de abril de 1914, marcando la segunda sección de la línea. En 2013 se incorporaron fotografías sobre la expansión reciente de la red.'}, 'Castro Barros': {addr:'Av. Rivadavia 3900', desc:'Inaugurada el 1° de abril de 1914 en Almagro. En 2013 se incorporaron murales de boxeo de Julio Lavallén, en referencia a la Federación Argentina de Box cercana.'}, 'Río de Janeiro': {addr:'Av. Rivadavia 4500', desc:'Abierta el 1° de abril de 1914, en el límite de Caballito y Almagro. En 2013 se instalaron diez obras de Quinquela Martín.'}, 'Acoyte': {addr:'Av. Rivadavia 5000', desc:'Inaugurada en julio de 1914 en Caballito. En 2013 se incorporaron 16 obras temáticas de educación de Claudio Gallina.'}, 'Primera Junta': {addr:'Av. Rivadavia 5300', desc:'Abierta en julio de 1914, con andenes centrales. Honra al primer gobierno patrio tras la Revolución de Mayo. En 2013 se instalaron espejos sobre el cuidado del transporte público.'}, 'Puán': {addr:'Av. Rivadavia 5900', desc:'Inaugurada en diciembre de 2008 en Caballito. Diseño contemporáneo con vestíbulo intermedio y tímpanos de arte urbano representando la cultura porteña.'}, 'Carabobo': {addr:'Av. Rivadavia 6300', desc:'Abierta en diciembre de 2008 en Flores. Diseño moderno con vestíbulo intermedio y arte urbano sobre cultura e imaginario porteño.'}, 'San José de Flores':{addr:'Av. Rivadavia 6950', desc:'Inaugurada en agosto de 2013 en Flores. Diseño contemporáneo con antenas laterales de 110 m. Exhibe obras de Guillermo Roux sobre la historia del barrio.'}, 'San Pedrito': {addr:'Av. Rivadavia 7450', desc:'Abierta en agosto de 2013 en Flores. Terminal de la línea. Exhibe "Luna con Corona Blanca" de Eugenio Cuttica y cuatro retratos de figuras representativas del barrio.'}, // ── Línea B ────────────────────────────────────────────────────────────── 'L. N. Alem': {addr:'Av. Corrientes 200', desc:'Estación terminal inaugurada el 1° de diciembre de 1931 en San Nicolás, frente al Palacio de Correos. Restaurada en 2013.'}, 'Florida': {addr:'Av. Corrientes 500', desc:'Inaugurada el 15 de diciembre de 1931 en San Nicolás. Mural de Mariano Imposti Indart dedicado a Patoruzú (1998).'}, 'Carlos Pellegrini':{addr:'Av. Corrientes 900', desc:'Abierta el 22 de junio de 1931, próxima al Obelisco y al Teatro Colón. Mural cerámico geométrico de Pablo Siquier y bajo-relieve en honor a Federico Lacroze.'}, 'Uruguay': {addr:'Av. Corrientes 1400', desc:'Inaugurada el 22 de junio de 1931. El 22 de diciembre de 2003 se convirtió en la primera estación temática, dedicada al cine argentino. Murales del Eternauta (Solano López, Breccia) e Inodoro Pereyra (Fontanarrosa).'}, 'B:Callao': {addr:'Av. Corrientes 1800', desc:'Abierta el 17 de octubre de 1930 en Balvanera. Murales "Desolación y Amor" (Kaplan) y "Altar Porteño" (Meana) en andén sur; "El Enigma del Espacio y del Tiempo" de Tomás Fracchia en andén norte.'}, 'Pasteur': {addr:'Av. Corrientes 2300', desc:'Inaugurada el 17 de octubre de 1930 en Balvanera, a 600 m de la estación Facultad de Medicina (Línea D).'}, 'Pueyrredón': {addr:'Av. Corrientes 2700', desc:'Abierta el 22 de junio de 1931 en Balvanera. Murales "Los Elementos" (Doffo), "Santuario" (Gargano) y "Subcielo de Buenos Aires" (Pesce) en andén sur.'}, 'Carlos Gardel': {addr:'Av. Corrientes 3300', desc:'Inaugurada el 17 de octubre de 1930 en la zona del Abasto. Originalmente llamada "Agüero", renombrada en honor al ídolo del tango. Murales cerámicos de Ingeborn Ringer con diseño de Carlos Páez Vilaró y filete de León Untroib.'}, 'Medrano': {addr:'Av. Corrientes 3900', desc:'Abierta el 17 de octubre de 1930 en Almagro. Decorada con mural de Ricardo Roux y obra de L. Wells.'}, 'Ángel Gallardo': {addr:'Av. Corrientes 4600', desc:'Inaugurada el 17 de octubre de 1930 próxima al Parque Centenario. Tres murales: díptico de Marcia Schvartz y "Flores de mi País" de Margarita Pakza.'}, 'Malabia': {addr:'Av. Corrientes 5200', desc:'Abierta el 17 de octubre de 1930 en Villa Crespo. En 2010 incorporó el nombre de Osvaldo Pugliese. Mural "Metamorfosis: de flor a tomillo" de Luz Zorraquín.'}, 'Dorrego': {addr:'Av. Corrientes 6000', desc:'Inaugurada el 17 de octubre de 1930. Cuatro murales: Mildred Burton (norte), Roberto Scarfidi, José María Cáceres y Juan José Cambre (sur).'}, 'Federico Lacroze': {addr:'Av. Corrientes 6800', desc:'Abierta el 17 de octubre de 1930 frente al Cementerio de Chacarita. Mural "El Destino" de Gustavo Grünig y obra de Emma Gargiulo.'}, 'Tronador': {addr:'Av. Triunvirato 3000', desc:'Inaugurada el 9 de agosto de 2003. Decoración en vidrio de color: 18 obras del taller de Roberto José Soler sobre la historia del barrio. Exhibe restos de gliptodonte hallados durante las excavaciones.'}, 'De los Incas': {addr:'Av. de los Incas 4200', desc:'Abierta el 26 de julio de 2013 en Villa Urquiza. Diseño moderno con amplios vestíbulos. Decorada con reproducciones de obras de Carolina Antoniadis.'}, 'J. M. de Rosas': {addr:'Roosevelt 5100', desc:'Estación terminal inaugurada el 26 de julio de 2013 en Villa Urquiza. Exhibe reproducciones de Andrés Waissan y originales de Julio Lavallén.'}, // ── Línea C ────────────────────────────────────────────────────────────── 'C:Retiro': {addr:'Av. Dr. José Ramos Mejía 1400', desc:'Estación terminal inaugurada el 6 de febrero de 1936. Vestíbulo con tres murales de Fallieri: "Las Máscaras", "Historias del Sábado" y "Las Primeras Luces".'}, 'General San Martín':{addr:'Av. Santa Fe 800', desc:'Inaugurada el 17 de agosto de 1937, declarada monumento histórico en 1997. Bajo la Plaza San Martín. Ocho murales polícromos de cemento de Rodolfo Medina sobre campañas sanmartinianas.'}, 'Lavalle': {addr:'Esmeralda 500', desc:'Abierta el 6 de febrero de 1936, declarada monumento histórico en 1997. Dos murales cerámicos basados en bocetos de Noel y Manuel Escasany de la serie "Paisajes de España".'}, 'Diagonal Norte': {addr:'Sarmiento 800', desc:'Inaugurada el 9 de noviembre de 1934, declarada monumento histórico en 1997. Próxima al Obelisco. Dos murales cerámicos de la serie "Paisajes de España" de Noel y Manuel Escasany.'}, 'Avenida de Mayo': {addr:'Av. de Mayo 900', desc:'Abierta el 9 de noviembre de 1934, declarada monumento histórico en 1997. Murales cerámicos: andén este basado en Ignacio Zuloaga; andén oeste "España Argentina MCMXXIV" de Fernando Álvarez Sotomayor.'}, 'Moreno': {addr:'Bernardo de Irigoyen 300', desc:'Inaugurada el 9 de noviembre de 1934 en Monserrat, declarada monumento histórico en 1997. Andenes laterales ornamentados con murales cerámicos de Noel y Manuel Escasany.'}, 'C:Independencia': {addr:'Bernardo de Irigoyen 700', desc:'Abierta el 9 de noviembre de 1934, declarada monumento histórico en 1997. Andenes laterales con murales cerámicos basados en bocetos de Escasany.'}, 'San Juan': {addr:'Bernardo de Irigoyen 1100',desc:'Inaugurada el 9 de noviembre de 1934, declarada monumento histórico en 1997. Murales "Badajoz, Cáceres" (oeste) y "Salamanca" (este) de Escasany.'}, 'Constitución': {addr:'Lima 1600', desc:'Estación terminal inaugurada el 9 de noviembre de 1934. Andén central y laterales con murales cerámicos reproduciendo obras de Molina Campos.'}, // ── Línea D ────────────────────────────────────────────────────────────── 'Catedral': {addr:'San Martín 1', desc:'Estación terminal inaugurada el 3 de junio de 1937, declarada monumento histórico en 1997. Murales de Rodolfo Franco: sociedad porteña de 1830 (sur) y ciudad cosmopolita de 1936 (norte).'}, '9 de Julio': {addr:'Carlos Pellegrini 300', desc:'Inaugurada el 3 de junio de 1937, declarada monumento histórico. Próxima al Obelisco. Andén sur: "San José de Flores" de Alfredo Guido; andén norte: imágenes pampeanas.'}, 'Tribunales': {addr:'Talcahuano', desc:'Abierta el 3 de junio de 1937, declarada monumento histórico en 1997. Frente al Teatro Colón. Andén central con "Las Dos Fundaciones de Buenos Aires" (oeste) y "España y América" (este) basados en Rodolfo Rancho.'}, 'D:Callao': {addr:'Av. Córdoba 1800', desc:'Inaugurada el 29 de marzo de 1938 en Balvanera, a 100 m de la Plaza Rodríguez Peña y el Ministerio de Educación.'}, 'Facultad de Medicina':{addr:'Av. Córdoba 2100', desc:'Abierta el 10 de junio de 1938, declarada monumento histórico en 1997. Murales de Alfredo Guido: "Puente Setúbal, el Río Paraná y La Rosario" (norte) y Rosario de 1836 (sur).'}, 'D:Pueyrredón': {addr:'Av. Santa Fe 2500', desc:'Inaugurada el 5 de septiembre de 1938, declarada monumento histórico en 1997 en Recoleta. Andén central con decoración cerámica de Cattáneo.'}, 'Agüero': {addr:'Av. Santa Fe 2900', desc:'Abierta el 5 de septiembre de 1938, declarada monumento histórico en 1997. Murales de Rodolfo Franco: "Camino de Córdoba" (norte) y "Los Colonizadores" (sur).'}, 'Bulnes': {addr:'Av. Santa Fe 3200', desc:'Inaugurada el 29 de diciembre de 1938, declarada monumento histórico en 1997 en Palermo. Murales de Alfredo Guido: "Leyenda del País de la Selva" (sur) y arqueología diaguita (norte).'}, 'Scalabrini Ortiz': {addr:'Av. Santa Fe 3700', desc:'Abierta el 29 de diciembre de 1938, declarada monumento histórico en 1997 en Palermo. Murales de Rodolfo Franco: "Pobladores del Altiplano 1938" (sur) y "Evocaciones de Salta" (norte).'}, 'Plaza Italia': {addr:'Av. Santa Fe 4100', desc:'Inaugurada el 29 de diciembre de 1938, declarada monumento histórico en 1997. Próxima al Zoo y al Jardín Botánico. Andén central con diseño basado en Quinquela Martín y obras de Leonie Matthis.'}, 'Palermo': {addr:'Av. Santa Fe 4600', desc:'Abierta el 23 de febrero de 1940, declarada monumento histórico en 1997. El vestíbulo exhibe la obra "Almería de Cuenca Muñoz".'}, 'Ministro Carranza':{addr:'Av. Santa Fe 5300', desc:'Inaugurada parcialmente en diciembre de 1987; plena operación en diciembre de 1993 en Palermo. Vestíbulo con escultura "Hombre en Ciclomotor" de Yoel Novoa.'}, 'Olleros': {addr:'Av. Cabildo 700', desc:'Abierta el 31 de mayo de 1997, entre Colegiales y Palermo. Dos murales de Josefina Robirosa: "Plaza de Verano" y "Plaza de Invierno".'}, 'José Hernández': {addr:'Av. Cabildo 1700', desc:'Inaugurada el 13 de noviembre de 1997 en Belgrano. Primera estación con acceso directo a nivel de calle para personas con discapacidad. Cuatro murales cerámicos con reproducciones de Raúl Soldi.'}, 'Juramento': {addr:'Av. Cabildo 2000', desc:'Abierta el 21 de junio de 1999. Recorrido artístico sobre la historia del barrio, murales de Manuel Belgrano, homenajes a la Guerra de Malvinas y fotografías de parejas de tango.'}, 'Congreso de Tucumán':{addr:'Av. Cabildo 2800', desc:'Estación terminal inaugurada el 27 de abril de 2000, en el límite de Belgrano y Núñez. Presenta bustos de Borges y Gardel, mural de Charles Fourqueray y reproducción de "La Cuesta de Chacabuco".'}, // ── Línea E ────────────────────────────────────────────────────────────── 'E:Retiro': {addr:'Av. R. Sáenz Peña 500', desc:'Estación terminal norte de Línea E, con conexión directa a la terminal de ómnibus de Retiro y ferrocarriles Mitre y Belgrano Sur.'}, 'Bolívar': {addr:'Av. Pres. Julio A. Roca 501', desc:'Estación terminal de andén central, inaugurada el 24 de abril de 1966 en Monserrat. El vestíbulo alberga la escultura "Homenaje a la Madre" de Nilda Toledo Guma (1983).'}, 'Belgrano': {addr:'Av. Belgrano 800', desc:'Inaugurada el 24 de abril de 1966 en Monserrat. Andenes laterales decorados con azulejos que forman la bandera argentina.'}, 'E:Independencia': {addr:'Bernardo de Irigoyen 700', desc:'Abierta el 24 de abril de 1966 en Constitución. Andén central decorado con la obra "Saliendo" del artista cordobés Antonio Seguí.'}, 'San José': {addr:'Av. San Juan 1400', desc:'Inaugurada el 24 de abril de 1966, declarada monumento histórico en 1997. Característica singular: andenes laterales completamente separados.'}, 'Entre Ríos': {addr:'Av. Entre Ríos 1100', desc:'Abierta el 20 de junio de 1944, declarada monumento histórico en 1997. En 2013 incorporó el nombre del escritor Rodolfo Walsh. Andenes con bocetos de Antonio Ortiz Echagüe sobre el avance en la Patagonia.'}, 'Pichincha': {addr:'Av. San Juan 2200', desc:'Inaugurada el 20 de junio de 1944, declarada monumento histórico en 1997. Murales de Cuenca Muñoz: "Cordillera de los Andes" (sur) y "Valles de la Cordillera" (norte).'}, 'Jujuy': {addr:'Av. San Juan 2700', desc:'Abierta el 20 de junio de 1944, declarada monumento histórico en 1997. Obras de Alejandro Sirio honrando las actividades productivas de la provincia de San Juan.'}, 'Urquiza': {addr:'Av. San Juan 3100', desc:'Inaugurada el 20 de junio de 1944, declarada monumento histórico en 1997. Bocetos de Leonie Matthise en honor a Justo José de Urquiza.'}, 'Boedo': {addr:'Av. San Juan 3500', desc:'Abierta el 9 de julio de 1960 en Boedo. Murales basados en bocetos de Alfredo Guido: "Boedo a mediados del siglo XIX" y "Niños Jugando" de Primaldo Mónaco.'}, 'Avenida La Plata': {addr:'Av. Directorio 1', desc:'Inaugurada el 24 de abril de 1996, entre Caballito y Boedo. Estación de andén central.'}, 'José M. Moreno': {addr:'Av. Directorio 400', desc:'Abierta el 23 de julio de 1973 en el límite de Caballito y Parque Chacabuco. Mural en vestíbulo en honor al Dr. José María Moreno, por cerámica Santa María.'}, 'Emilio Mitre': {addr:'Emilio Mitre 700', desc:'Inaugurada el 7 de octubre de 1985 en Parque Chacabuco. Próxima a la Facultad de Filosofía y Letras de la UBA.'}, 'Medalla Milagrosa':{addr:'Pumacahua 900', desc:'Abierta el 27 de noviembre de 1985 en Parque Chacabuco. Nombrada por la parroquia cercana. Dos murales de Santiago García Sáenz: "Rogad por Nosotros" y "Medalla Milagrosa".'}, 'Varela': {addr:'Av. Varela 900', desc:'Inaugurada el 31 de octubre de 1985 sobre la autopista 25 de Mayo en Flores.'}, 'Plaza de los Virreyes':{addr:'Av. Lafuente 1000', desc:'Estación terminal de andén central, inaugurada el 8 de mayo de 1986 en Flores.'}, // ── Línea H ────────────────────────────────────────────────────────────── 'Corrientes': {addr:'Av. Pueyrredón 500', desc:'Inaugurada el 6 de diciembre de 2011 en Balvanera. Mezzanine con filete de Discépolo por J. Muscia y A. Martínez; tímpano con homenaje a artistas del tango.'}, 'Once': {addr:'Av. Pueyrredón 100', desc:'Abierta el 18 de octubre de 2007 en Balvanera. Nueve obras de Hermenegildo Sabat en homenaje a figuras del tango.'}, 'Venezuela': {addr:'Venezuela 2500', desc:'Inaugurada el 18 de octubre de 2007 en Balvanera. Diez obras de Carlos Nine sobre el tango. La Línea H fue declarada paseo cultural del tango por la Legislatura porteña.'}, 'Humberto 1°': {addr:'Av. Jujuy 1100', desc:'Abierta el 18 de octubre de 2007 en San Cristóbal. Seis obras de Oscar Grillo de temática tanguera.'}, 'Inclán': {addr:'Av. Jujuy 1700', desc:'Inaugurada el 18 de octubre de 2007 en Parque Patricios. Diez obras de Alfredo Sabat sobre el tango.'}, 'Caseros': {addr:'Av. Caseros 2600', desc:'Abierta el 18 de octubre de 2007 en Parque Patricios. Seis obras de Hermenegildo Sabat de temática tanguera.'}, 'Parque Patricios': {addr:'Monteagudo 100', desc:'Inaugurada el 4 de octubre de 2011 en Parque Patricios. Obras de Ricardo Carpani y Marcello Mortaroti en homenaje al tanguero Tito Lusiardo; pantalla interactiva con historia del barrio.'}, 'Hospitales': {addr:'Av. Almafuerte 300', desc:'Abierta el 27 de mayo de 2013 en Parque Patricios. Obras de Martín Ron y Leandro Frizzero en homenaje al tanguero Ángel Villoldo.'}, }; function getStationInfo(lineId, stationName) { return STATION_INFO[lineId + ':' + stationName] || STATION_INFO[stationName] || null; } // ─── Artwork data from BA gob.ar open data ──────────────────────────────────── // Source: cdn.buenosaires.gob.ar/datosabiertos/datasets/sbase/subte-estaciones/obras-de-arte.xlsx // Fields: l=line, s=STATION_UPPERCASE, a=artist, t=title/description const _OBRAS_RAW = [ {l:"A",s:"PLAZA DE MAYO",a:"Martin Ron, Triangulo Dorado",t:"Ron: La Justicia, Sin Titulo (1), Fuerza, Sin Titulo (2), Sin Titulo (3). Triangulo Dorado: La Tuna, Atahualpa Yupanqui, Libertad, Homenaje A Pueblos Originarios, Homenaje A Pueblos Originarios (2), Homenaje A Pueblos Originarios (3), Sin Titulo, Sin Titulo (2)."}, {l:"A",s:"PERU",a:"Pedro Cuevas",t:"Sin Titulo"}, {l:"A",s:"PASILLO DE COMBINACION CON LINEAS D Y E",a:"Quino",t:"El Mundo De Mafalda"}, {l:"A",s:"PIEDRAS",a:"Ana Maria Moncalvo",t:"Los Cafes De Buenos Aires"}, {l:"A",s:"PIEDRAS",a:"Miguel D'arienzo",t:"Palimpsesto, Van Gogh En El Ibera, El Jardin De Los Pintores (Detalle), El Jardin De Los Pintores Ii (Detalle), El Jardin Federal, La Cantante De Boleros, Viaje Al Fin Del Mundo, La Opera De Carmen, Madame Butterfly, Garden Night In The Malvinas Islands, La Comedia Nacional, L'opera Evita, Volvere Y Sere Millones, El Jardin De Frida."}, {l:"A",s:"LIMA",a:"Gustavo Godoy",t:"Cleo, Despreocupada, El Viaje, Eslabon Perdido, Eslabon Perdido Ii, Green Paz, La Promesa, Mirtolina, Ring, Ula-Ul."}, {l:"A",s:"LIMA",a:"Horacio Altuna",t:"Gente De Buenos Aires"}, {l:"A",s:"LIMA",a:"Hermenegildo Sabat",t:"Musicos De Buenos Aires I, Ii Y Iii."}, {l:"A",s:"SAENZ PEÑA",a:"Marta Minujin",t:"All The Lovely People; Autorretrato; Brasa Ardiente; Carlos Gardel De Fuego; Colchones; Desde Grecia Hasta El Renacimiento Con Amor; El Obelisco De Pan Dulce; El Partenon De Libros; Freaking In Fluo; Geometria Blanda; Impacto A La Memoria; Kidnappening; Pago De La Deuda Externa A Andy Warhol (Con Choclos, El Oro Latinoamericano); Rostros En Escorzo Dividiendose Y Multiplicandose; Torre De Babel De Libros; Version 4 De Febrero."}, {l:"A",s:"SAENZ PEÑA",a:"Silvana Sica",t:"Nuestra Señora De La Piedad; Beata Maria Antonia De San Jose, Mama Antula"}, {l:"A",s:"CONGRESO",a:"Carlos Nine",t:"El Nacimiento De La Patria"}, {l:"A",s:"LORIA",a:"Sin Dato",t:"Fotografias De La Red"}, {l:"A",s:"CASTRO BARROS",a:"Julio Lavallan",t:"Murales De Boxeo"}, {l:"A",s:"RIO DE JANEIRO",a:"Quinquela Martin",t:"10 Obras"}, {l:"A",s:"ACOYTE",a:"Claudio Gallina",t:"16 Obras Tematica Educacion"}, {l:"A",s:"PRIMERA JUNTA",a:"Sin Dato",t:"Espejos Concientizacion"}, {l:"A",s:"PUAN",a:"Artistas Varios",t:"Timpanos De Arte Urbano"}, {l:"A",s:"CARABOBO",a:"Artistas Varios",t:"Timpanos De Arte Urbano"}, {l:"A",s:"SAN JOSE DE FLORES",a:"Guillermo Roux",t:"Historia Del Barrio Flores"}, {l:"A",s:"SAN PEDRITO",a:"Eugenio Cuttica",t:"Luna Con Corona Blanca Y 4 Retratos"}, {l:"A",s:"PLAZA MISERERE",a:"Artistas Varios",t:"Intervenciones Artisticas"}, {l:"B",s:"LEANDRO N. ALEM",a:"Sin Dato",t:"Obras De Mejoramiento"}, {l:"B",s:"FLORIDA",a:"Mariano Imposti Indart",t:"Patoruzu"}, {l:"B",s:"CARLOS PELLEGRINI",a:"Pablo Siquier",t:"Mural Ceramico Geometrico"}, {l:"B",s:"URUGUAY",a:"Francisco Solano Lopez, Francisco Breccia",t:"El Eternauta"}, {l:"B",s:"URUGUAY",a:"Roberto Fontanarrosa",t:"Inodoro Pereyra"}, {l:"B",s:"CALLAO",a:"Daniel Kaplan",t:"Desolacion Y Amor"}, {l:"B",s:"CALLAO",a:"Hector Meana",t:"Altar Porteño"}, {l:"B",s:"CALLAO",a:"Tomas Fracchia",t:"El Enigma Del Espacio Y Del Tiempo"}, {l:"B",s:"PASTEUR-AMIA",a:"Sin Dato",t:""}, {l:"B",s:"PUEYRREDON",a:"Juan Doffo",t:"Los Elementos"}, {l:"B",s:"PUEYRREDON",a:"German Gargano",t:"Santuario"}, {l:"B",s:"PUEYRREDON",a:"Ernesto Pesce",t:"Subcielo De Buenos Aires"}, {l:"B",s:"CARLOS GARDEL",a:"Ingeborn Ringer",t:"Ceramicos Con Diseño De Carlos Paez Vilaro Y Filete De Leon Untroib"}, {l:"B",s:"MEDRANO",a:"Ricardo Roux",t:"Mural"}, {l:"B",s:"MEDRANO",a:"L. Wells",t:"Obra"}, {l:"B",s:"ANGEL GALLARDO",a:"Marcia Schvartz",t:"Diptico"}, {l:"B",s:"ANGEL GALLARDO",a:"Margarita Pakza",t:"Flores De Mi Pais"}, {l:"B",s:"MALABIA- PUGLIESE",a:"Luz Zorraquin",t:"Metamorfosis: De Flor A Tomillo"}, {l:"B",s:"DORREGO",a:"Mildred Burton",t:"Obra Norte"}, {l:"B",s:"DORREGO",a:"Roberto Scarfidi",t:"Obra Sur"}, {l:"B",s:"DORREGO",a:"Jose Maria Caceres",t:"Obra Sur"}, {l:"B",s:"DORREGO",a:"Juan Jose Cambre",t:"Obra Sur"}, {l:"B",s:"FEDERICO LACROZE",a:"Gustavo Grunig",t:"El Destino"}, {l:"B",s:"FEDERICO LACROZE",a:"Emma Gargiulo",t:"Obra"}, {l:"B",s:"FEDERICO LACROZE A URUGUAY",a:"Sin Dato",t:"Mural En Pasillo"}, {l:"B",s:"TRONADOR V. ORTUZAR",a:"Roberto Jose Soler",t:"18 Obras Historia Del Barrio"}, {l:"B",s:"DE LOS INCAS- PARQUE CHAS",a:"Carolina Antoniadis",t:"Reproducciones"}, {l:"B",s:"ECHEVERRIA",a:"Sin Dato",t:""}, {l:"B",s:"JUAN MANUEL DE ROSAS",a:"Andres Waissan",t:"Multitudes, La Alcantarilla De Dios, La Cosecha"}, {l:"B",s:"JUAN MANUEL DE ROSAS",a:"Julio Lavallan",t:"Tropillas Sobre Andenes, Encuentro De Trompillas"}, {l:"B",s:"GALERIAS COMERCIALES",a:"Sin Dato",t:""}, {l:"C",s:"RETIRO",a:"Fallieri",t:"Las Mascaras, Historias Del Sabado, Las Primeras Luces"}, {l:"C",s:"GRAL. SAN MARTIN",a:"Rodolfo Medina",t:"8 Murales Policromados De Cemento Tematica Sanmartiniana"}, {l:"C",s:"LAVALLE",a:"Noel Y Manuel Escasany",t:"Paisajes De España"}, {l:"C",s:"DIAGONAL NORTE",a:"Noel Y Manuel Escasany",t:"Paisajes De España"}, {l:"C",s:"AV. DE MAYO",a:"Ignacio Zuloaga",t:"Boceto"}, {l:"C",s:"AV. DE MAYO",a:"Fernando Alvarez Sotomayor",t:"España Argentina Mcmxxiv"}, {l:"C",s:"MORENO",a:"Noel Y Manuel Escasany",t:"Murales Ceramicos"}, {l:"C",s:"INDEPENDENCIA",a:"Escasany",t:"Bocetos Ceramicos"}, {l:"C",s:"SAN JUAN",a:"Escasany",t:"Badajoz, Caseres; Salamanca"}, {l:"C",s:"CONSTITUCION",a:"Molina Campos",t:"Reproducciones"}, {l:"D",s:"CATEDRAL",a:"Rodolfo Franco",t:"Buenos Aires 1830 Y Buenos Aires 1936"}, {l:"D",s:"9 DE JULIO",a:"Alfredo Guido",t:"San Jose De Flores; Pampas"}, {l:"D",s:"TRIBUNALES",a:"Rodolfo Rancho",t:"Las Dos Fundaciones De Buenos Aires; España Y America"}, {l:"D",s:"CALLAO",a:"Sin Dato",t:""}, {l:"D",s:"FACULTAD DE MEDICINA",a:"Alfredo Guido",t:"Puente Setubal El Rio Parana Y La Rosario; Rosario 1836"}, {l:"D",s:"PUEYRREDON",a:"Cattaneo",t:"Decoracion Ceramica"}, {l:"D",s:"AGUERO",a:"Rodolfo Franco",t:"Camino De Cordoba; Los Colonizadores"}, {l:"D",s:"BULNES",a:"Alfredo Guido",t:"Leyenda Del Pais De La Selva; Arqueologia Diaguita Los Valles Tucuman La Zafra Los Ingenios"}, {l:"D",s:"SCALABRINI ORTIZ",a:"Rodolfo Franco",t:"Pobladores Del Altiplano 1938; Evocaciones De Salta"}, {l:"D",s:"PLAZA ITALIA",a:"Benito Quinquela Martin / Constantino Yuste",t:"La Descarga De Los Comboyes"}, {l:"D",s:"PLAZA ITALIA",a:"Leonie Matthis",t:"Obras Varias"}, {l:"D",s:"PALERMO",a:"Cuenca Muñoz",t:"Almeria"}, {l:"D",s:"MINISTRO CARRANZA",a:"Yoel Novoa",t:"Hombre En Ciclomotor"}, {l:"D",s:"OLLEROS",a:"Josefina Robirosa",t:"Plaza De Verano; Plaza De Invierno"}, {l:"D",s:"JOSE HERNANDEZ",a:"Raul Soldi",t:"En El Jardin, Los Amantes, La Musica; Galeria Rotatoria"}, {l:"D",s:"JURAMENTO",a:"Manuel Belgrano",t:"Historia Del Barrio; Malvinas; Tango"}, {l:"D",s:"CONGRESO DE TUCUMAN",a:"Charles Fourqueray",t:"La Reconquista De Buenos Aires"}, {l:"D",s:"CONGRESO DE TUCUMAN",a:"Pedro Subercaseaux",t:"La Cuesta De Chacabuco (Reproduccion)"}, {l:"E",s:"BOLIVAR",a:"Nilda Toledo Guma",t:"Homenaje A La Madre"}, {l:"E",s:"BELGRANO",a:"Sin Dato",t:"Azulejos Bandera Argentina"}, {l:"E",s:"INDEPENDENCIA",a:"Antonio Segui",t:"Saliendo"}, {l:"E",s:"SAN JOSE",a:"Sin Dato",t:""}, {l:"E",s:"ENTRE RIOS - RODOLFO WALSH",a:"Antonio Ortiz Echague",t:"Avance En La Patagonia"}, {l:"E",s:"PICHINCHA",a:"Cuenca Muñoz",t:"Cordillera De Los Andes; Valles De La Cordillera"}, {l:"E",s:"JUJUY",a:"Alejandro Sirio",t:"Actividades Productivas De San Juan"}, {l:"E",s:"GRAL. URQUIZA",a:"Leonie Matthise",t:"Homenaje A Justo Jose De Urquiza"}, {l:"E",s:"BOEDO",a:"Alfredo Guido",t:"Boedo A Mediados Del Siglo Xix"}, {l:"E",s:"BOEDO",a:"Primaldo Monaco",t:"Niños Jugando"}, {l:"E",s:"AV. LA PLATA",a:"Sin Dato",t:""}, {l:"E",s:"JOSE MARIA MORENO",a:"Carlos Benvenuto / Ceramica Santa Maria",t:"Mural Homenaje Dr. Jose Maria Moreno"}, {l:"E",s:"EMILIO MITRE",a:"Sin Dato",t:""}, {l:"E",s:"MEDALLA MILAGROSA",a:"Santiago Garcia Saenz",t:"Rogad Por Nosotros; Medalla Milagrosa"}, {l:"E",s:"VARELA",a:"Sin Dato",t:""}, {l:"E",s:"PLAZA DE LOS VIRREYES",a:"Sin Dato",t:""}, {l:"H",s:"LAS HERAS",a:"Sin Dato",t:""}, {l:"H",s:"CORDOBA",a:"Sin Dato",t:""}, {l:"H",s:"CORRIENTES",a:"J. Muscia, A. Martinez",t:"Filete De Discepolo; Homenaje Artistas Del Tango"}, {l:"H",s:"ONCE- 30 DE DICIEMBRE",a:"Hermenegildo Sabat",t:"9 Obras Homenaje Figuras Del Tango"}, {l:"H",s:"VENEZUELA",a:"Carlos Nine",t:"10 Obras Tematica Tanguera"}, {l:"H",s:"HUMBERTO I",a:"Oscar Grillo",t:"6 Obras Tematica Tanguera"}, {l:"H",s:"INCLAN",a:"Alfredo Sabat",t:"10 Obras Tematica Tanguera"}, {l:"H",s:"CASEROS",a:"Hermenegildo Sabat",t:"6 Obras Tematica Tanguera"}, {l:"H",s:"PARQUE PATRICIOS",a:"Ricardo Carpani, Marcello Mortaroti",t:"Homenaje A Tito Lusiardo"}, {l:"H",s:"HOSPITALES",a:"Martin Ron, Leandro Frizzero",t:"Homenaje A Angel Villoldo"}, {l:"P",s:"CENTRO DE TRANSFERENCIA INTENDENTE JULIO SAGUIER",a:"Martin Ron",t:"Saint Felipe"}, {l:"P",s:"CENTRO DE TRANSFERENCIA INTENDENTE JULIO SAGUIER",a:"Jaquelina Abraham",t:"Tatau, Testimonios En Tinta"}, ]; // Aliases: maps 'LINE:APP_STATION_UPPERCASE' → 'LINE:XLSX_STATION_UPPERCASE' // Handles abbreviated/alternate names between app station names and XLSX station names. const _OBRA_ALIASES = { 'B:L. N. ALEM': 'B:LEANDRO N. ALEM', 'B:J. M. DE ROSAS': 'B:JUAN MANUEL DE ROSAS', 'B:TRONADOR': 'B:TRONADOR V. ORTUZAR', 'B:DE LOS INCAS': 'B:DE LOS INCAS- PARQUE CHAS', 'B:MALABIA': 'B:MALABIA- PUGLIESE', 'B:PASTEUR': 'B:PASTEUR-AMIA', 'C:GENERAL SAN MARTIN': 'C:GRAL. SAN MARTIN', 'C:AVENIDA DE MAYO': 'C:AV. DE MAYO', 'D:SCALABRINI ORTIZ': 'D:SCALABRINI ORTIZ', 'E:URQUIZA': 'E:GRAL. URQUIZA', 'E:ENTRE RIOS': 'E:ENTRE RIOS - RODOLFO WALSH', 'E:AVENIDA LA PLATA': 'E:AV. LA PLATA', 'E:JOSE M. MORENO': 'E:JOSE MARIA MORENO', 'E:BOLIVAR': 'E:BOLIVAR', 'H:HUMBERTO 1°': 'H:HUMBERTO I', 'H:ONCE': 'H:ONCE- 30 DE DICIEMBRE', 'H:INCLAN': 'H:INCLAN', 'H:PARQUE PATRICIOS': 'H:PARQUE PATRICIOS', }; function _normalizeStation(s) { // uppercase + remove diacritics (NFD → strip combining marks U+0300–U+036F) return s.toUpperCase() .normalize('NFD').replace(/[̀-ͯ]/g, '') .trim(); } // Returns array of artwork entries for the given line+station (may be empty). function getStationArtwork(lineId, stationName) { const normalized = _normalizeStation(stationName); const lineKey = lineId + ':' + normalized; const resolved = _OBRA_ALIASES[lineKey] || lineKey; const colonIdx = resolved.indexOf(':'); const rl = colonIdx >= 0 ? resolved.slice(0, colonIdx) : lineId; const rs = colonIdx >= 0 ? resolved.slice(colonIdx + 1) : resolved; // Normalize XLSX station names for comparison (handles accents like Ñ, É, etc.) return _OBRAS_RAW.filter(o => o.l === rl && _normalizeStation(o.s) === rs); } // ─── exports ────────────────────────────────────────────────────────────────── Object.assign(window, { LINES, LINE_STATUS, NEWS, REPORTS_SEED, INCIDENT_TYPES, SCHEMATIC, CROSSINGS, STATION_DETAIL, STATION_DEFAULT, CLOSED_TODAY, CLOSED_REASONS, B_EXTENDED_STATIONS, LEADERBOARD, PUSH_DEFAULTS, getSupabase, LiveStatusProvider, useLiveStatus, useLiveFreshness, SERVICE_HOURS, getServiceWindow, getServiceState, getLastTrainTime, decimalHourToLabel, getAllClosedInfo, isHoliday, loadHolidays, ANNOUNCEMENTS, ANNOUNCEMENT_KIND, SOURCES, isMajorAnnouncement, getActiveAnnouncements, getAnnouncementHistory, loadDismissed, saveDismissed, timeAgoLabel, WX_CODES, useWeather, useRealTrains, EMOVA_STATION_ID, loadEmovaStatus, getEmovaDevices, STATION_INFO, getStationInfo, getStationArtwork, loadStatusEvents, });