// ScreenLineDetail.jsx — dual-track strip, station amenity popups, closed/extended states, transfer tags // Device tipo labels const TIPO_LABEL = { 0: 'Ascensor', 1: 'Escalera mecánica', 2: 'Salvaescaleras' }; function StationPopup({ station, lineId, onClose }) { const detail = { ...STATION_DEFAULT, ...(STATION_DETAIL[station] || {}) }; const crossings = CROSSINGS[station] || []; const isClosed = CLOSED_TODAY.includes(station); const isExtended = lineId === 'B' && B_EXTENDED_STATIONS.includes(station); const closedReason = CLOSED_REASONS[station]; const singleDir = detail.singleDir || 0; // Only show device panel for stations with actual Emova mappings const hasElevation = (detail.elevator || detail.escalator) && (EMOVA_STATION_ID[lineId]?.[station] != null); // ── Emova device state ──────────────────────────────────────────────────── const [devices, setDevices] = React.useState(undefined); // undefined=loading, []=loaded const [showDevices, setShowDevices] = React.useState(false); const [showInfo, setShowInfo] = React.useState(false); React.useEffect(() => { let cancelled = false; if (!hasElevation) { setDevices([]); return; } const cached = getEmovaDevices(lineId, station); if (cached !== null) { setDevices(cached); return; } loadEmovaStatus().then(() => { if (cancelled) return; setDevices(getEmovaDevices(lineId, station) || []); }); return () => { cancelled = true; }; }, [lineId, station]); // Derive presence + warning states from loaded Emova devices. // Emova is authoritative: if the API says a device is there, we show it // even if the static STATION_DETAIL doesn't list it. const hasElevatorDevices = Array.isArray(devices) && devices.some(d => d.device_tipo === 0); const hasEscalatorDevices = Array.isArray(devices) && devices.some(d => d.device_tipo === 1); const showElevator = detail.elevator || hasElevatorDevices; const showEscalator = detail.escalator || hasEscalatorDevices; const elevatorDown = hasElevatorDevices && devices.some(d => d.device_tipo === 0 && !d.funcionando); const escalatorDown = hasEscalatorDevices && devices.some(d => d.device_tipo === 1 && !d.funcionando); const anyDown = elevatorDown || escalatorDown; const fetchedAt = Array.isArray(devices) && devices[0]?.fetched_at; // ── Helpers ─────────────────────────────────────────────────────────────── const fmtDate = (iso) => { if (!iso) return null; const d = new Date(iso); return d.toLocaleDateString('es-AR', { day: 'numeric', month: 'numeric', year: 'numeric' }) + ' ' + d.toLocaleTimeString('es-AR', { hour: '2-digit', minute: '2-digit' }); }; // Amenity icon: state = 'ok' | 'warn' | 'off' const amenityIcon = (present, warn, label, icon) => { const state = !present ? 'off' : warn ? 'warn' : 'ok'; const bg = state === 'ok' ? 'oklch(0.94 0.08 155)' : state === 'warn' ? 'oklch(0.96 0.12 85)' : 'var(--ink-100)'; const clr = state === 'ok' ? 'var(--ok)' : state === 'warn' ? 'oklch(0.55 0.18 75)' : 'var(--text-dim)'; return (
{label}
); }; // ── Station info & artwork ───────────────────────────────────────────────── const stInfo = getStationInfo(lineId, station); const artworks = getStationArtwork(lineId, station); const hasInfo = !!(stInfo || artworks.length > 0); return (
e.stopPropagation()} style={{ background: 'var(--surface)', borderRadius: '20px 20px 0 0', width: '100%', maxWidth: 420, padding: '20px 20px 32px', maxHeight: '82vh', overflowY: 'auto' }}> {/* handle */}
{/* Header */}
{station}
{stInfo?.addr && (
{stInfo.addr}
)} {isClosed && (
{closedReason || 'Cerrada temporalmente'}
)} {isExtended && !isClosed && (
Horario extendido · hasta 2:30
)}
{/* Transfers */} {crossings.length > 0 && (
Combinaciones
{crossings.map(id => (
Línea {id}
))}
)} {/* Entrance direction note */} {singleDir > 0 && (
⚠ {singleDir === 1 ? 'Alguna boca solo permite acceso en una dirección.' : 'Algunas bocas son de salida únicamente.'}
)} {/* Openings count */} {detail.openings && (
{detail.openings} boca{detail.openings !== 1 ? 's' : ''} de acceso
)} {/* Amenities */}
Servicios
{amenityIcon(detail.accessible, false, 'Accesible', 'accessibility')} {amenityIcon(showElevator, elevatorDown, 'Ascensor', 'arrowUp')} {amenityIcon(showEscalator, escalatorDown, 'Escalera mecánica', 'list')} {amenityIcon(detail.baths, false, 'Baños', 'info')} {amenityIcon(detail.wifi, false, 'Wi-Fi', 'wifi')} {amenityIcon(detail.office, false, 'Boletería', 'user')} {amenityIcon(detail.atm, false, 'Cajero', 'star')}
{/* ── Action buttons row (Detalle de accesos / + info) ─────────────── */} {(hasElevation || hasInfo) && (
{hasElevation && ( )} {hasInfo && ( )}
)} {/* ── Detalle de accesos panel ──────────────────────────────────────── */} {showDevices && (
{/* data freshness */} {fetchedAt && (
Datos: {fmtDate(fetchedAt)}
)} {/* closed station override */} {isClosed ? (
{closedReason || 'Estación cerrada temporalmente'}
) : devices === undefined ? (
Cargando estado de ascensores…
) : !devices || devices.length === 0 ? (
Sin datos disponibles para esta estación.
) : (
{devices.map((d, i) => { const isDown = !d.funcionando; return (
{TIPO_LABEL[d.device_tipo] || 'Dispositivo'} · {d.device_name}
{d.descripcion && (
{d.descripcion}
)}
Estado: {d.funcionando ? 'Funcionando' : 'Fuera de servicio'}
{d.fecha_actualizacion && (
Última actualización: {fmtDate(d.fecha_actualizacion)}
)} {d.sentido && (
Sentido: {d.sentido}
)} {d.fecha_normalizacion && isDown && (
Normalización estimada: {fmtDate(d.fecha_normalizacion)}
)}
); })}
)}
)} {/* ── + info panel ──────────────────────────────────────────────────── */} {showInfo && (
{stInfo?.desc && (

{stInfo.desc}

)} {artworks.length > 0 && (
Arte en la estación
{artworks.map((aw, i) => (
{aw.a}
{aw.t &&
{aw.t}
}
))}
)} {!stInfo && artworks.length === 0 && (
Sin información adicional disponible.
)}
)}
); } // Finds the station index on `crossLineId` that corresponds to the interchange with // `currentLineId`. First tries a direct name match, then does a reverse CROSSINGS lookup: // finds a station on crossLineId whose CROSSINGS entry lists currentLineId. function getTransferStationIdx(s, crossLineId, currentLineId) { const xLine = LINES.find(l => l.id === crossLineId); if (!xLine) return -1; const direct = xLine.stations.indexOf(s); if (direct >= 0) return direct; for (const [st, lineIds] of Object.entries(CROSSINGS)) { if (lineIds.includes(currentLineId) && xLine.stations.includes(st)) { return xLine.stations.indexOf(st); } } return -1; } // Returns SECONDS until next train at station `idx` in direction dir (0=fwd, 1=bwd). // Uses Date.now() — same epoch-based time as the train animation — so chips and icons stay in sync. // stationT = interStation + dwellSecs (must match trainPos calculation). // Returns 0 ("●") for 30 s after departure (phase < 30) or when train is arriving (secs ≈ 0). const AT_STATION_LINGER_SECS = 30; function getNextArrival(idx, stationCount, stationT, headwaySecs, dir) { const nowSecs = Date.now() / 1000; const stationOffset = (dir === 0 ? idx : (stationCount - 1 - idx)) * stationT; const phase = ((nowSecs - stationOffset) % headwaySecs + headwaySecs) % headwaySecs; const remaining = Math.round(headwaySecs - phase); // Show dot while train just departed (phase < linger) or is arriving (remaining ≤ 0) if (phase < AT_STATION_LINGER_SECS || remaining <= 0) return 0; return remaining; } // Is current time peak hour? (8:30-10:00 or 17:30-19:00) function isPeakHour() { const now = new Date(); const mins = now.getHours() * 60 + now.getMinutes(); return (mins >= 510 && mins < 600) || (mins >= 1050 && mins < 1140); } function ScreenLineDetail({ lineId, go, t, pushPrefs, setPushPrefs }) { const line = LINES.find(l => l.id === lineId) || LINES[0]; const lineStatus = useLiveStatus(); const st = lineStatus[lineId]; const { isStale } = useLiveFreshness(); const svc = getServiceState(lineId); const serviceClosed = svc.state === 'closed'; const closingSoon = svc.state === 'closing-soon'; const minsLeft = closingSoon ? Math.max(1, Math.round(svc.minsUntilClose)) : null; // When service is closed, override the alert note with first-train info const noteText = serviceClosed ? `Fin del servicio (cerró ${svc.closedSince}). Primer tren ${svc.opensDay} a las ${svc.opensAt}.` : st.note; // NOTA: Los anuncios del CMS NO se muestran en el detalle de línea. // Solo se muestran en ScreenMain (página principal) y en ScreenNews (histórico). // Acá solo mostraríamos alertas de Emova/infosubte si las incorporamos directamente // al objeto `st` (status de la línea), pero por ahora lo dejamos sin anuncios de línea // para mantenerlo limpio y enfocado en el estado/información de la línea. const isLightLine = lineId === 'H' || lineId === 'P'; const isY = isLightLine; // keep alias for compat const txt = isLightLine ? '#0B1220' : '#fff'; const gradClass = 'grad-' + lineId.toLowerCase(); const c2 = `var(--linea-${lineId.toLowerCase()}-2)`; // Tick every second for countdown const [, setTick] = React.useState(0); const [selectedStation, setSelectedStation] = React.useState(null); const [scrolled, setScrolled] = React.useState(false); const scrollRef = React.useRef(null); // Push notification prefs for this line — uses App-level pushPrefs (synced via localStorage) const prefs = pushPrefs || PUSH_DEFAULTS; const lineNotifEnabled = !!(prefs.lines?.[lineId]); const [showNotifModal, setShowNotifModal] = React.useState(false); const toggleLineNotif = async () => { if (!lineNotifEnabled) { if ('Notification' in window) { const perm = await Notification.requestPermission(); if (perm !== 'granted') return; } setShowNotifModal(true); } else { setPushPrefs(p => ({ ...p, lines: { ...(p.lines || {}), [lineId]: false } })); } }; const saveNotifConfig = (config) => { setPushPrefs(p => ({ ...p, lines: { ...(p.lines || {}), [lineId]: true }, lineConfig: { ...(p.lineConfig || {}), [lineId]: config }, })); setShowNotifModal(false); }; React.useEffect(() => { const i = setInterval(() => setTick(x => x + 1), 1000); return () => clearInterval(i); }, []); React.useEffect(() => { const el = scrollRef.current; if (!el) return; const fn = () => setScrolled(el.scrollTop > 60); el.addEventListener('scroll', fn, { passive: true }); return () => el.removeEventListener('scroll', fn); }, []); const stationCount = line.stations.length; // Stations mentioned in the live alert note (e.g. "Estación Piedras cerrada") const affectedStations = React.useMemo(() => { if (!noteText || serviceClosed) return new Set(); const lower = noteText.toLowerCase(); return new Set(line.stations.filter(s => lower.includes(s.toLowerCase()))); }, [noteText, serviceClosed, line.stations]); // Check if last train of the day already passed station idx in direction dir function lastTrainPassed(idx, dir) { const ext = lineId === 'B' && B_EXTENDED_STATIONS.includes(line.stations[idx]); const lastH = getLastTrainTime(lineId, idx, stationCount, line.travelTime, dir, new Date(), ext); if (lastH == null) return false; const nowH = new Date().getHours() + new Date().getMinutes()/60 + new Date().getSeconds()/3600; return nowH > lastH; } // Should we hide all live predictions? (service closed OR data stale > 2 min) const hidePredictions = serviceClosed || isStale; const headwaySecs = st.freqSecs || 300; const dwellSecs = isPeakHour() ? 30 : 20; const travelTimeSecs = (line.travelTime || 20) * 60; const interStation = travelTimeSecs / (stationCount - 1); const stationT = interStation + dwellSecs; // seconds per station including dwell (used by fallback) // ── Real trains from GTFS-RT (via get_trips_with_schedule) ─────────────── // Each train corresponds to a specific stop in the forecast; stationIdx is a // fractional index in line.stations so it can be placed on the vertical track. const { trains: realTrains, nextArrivalSecs, hasData: hasRealData } = useRealTrains(lineId); // Build per-direction lists of fractional positions. Direction_id 0 = heading // one way, 1 = the other. We map direction → fwd/bwd relative to line.stations[0]. // For lines in this system, the mapping is consistent: direction 0 travels from // stations[0] toward stations[last] (forward), direction 1 goes backward. let fwdPositions = []; let bwdPositions = []; if (hasRealData && !hidePredictions) { realTrains.forEach(tr => { if (tr.direction_id === 0) fwdPositions.push(tr.stationIdx); else bwdPositions.push(tr.stationIdx); }); } else if (!hidePredictions) { // Synthetic fallback (for lines without GTFS-RT data: C, H, U, P) const cycleSecs = travelTimeSecs + stationCount * dwellSecs; const nowSecs = Date.now() / 1000; const trainPos = (phaseOffset) => { const t = ((nowSecs + phaseOffset) % cycleSecs + cycleSecs) % cycleSecs; const idx = t / stationT; return idx < stationCount ? idx : null; }; const trainsPerDir = Math.ceil(travelTimeSecs / headwaySecs) + 1; fwdPositions = Array.from({ length: trainsPerDir }, (_, i) => trainPos(i * headwaySecs)) .filter(p => p !== null); bwdPositions = Array.from({ length: trainsPerDir }, (_, i) => { const p = trainPos(i * headwaySecs + headwaySecs * 0.37); return p !== null ? (stationCount - 1) - p : null; }).filter(p => p !== null); } const TRACK_OFFSET = 12; // px between the two parallel tracks (closer = more homogeneous) const TOTAL_W = TRACK_OFFSET + 12; // total width of dual-track zone return (
{/* ── Header — collapses on scroll ── */}
{/* Nav row — always visible */}
{/* Compact: letter badge + line name */}
{lineId}
{line.name}
{/* Right: freq (compact only) + heart */}
{st.freq}
{/* Full content — slides out when scrolled */}
Línea
{lineId}
{t('frequency')}
{st.freq}
{serviceClosed ? '🌙 Fin del servicio' : isStale ? '… Sin datos en vivo' : st.state === 'normal' ? '✓ ' + t('normalService') : '⚠ ' + (st.state === 'delay' ? t('delays') : t('alert'))}
{lineId === 'B' && (
Horario extendido en estaciones clave
)}
{/* Closing-soon banner — pesimista, titilando, con detalle por sentido */} {closingSoon && !serviceClosed && (() => { // último tren parte del terminal a (close - travelTime); arriba a la otra cabecera a `close` const lastDepartH = (() => { const w = getServiceWindow(lineId); if (!w) return null; return w.close - (line.travelTime / 60); })(); const lastDepart = lastDepartH != null ? decimalHourToLabel(lastDepartH) : '—'; return (
⚠ Último tren en {minsLeft} min
{!scrolled && (
↓ {line.head2}: último sale de {line.head1} a las {lastDepart}
↑ {line.head1}: último sale de {line.head2} a las {lastDepart}
)}
); })()} {/* Alert note — visible in both states (taller when scrolled + alert) */} {noteText && (
{noteText}
)}
{/* station strip */} {/* paddingBottom ensures enough overflow to always trigger header collapse */}
{/* Direction legend */}
via 1 ↓
{line.head1} → {line.head2}
via 2 ↑
{line.head2} → {line.head1}
{stationCount} estaciones · {line.travelTime} min punta a punta {(() => { const nowH = new Date().getHours() + new Date().getMinutes()/60; // Last train for each direction departing from each terminal // Para B, los terminales son estaciones extendidas → usar horario extendido (dir-específico) const termExt = lineId === 'B'; const fwdLastH = getLastTrainTime(lineId, 0, stationCount, line.travelTime, 0, new Date(), termExt); const bwdLastH = getLastTrainTime(lineId, stationCount - 1, stationCount, line.travelTime, 1, new Date(), termExt); const fwdPassed = fwdLastH != null && nowH > fwdLastH; const bwdPassed = bwdLastH != null && nowH > bwdLastH; const parts = []; if (fwdLastH != null && !fwdPassed) parts.push(`↓ ${line.head1} ${decimalHourToLabel(fwdLastH)}`); if (bwdLastH != null && !bwdPassed) parts.push(`↑ ${line.head2} ${decimalHourToLabel(bwdLastH)}`); if (parts.length === 0) return ' · tocá para más info'; return ' · último: ' + parts.join(' · '); })()}
{/* Dual-track + stations */}
{/* Track 1 — forward */}
{/* Track 2 — backward (same color, slightly faded) */}
{/* Live train icons hidden — representación en vivo desactivada */} {/* Station rows */} {line.stations.map((s, i) => { const isHead = i === 0 || i === stationCount - 1; const isClosed = CLOSED_TODAY.includes(s); const isAffected = !isClosed && affectedStations.has(s); const isExtended = lineId === 'B' && B_EXTENDED_STATIONS.includes(s); const crossings = CROSSINGS[s] || []; const detail = { ...STATION_DEFAULT, ...(STATION_DETAIL[s] || {}) }; const singleDir = detail.singleDir || 0; return ( ); })}
{/* end minHeight wrapper */}
{/* end scroll-area */} {/* Station popup */} {selectedStation && ( setSelectedStation(null)} /> )} {/* Notification config modal */} {showNotifModal && ( setShowNotifModal(false)} /> )}
); } function NotifConfigModal({ lineId, lineColor, txt, existing, onSave, onClose }) { const DAY_LABELS = ['Dom','Lun','Mar','Mié','Jue','Vie','Sáb']; const [days, setDays] = React.useState(existing.days ?? [1,2,3,4,5]); const [hourFrom, setHourFrom] = React.useState(existing.hourFrom ?? 6); const [hourTo, setHourTo] = React.useState(existing.hourTo ?? 23); const toggleDay = (d) => setDays(prev => prev.includes(d) ? prev.filter(x=>x!==d) : [...prev,d]); return (
e.stopPropagation()} style={{ background:'var(--surface)', borderRadius:'20px 20px 0 0', width:'100%', maxWidth:420, padding:'20px 20px 36px' }}>
Alertas — Línea {lineId}
Recibirás una notificación push cuando haya novedades en esta línea.
{/* Days */}
Días
{[1,2,3,4,5,6,0].map(d => ( ))}
{/* Hours */}
Horario
Desde
Hasta
); } // Chip showing next train arrival. Colors stay within the line's own hue palette. // `lineHex` is the line's dark hex color (e.g. '#0078B1' for A). `darkText` for light lines (H). function NextTrainChip({ secs, dir, lineHex, darkText = false, faded = false }) { const label = secs <= 0 ? '●' : secs < 60 ? `${secs}s` : secs < 3600 ? `${Math.round(secs / 60)}m` : '–'; // Stay within the line's hue: full color → tinted → very light → neutral bg const bg = secs <= 60 ? lineHex : secs <= 120 ? `color-mix(in srgb, ${lineHex} 72%, white)` : secs <= 300 ? `color-mix(in srgb, ${lineHex} 32%, white)` : 'var(--ink-100)'; // White text on dark/saturated bg; line color on tint; muted on neutral const fg = secs <= 120 ? (darkText ? '#0B1220' : 'white') : secs <= 300 ? lineHex : 'var(--text-muted)'; const bd = secs <= 300 ? bg : 'var(--border)'; return (
{dir}{label}
); } // Small animated train on a track function TrainIcon({ pos, stationCount, left, color, dir, opacity = 1 }) { return (
); } Object.assign(window, { ScreenLineDetail });