`; } }); function pad2(n){ return String(n).padStart(2,'0'); } function getTZ(root){ return (root.getAttribute('data-tz') || 'America/Bogota').trim() || 'America/Bogota'; } function nowPartsInTZ(tz){ const dtf = new Intl.DateTimeFormat('en-CA', { timeZone: tz, year:'numeric', month:'2-digit', day:'2-digit', hour:'2-digit', minute:'2-digit', second:'2-digit', hour12:false }); const parts = dtf.formatToParts(new Date()); const m = {}; for (const p of parts) if (p.type !== 'literal') m[p.type] = p.value; return { year:+m.year, month:+m.month, day:+m.day, hour:+m.hour, minute:+m.minute, second:+m.second }; } function ymdFromParts(p){ return `${p.year}-${pad2(p.month)}-${pad2(p.day)}`; } function minutesOfDayFromParts(p){ return p.hour*60 + p.minute; } function dateUTCNoonFromYMD(ymd){ const [y,m,d] = ymd.split('-').map(Number); return new Date(Date.UTC(y, m-1, d, 12, 0, 0)); } function ymdFromUTCNoon(dt){ return `${dt.getUTCFullYear()}-${pad2(dt.getUTCMonth()+1)}-${pad2(dt.getUTCDate())}`; } function addDaysYMD(ymd, delta){ const dt = dateUTCNoonFromYMD(ymd); dt.setUTCDate(dt.getUTCDate() + delta); return ymdFromUTCNoon(dt); } function fmtTimeNoSeconds(tz){ return new Intl.DateTimeFormat('es-CO', { timeZone: tz, hour:'numeric', minute:'2-digit', hour12:true }).format(new Date()); } function weekdayUpperES(tz, ymd){ const dt = dateUTCNoonFromYMD(ymd); const w = new Intl.DateTimeFormat('es-CO', { timeZone: tz, weekday:'long' }).format(dt); return String(w).toUpperCase(); } function prettyDateES(ymd){ const [y,m,d] = ymd.split('-').map(Number); const meses = ['Ene','Feb','Mar','Abr','May','Jun','Jul','Ago','Sep','Oct','Nov','Dic']; return `${d} de ${meses[m-1]} ${y}`; } function isHHMM(s){ return /^([01]\d|2[0-3]):([0-5]\d)$/.test(String(s||'').trim()); } function toMinutes(hhmm){ if (!isHHMM(hhmm)) return null; const [h,m] = hhmm.split(':').map(Number); return h*60 + m; } function time24to12ES(hhmm){ if (!isHHMM(hhmm)) return String(hhmm||''); const [h,m] = hhmm.split(':').map(Number); const h12 = ((h + 11) % 12) + 1; const suf = h >= 12 ? 'p. m.' : 'a. m.'; return `${h12}:${pad2(m)} ${suf}`; } function scheduleLabel(schedule){ const slots = Array.isArray(schedule) ? schedule : []; const parts = slots .filter(s => s && s.start && s.end) .map(s => `${time24to12ES(String(s.start))} a ${time24to12ES(String(s.end))}`); return parts.length ? parts.join(' · ') : 'Sin restricción'; } function getHorarioEstado(schedule, nowMin){ const slots = (Array.isArray(schedule) ? schedule : []) .map(s => ({ s: toMinutes(String(s.start||'')), e: toMinutes(String(s.end||'')) })) .filter(x => x.s !== null && x.e !== null) .sort((a,b) => a.s - b.s); if (!slots.length) return { kind:'none', endMin:null }; for (const slot of slots){ if (nowMin >= slot.s && nowMin < slot.e) return { kind:'active', endMin: slot.e }; } return { kind:'inactive', endMin:null }; } function countdownHM(endMin, nowMin){ if (endMin === null) return null; const rem = Math.max(0, endMin - nowMin); return { h: Math.floor(rem/60), m: rem % 60 }; } function safeUrl(u){ const s = String(u ?? '').trim(); return /^https?:\/\//i.test(s) ? s : ''; } function dayKeyFromYMD(ymd){ const dt = dateUTCNoonFromYMD(ymd); return ['sun','mon','tue','wed','thu','fri','sat'][dt.getUTCDay()]; } function iconSvg(key){ const common = `width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true"`; const car = `
`;
const taxi = `
`;
const moto = `
`;
const k = String(key||'').toLowerCase();
if (k.includes('moto')) return moto;
if (k.includes('taxi')) return taxi;
return car;
}
function pinSvg(){
return `
`;
}
function calSvg(){
return `
`;
}
// “NO_APLICA” por fin de semana / reglas, y mapa por fecha si aplica
function resolveRestriction(city, cat, ymd){
const rule = cat.rule || {};
const dk = dayKeyFromYMD(ymd);
if (rule.type === "weekday_pairs"){
if (rule.no_weekends && (dk === "sat" || dk === "sun")){
return { value:"NO APLICA", kind:"na", note:"Fin de semana: no aplica.", schedule: [] };
}
const v = rule.weekdays && rule.weekdays[dk] ? String(rule.weekdays[dk]) : "";
if (!v) return { value:"SIN INFORMACIÓN", kind:"unknown", note:"No hay dato cargado para esta fecha.", schedule: [] };
return { value:v, kind:"digits", note:"", schedule: cat.schedule || [] };
}
if (rule.type === "date_map"){
const m = rule.map || {};
const v = m[ymd];
if (!v){
return { value:"SIN INFORMACIÓN", kind:"unknown", note:String(rule.note_on_no_data||"").trim(), schedule: [] };
}
if (String(v).toUpperCase() === "NO_APLICA"){
return { value:"NO APLICA", kind:"na", note:"No aplica para esta fecha.", schedule: [] };
}
return { value:String(v), kind:"digits", note:"", schedule: cat.schedule || [] };
}
return { value:"SIN INFORMACIÓN", kind:"unknown", note:"", schedule: [] };
}
function mount(root){
const tz = getTZ(root);
const key = String(root.getAttribute("data-key") || "").trim();
const cfg = window.PZ_PYP_DATA && window.PZ_PYP_DATA[key] ? window.PZ_PYP_DATA[key] : null;
if (!cfg) throw new Error(`No existe window.PZ_PYP_DATA["${key}"]. Revisa data-key.`);
const city = cfg.city;
const options = (city.switch && Array.isArray(city.switch.options)) ? city.switch.options : [];
root.innerHTML = `
Hora: --:--
${pinSvg()}
Cambiar ciudad – ${city.name}
‹ AYER
${calSvg()}
--
--
Toca para elegir fecha
MAÑANA ›
Si vas a viajar, revisa el pico y placa para la fecha de tu viaje.
Filtrar:
`;
const $ = (sel) => root.querySelector(sel);
const elTitle = $('.pz-title');
const elTime = $('.pz-time');
const elCity = $('.pz-cityselect');
const elWeek = $('.pz-weekday');
const elDate = $('.pz-date');
const elDateInput = $('.pz-dateinput');
const elDateCenter = $('.pz-datecenter');
const elPrevDay = $('.pz-daybtn--left');
const elNextDay = $('.pz-daybtn--right');
const elFoot = $('.pz-foot');
const elFilter = $('.pz-filterselect');
const elTrack = $('.pz-track');
const elPrev = $('.pz-nav--prev');
const elNext = $('.pz-nav--next');
// City options
elCity.innerHTML = options
.slice()
.sort((a,b) => String(a.name||'').localeCompare(String(b.name||''), 'es'))
.map(o => ``)
.join('');
elCity.value = city.id;
// Filter options
const catsAll = (Array.isArray(city.categories) ? city.categories : [])
.slice()
.sort((a,b) => (Number(a.priority||99) - Number(b.priority||99)) || String(a.name||'').localeCompare(String(b.name||''), 'es'));
elFilter.innerHTML = [
``,
...catsAll.map(c => ``)
].join('');
elFilter.value = "__all__";
let todayTZ = ymdFromParts(nowPartsInTZ(tz));
let selectedYMD = todayTZ;
// Carrusel
let visiblePerView = calcVisiblePerView();
let index = 0;
let renderedCats = [];
function calcVisiblePerView(){
return window.matchMedia('(min-width: 900px)').matches ? 2 : 1;
}
function updateHeader(){
elTitle.textContent = `PICO Y PLACA ${String(city.name || city.id).toUpperCase()}`.trim();
elTime.textContent = `Hora: ${fmtTimeNoSeconds(tz)}`;
elWeek.textContent = weekdayUpperES(tz, selectedYMD);
elDate.textContent = prettyDateES(selectedYMD);
elDateInput.value = selectedYMD;
}
function updateFooter(){
const srcName = String(city.source && city.source.name ? city.source.name : '').trim();
const srcUrl = safeUrl(city.source && city.source.url ? city.source.url : '');
if (srcUrl){
elFoot.innerHTML = `Fuente: ${srcName || 'Fuente oficial'}`;
} else {
elFoot.textContent = srcName ? `Fuente: ${srcName}` : '';
}
}
function openPicker(){
if (typeof elDateInput.showPicker === 'function') elDateInput.showPicker();
else elDateInput.focus();
}
function getFilteredCats(){
const v = String(elFilter.value || "__all__");
if (v === "__all__") return catsAll;
return catsAll.filter(c => String(c.id) === v);
}
function cardHTML(cat){
return `
${iconSvg(cat.icon || cat.id)}
${String(cat.name || cat.id).toUpperCase()}
Horario: --
`;
}
function clampIndex(){
const maxIndex = Math.max(0, renderedCats.length - visiblePerView);
index = Math.max(0, Math.min(index, maxIndex));
}
function updateNavState(){
const maxIndex = Math.max(0, renderedCats.length - visiblePerView);
const showNav = renderedCats.length > visiblePerView;
elPrev.style.display = showNav ? 'flex' : 'none';
elNext.style.display = showNav ? 'flex' : 'none';
elPrev.disabled = (index <= 0);
elNext.disabled = (index >= maxIndex);
root.querySelector('.pz-swipehint').style.display = showNav ? 'block' : 'none';
}
function applyTransform(){
const card = elTrack.querySelector('.pz-card');
if (!card) return;
const gap = parseFloat(getComputedStyle(elTrack).columnGap || getComputedStyle(elTrack).gap || '16') || 16;
const cardW = card.getBoundingClientRect().width;
const step = cardW + gap;
elTrack.style.transform = `translateX(${-index * step}px)`;
}
function renderCards(){
visiblePerView = calcVisiblePerView();
renderedCats = getFilteredCats();
if (!renderedCats.length){
elTrack.innerHTML = `
No hay categorías para mostrar con este filtro.
`;
elTrack.style.transform = 'translateX(0px)';
updateNavState();
return;
}
// Importante: para evitar “huecos” visuales, el carrusel siempre es una sola fila.
elTrack.innerHTML = renderedCats.map(cardHTML).join('');
index = 0;
clampIndex();
updateNavState();
applyTransform();
updateCardsDynamic();
}
function updateCardsDynamic(){
const nowP = nowPartsInTZ(tz);
todayTZ = ymdFromParts(nowP);
const nowMin = minutesOfDayFromParts(nowP);
const isTodaySelected = (selectedYMD === todayTZ);
// Actualiza solo lo que está renderizado
elTrack.querySelectorAll('.pz-card').forEach(card => {
const catId = String(card.getAttribute('data-cat') || '');
const cat = renderedCats.find(c => String(c.id) === catId);
if (!cat) return;
const refs = {
label: card.querySelector('.pz-label'),
value: card.querySelector('.pz-value'),
cd: card.querySelector('.pz-countdown'),
cdText: card.querySelector('.pz-counttext'),
band: card.querySelector('.pz-band'),
bandText: card.querySelector('.pz-bandtext'),
note: card.querySelector('.pz-note')
};
const r = resolveRestriction(city, cat, selectedYMD);
const sched = Array.isArray(r.schedule) && r.schedule.length ? r.schedule : (Array.isArray(cat.schedule) ? cat.schedule : []);
const schedLabel = scheduleLabel(sched);
refs.value.classList.remove('pz-value--na');
if (r.kind === 'na'){
refs.label.textContent = 'Estado:';
refs.value.textContent = 'NO APLICA';
refs.value.classList.add('pz-value--na');
refs.band.classList.remove('pz-band--yellow');
refs.band.classList.add('pz-band--gray');
refs.bandText.textContent = 'Sin restricción';
} else if (r.kind === 'unknown'){
refs.label.textContent = 'Estado:';
refs.value.textContent = 'SIN INFORMACIÓN';
refs.band.classList.remove('pz-band--yellow');
refs.band.classList.add('pz-band--gray');
refs.bandText.textContent = 'Sin restricción';
} else {
refs.label.textContent = String(cat.plate_label || 'Placas terminadas en:');
refs.value.textContent = String(r.value);
refs.band.classList.remove('pz-band--gray');
refs.band.classList.add('pz-band--yellow');
refs.bandText.textContent = schedLabel;
}
const note = String(r.note || '').trim();
if (note){ refs.note.style.display = 'block'; refs.note.textContent = note; }
else { refs.note.style.display = 'none'; refs.note.textContent = ''; }
// Conteo: solo si hoy y está dentro de un tramo
const canCount = isTodaySelected && r.kind === 'digits' && Array.isArray(sched) && sched.length > 0;
if (!canCount){ refs.cd.style.display = 'none'; refs.cdText.textContent = ''; return; }
const st = getHorarioEstado(sched, nowMin);
if (st.kind !== 'active'){ refs.cd.style.display = 'none'; refs.cdText.textContent = ''; return; }
const hm = countdownHM(st.endMin, nowMin);
refs.cd.style.display = 'flex';
refs.cdText.textContent = `Termina en: ${hm.h}h ${pad2(hm.m)}m`;
});
elTime.textContent = `Hora: ${fmtTimeNoSeconds(tz)}`;
}
// Events
elCity.addEventListener('change', () => {
const nextId = elCity.value;
const target = options.find(o => String(o.id) === String(nextId));
const url = target && target.url ? String(target.url).trim() : '';
if (safeUrl(url)) window.location.href = url;
});
elPrevDay.addEventListener('click', () => {
selectedYMD = addDaysYMD(selectedYMD, -1);
updateHeader();
updateCardsDynamic();
});
elNextDay.addEventListener('click', () => {
selectedYMD = addDaysYMD(selectedYMD, +1);
updateHeader();
updateCardsDynamic();
});
elDateCenter.addEventListener('click', openPicker);
elDateCenter.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' '){ e.preventDefault(); openPicker(); }
});
elDateInput.addEventListener('change', () => {
if (elDateInput.value){
selectedYMD = elDateInput.value;
updateHeader();
updateCardsDynamic();
}
});
elFilter.addEventListener('change', () => {
renderCards();
});
elPrev.addEventListener('click', () => {
index -= 1;
clampIndex();
updateNavState();
applyTransform();
});
elNext.addEventListener('click', () => {
index += 1;
clampIndex();
updateNavState();
applyTransform();
});
// Swipe/drag (desktop + mobile) para que “se note” que es deslizable
let drag = { active:false, startX:0, startT:0, moved:false };
function getTranslateX(){
const m = /translateX\(([-\d.]+)px\)/.exec(elTrack.style.transform || '');
return m ? parseFloat(m[1]) : 0;
}
function onDown(e){
if (renderedCats.length <= visiblePerView) return;
drag.active = true;
drag.moved = false;
drag.startX = (e.touches ? e.touches[0].clientX : e.clientX);
drag.startT = getTranslateX();
elTrack.classList.add('pz-dragging');
}
function onMove(e){
if (!drag.active) return;
const x = (e.touches ? e.touches[0].clientX : e.clientX);
const dx = x - drag.startX;
if (Math.abs(dx) > 6) drag.moved = true;
elTrack.style.transform = `translateX(${drag.startT + dx}px)`;
}
function onUp(){
if (!drag.active) return;
drag.active = false;
elTrack.classList.remove('pz-dragging');
// Snap al índice más cercano
const card = elTrack.querySelector('.pz-card');
if (!card){ applyTransform(); return; }
const gap = parseFloat(getComputedStyle(elTrack).columnGap || getComputedStyle(elTrack).gap || '16') || 16;
const cardW = card.getBoundingClientRect().width;
const step = cardW + gap;
const current = getTranslateX(); // negativo
const rawIndex = Math.round(Math.abs(current) / step);
index = rawIndex;
clampIndex();
updateNavState();
applyTransform();
}
const wrap = root.querySelector('.pz-trackwrap');
wrap.addEventListener('mousedown', onDown);
wrap.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
wrap.addEventListener('touchstart', onDown, { passive:true });
wrap.addEventListener('touchmove', onMove, { passive:true });
wrap.addEventListener('touchend', onUp);
window.addEventListener('resize', () => {
const next = calcVisiblePerView();
if (next !== visiblePerView){
visiblePerView = next;
index = 0;
updateNavState();
applyTransform();
} else {
applyTransform();
}
});
// Init
updateHeader();
updateFooter();
renderCards();
root._pzTimer && clearInterval(root._pzTimer);
root._pzTimer = setInterval(() => { updateHeader(); updateCardsDynamic(); }, 30 * 1000);
}
})();