--:--:--
Bienvenido a gasolinasmx
Gu\xEDa r\xE1pida de funciones
B\xFAsqueda de estaciones
Escribe el nombre de una estaci\xF3n, su n\xFAmero de permiso CRE o su ID para filtrar los resultados en tiempo real.

Atajo de teclado: presiona / desde cualquier pantalla para enfocar la b\xFAsqueda.
TipPuedes buscar por permiso CRE completo, por ejemplo: PL/6005/EXP/ES/2015
Fichas de estaciones
Vista en cuadr\xEDcula con todas las estaciones. Cada ficha muestra el permiso CRE, nombre, y precios de Regular, Premium y Di\xE9sel.

Al hacer clic en una ficha se expanden los detalles: coordenadas GPS (bot\xF3n para ir al mapa), e historial de precios de los \xFAltimos 30 d\xEDas.
NuevoEl bot\xF3n GPS en cada ficha lleva directo al mapa centrado en esa estaci\xF3n.
Mapa interactivo
Vista de mapa con 14,744 estaciones como marcadores. Los marcadores se agrupan en clusters al alejar el zoom.

Usa el slider de zoom o la rueda del rat\xF3n. El pin azul indica tu ubicaci\xF3n actual cuando el filtro de cercan\xEDa est\xE1 activo.
TipEn el mapa, al hacer clic en un marcador aparece un popup con precios y enlace a Google Maps.
Cerca de m\xED
Filtra las estaciones m\xE1s cercanas a tu ubicaci\xF3n. Selecciona el radio en km (5, 10 o 15 km) y activa el bot\xF3n.

Las fichas y el mapa se actualizan mostrando solo las estaciones dentro del radio. Las fichas se ordenan de m\xE1s cercana a m\xE1s lejana.
GPSTu ubicaci\xF3n se actualiza autom\xE1ticamente si te mueves \u2014 el filtro es din\xE1mico.
Mi Estaci\xF3n
Selecciona tu estaci\xF3n principal (bot\xF3n \u2605 en cualquier ficha) y hasta 10 estaciones competidoras (bot\xF3n +).

El panel muestra una tabla comparativa de precios. El precio m\xE1s bajo lleva badge verde \u2713 y el m\xE1s alto badge rojo \u2191.
CuentaTu selecci\xF3n se guarda por cuenta \u2014 cada usuario administrador tiene su propia configuraci\xF3n.
Mi Panel
Acceso a la administraci\xF3n de tu cuenta seg\xFAn tu rol:

\u2022 Superadmin \u2192 acceso al panel completo de precios y usuarios
\u2022 Administrador \u2192 gestiona hasta 3 sub-usuarios de tu cuenta
\u2022 Usuario \u2192 cambia tu contrase\xF1a
Sesi\xF3nEl bot\xF3n rojo Salir cierra tu sesi\xF3n. La sesi\xF3n expira autom\xE1ticamente tras 5 minutos de inactividad.
Configura tu estaci\xF3n principal
A\xFAn no has seleccionado tu estaci\xF3n principal. Ve a Fichas, busca tu estaci\xF3n y toca \u2605 para marcarla.

Cargando datos

Consultando precios y estaciones CRE

Conectando...
Descargando estaciones
Preparando vistas

Sin datos disponibles

No se encontraron datos.

\u2192 Abrir /admin para cargar datos

\u21BA Actualizar \u{1F4CD} Ubicaci\xF3n
Zoom del mapa Zoom 5

Combustible

Regular
Premium
Di\xE9sel
Sin precio
}); document.getElementById('btnRetry').addEventListener('click', init); // \u2500\u2500 Geolocalizaci\xF3n \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 var GEO_KM = 15; // distancia fija var geoBtn = document.getElementById('geoBtn'); var mapGeoBtn = document.getElementById('mapGeoBtn'); var geoKmSel = document.getElementById('geoKmSel'); var mapGeoKm = document.getElementById('mapGeoKm'); function getGeoKm() { var sel = document.getElementById('geoKmSel') || document.getElementById('mapGeoKm'); return sel ? parseInt(sel.value, 10) : GEO_KM; } function syncGeoKm(val) { var s1 = document.getElementById('geoKmSel'); var s2 = document.getElementById('mapGeoKm'); if (s1) s1.value = val; if (s2) s2.value = val; if (s1) s1.classList.toggle('active', !!userCoords); if (s2) s2.classList.toggle('active', !!userCoords); } document.addEventListener('change', function(e) { if (e.target.id === 'geoKmSel' || e.target.id === 'mapGeoKm') { syncGeoKm(e.target.value); if (userCoords) { applyFilter(); if (activeView === 'map') renderMapMarkers(); } } }); // Funci\xF3n compartida: activar/desactivar geolocalizaci\xF3n function toggleGeo(onActivate, onDeactivate, onError) { if (userCoords) { userCoords = null; if (onDeactivate) onDeactivate(); return; } if (!navigator.geolocation) { toast('Tu navegador no soporta geolocalizaci\xF3n'); return; } geoBtn.classList.add('loading'); mapGeoBtn.textContent = 'Localizando...'; navigator.geolocation.watchPosition( function(pos) { var isFirst = !userCoords; userCoords = { lat: pos.coords.latitude, lon: pos.coords.longitude }; if (isFirst) { geoBtn.classList.remove('loading'); if (onActivate) onActivate(); } else { // Live update: refresh filter + move user pin applyFilter(); if (activeView === 'map' && userMarker && mapInst) { userMarker.setLatLng([userCoords.lat, userCoords.lon]); document.getElementById('mapGeoCount').textContent = (filtered.length || 0).toLocaleString() + ' estaciones'; } } }, function(err) { geoBtn.classList.remove('loading'); mapGeoBtn.textContent = 'Cerca de m\xED'; if (onError) onError(err); toast(err.code === 1 ? 'Permiso de ubicaci\xF3n denegado' : 'No se pudo obtener la ubicaci\xF3n'); }, { enableHighAccuracy: true, timeout: 15000, maximumAge: 5000 } ); } function syncGeoBtns() { var active = !!userCoords; // Bot\xF3n en controles de fichas geoBtn.classList.toggle('active', active); geoBtn.innerHTML = active ? ' ' + getGeoKm() + ' km \u2715' : ' Cerca de m\xED (15 km)'; // Bot\xF3n en panel del mapa mapGeoBtn.classList.toggle('active', active); mapGeoBtn.textContent = active ? getGeoKm() + ' km \u2715' : 'Cerca de m\xED'; } geoBtn.addEventListener('click', function() { toggleGeo( function() { syncGeoBtns(); applyFilter(); if (activeView === 'map') renderMapMarkers(); }, function() { syncGeoBtns(); applyFilter(); if (activeView === 'map') { mapInst && mapInst.setView([23.6, -102.5], 5); renderMapMarkers(); } }, function() { syncGeoBtns(); } ); }); mapGeoBtn.addEventListener('click', function() { toggleGeo( function() { syncGeoBtns(); applyFilter(); renderMapMarkers(); }, function() { syncGeoBtns(); applyFilter(); mapInst && mapInst.setView([23.6, -102.5], 5); renderMapMarkers(); }, function() { syncGeoBtns(); } ); }); // Botones de zoom del mapa var mapZoomSlider = document.getElementById('mapZoomSlider'); var mapZoomVal = document.getElementById('mapZoomVal'); mapZoomSlider.addEventListener('input', function() { var z = parseInt(this.value, 10); mapZoomVal.textContent = 'Zoom ' + z; if (mapInst) mapInst.setZoom(z); }); var mapZoomSynced = false; function syncMapZoom() { if (!mapInst || mapZoomSynced) return; mapZoomSynced = true; mapInst.on('zoomend', function() { var z = mapInst.getZoom(); mapZoomSlider.value = z; mapZoomVal.textContent = 'Zoom ' + z; }); } // Bot\xF3n refresh dentro del panel del mapa document.getElementById('mapRefBtn').addEventListener('click', function() { var btn = this; btn.textContent = 'Actualizando...'; btn.style.opacity = '.6'; document.getElementById('btnRef').click(); setTimeout(function() { btn.textContent = 'Actualizar mapa'; btn.style.opacity = '1'; }, 2000); }); document.addEventListener('keydown', function(e) { if (e.key === '/' && document.activeElement.tagName !== 'INPUT') { e.preventDefault(); document.getElementById('searchIn').focus(); } }); // \u2500\u2500 Mi Estacion \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 var MI_KEY = 'gx_mi_v2'; // base key \u2014 actual key set in getMiKey() var miData = { main: null, competitors: [] }; function getMiKey() { // Per-account key so each admin account has isolated Mi Estacion config var acct = (mySession && mySession.accountNo) ? mySession.accountNo : ''; return acct ? MI_KEY + '_' + acct : MI_KEY; } function loadMi() { var key = getMiKey(); try { miData = JSON.parse(localStorage.getItem(key) || '{"main":null,"competitors":[]}'); } catch(e) { miData = { main: null, competitors: [] }; } if (!miData.competitors) miData.competitors = []; if (mySession && mySession.accountNo) { fetch('https://gasolinasmx.com/api/my/mi-config').then(function(r){return r.json();}).then(function(d){ if (d.ok && d.data && d.data.main) { window._pendingMiKV = d.data; localStorage.setItem(GUIDE_KEY, 'skip'); if (!miData.main) { miData.main = d.data.main; miData.competitors = d.data.competitors || []; localStorage.setItem(getMiKey(), JSON.stringify(miData)); if (typeof renderMi === 'function') renderMi(); if (typeof applyFilter === 'function') applyFilter(); } } }).catch(function(){}); } } function saveMi() { // Sub-users (role='user') cannot modify Mi Estacion if (mySession && mySession.role === 'user') return; localStorage.setItem(getMiKey(), JSON.stringify(miData)); // Sync to KV so cron can read Mi Estacion for alerts syncMiEstacionToKV(); } function setMain(id) { if (mySession && mySession.role === 'user') { toast('Mi Estaci\xF3n es de solo lectura'); return; } miData.main = (miData.main === id) ? null : id; // Remove from competitors if set as main miData.competitors = miData.competitors.filter(function(c){ return c !== id; }); saveMi(); // Re-render cards (only update badges, not full re-render) updateSelBadges(); } function setComp(id) { if (mySession && mySession.role === 'user') { toast('Mi Estaci\xF3n es de solo lectura'); return; } var idx = miData.competitors.indexOf(id); if (idx !== -1) { miData.competitors.splice(idx, 1); } else { if (miData.competitors.length >= 10) { toast('M\xE1ximo 10 estaciones competidoras'); return; } if (miData.main === id) { toast('Esta estaci\xF3n ya es tu principal'); return; } miData.competitors.push(id); } saveMi(); updateSelBadges(); } function updateSelBadges() { // Update all visible cards without re-rendering var cards = document.querySelectorAll('.card'); for (var i = 0; i < cards.length; i++) { var cid = cards[i].id.replace('c-', ''); var btnM = cards[i].querySelector('.sel-main'); var btnC = cards[i].querySelector('.sel-comp'); if (btnM) btnM.className = 'sel-main' + (miData.main === cid ? ' on' : ''); if (btnC) btnC.className = 'sel-comp' + (miData.competitors.indexOf(cid) !== -1 ? ' on' : ''); } // Refresh Mi Estacion view if visible if (activeView === 'mi') renderMi(); } function getMiById(id) { for (var i = 0; i < all.length; i++) if (all[i].id === id) return all[i]; return null; } function fmtPrice(v) { return v !== null ? '$' + v.toFixed(2) : '\u2014'; } function priceDiff(a, b) { if (a === null || b === null) return ''; var d = b - a; // positive = competitor more expensive if (Math.abs(d) < 0.005) return '='; var sign = d > 0 ? '+' : ''; var cls = d > 0 ? 'cmp-diff-pos' : 'cmp-diff-neg'; // pos = comp more expensive (good for main) return '' + sign + '$' + Math.abs(d).toFixed(2) + ''; } function renderMi() { var el = document.getElementById('miContent'); var mainSt = miData.main ? getMiById(miData.main) : null; var comps = miData.competitors.map(getMiById).filter(Boolean); if (!mainSt && !comps.length) { el.innerHTML = '
' + '' + '
Sin estaciones seleccionadas
' + '
En las fichas usa ★ para marcar tu estaci\xF3n principal y + para agregar hasta 10 competidoras.
' + '
'; return; } var html = ''; // \u2500\u2500 Main station card \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 if (mainSt) { var mn = cleanName(mainSt.name) || mainSt.name || 'Estaci\xF3n ' + mainSt.id; html += '
' + '

' + 'Mi Estaci\xF3n Principal

' + '
' + (mainSt.cre_id || '\u2014') + '
' + '
' + mn + '
' + '
' + (mainSt.regular !== null ? '
Regular$' + mainSt.regular.toFixed(2) + '
' : '') + (mainSt.premium !== null ? '
Premium$' + mainSt.premium.toFixed(2) + '
' : '') + (mainSt.diesel !== null ? '
Diésel$' + mainSt.diesel.toFixed(2) + '
' : '') + '
' + '
' + '
'; } // \u2500\u2500 Comparison table \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 if (comps.length) { html += '
' + '

' + 'Comparativo de precios

' + '
' + '' + '' + '' + (mainSt ? '' : '') + '' + (mainSt ? '' : '') + '' + (mainSt ? '' : '') + ''; // Compute per-fuel winners (min=green, max=red) across all stations var allSts = (mainSt ? [mainSt] : []).concat(comps); function fuelRange(key) { var vals = allSts.map(function(s){ return s[key]; }).filter(function(v){ return v !== null; }); if (vals.length < 2) return { min: null, max: null }; return { min: Math.min.apply(null,vals), max: Math.max.apply(null,vals) }; } var wR = fuelRange('regular'), wP = fuelRange('premium'), wD = fuelRange('diesel'); function priceCell(val, w) { if (val === null) return fmtPrice(val); var badge = ''; if (w.min !== null && Math.abs(val - w.min) < 0.005) badge = '\u2713'; else if (w.max !== null && Math.abs(val - w.max) < 0.005) badge = '\u2191'; return '$' + val.toFixed(2) + badge + ''; } // Main row if (mainSt) { html += '' + '' + '' + '' + '' + ''; } comps.forEach(function(c) { html += '' + '' + '' + (mainSt ? '' : '') + '' + (mainSt ? '' : '') + '' + (mainSt ? '' : '') + ''; }); html += '
Estaci\xF3nRegularDif.PremiumDif.Di\xE9selDif.
' + (cleanName(mainSt.name)||mainSt.name||mainSt.id) + '
' + '
' + (mainSt.cre_id||'') + ' Principal
' + priceCell(mainSt.regular, wR) + '' + priceCell(mainSt.premium, wP) + '' + priceCell(mainSt.diesel, wD) + '
' + (cleanName(c.name)||c.name||c.id) + '
' + '
' + (c.cre_id||'') + ' Competidor
' + priceCell(c.regular, wR) + '' + priceDiff(mainSt.regular, c.regular) + '' + priceCell(c.premium, wP) + '' + priceDiff(mainSt.premium, c.premium) + '' + priceCell(c.diesel, wD) + '' + priceDiff(mainSt.diesel, c.diesel) + '
' + '
' + 'Precio m\xE1s bajo  |  Precio m\xE1s alto' + '
' + '
'; } else if (mainSt) { html += '
' + 'En las fichas usa + para agregar hasta 10 estaciones competidoras.' + '
'; } // \u2500\u2500 Price history charts \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 var allSts = typeof priceHistory !== "undefined" ? priceHistory : (typeof all !== "undefined" ? all : []); if ((mainSt || comps.length) && allSts && allSts.length) { var chartStations = []; if (mainSt) chartStations.push({ id: mainSt.id, label: (cleanName(mainSt.name)||mainSt.id) + ' (Principal)', colorIdx: 0 }); comps.forEach(function(c, ci) { chartStations.push({ id: c.id, label: cleanName(c.name)||c.id, colorIdx: ci + 1 }); }); var chartHtml = buildPriceChart(chartStations); if (chartHtml) html += chartHtml; } // \u2500\u2500 Actions \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 html += '
'; if (miData.main) html += ''; if (miData.competitors.length) html += ''; html += '
'; el.innerHTML = html; } function exportMiPDF() { var mainSt = miData.main ? getMiById(miData.main) : null; var comps = miData.competitors.map(getMiById).filter(Boolean); if (!mainSt && !comps.length) { toast('Sin estaciones seleccionadas'); return; } var all2 = (mainSt ? [mainSt] : []).concat(comps); var W=792, H=612, m=16; var svg=[]; svg.push(''); svg.push(''); // Header svg.push(''); svg.push(''); svg.push('G'); svg.push('gasolinasmx'); svg.push('REPORTE COMPARATIVO DE PRECIOS'); svg.push(''+new Date().toLocaleDateString('es-MX',{weekday:'long',day:'numeric',month:'long',year:'numeric'})+''); // Tarjeta principal - precios en linea separada var cardH = 62; if (mainSt) { var mn = (cleanName(mainSt.name)||mainSt.name||mainSt.id).slice(0,55); svg.push(''); svg.push(''); svg.push('\u2605 Mi Estaci\xF3n Principal'); svg.push(''); svg.push(''+(mainSt.cre_id||'')+''); svg.push(''+mn+''); // Precios en segunda linea var px2 = m+10; // Contar combustibles disponibles para distribuir centrado var avFuels=[]; if(mainSt.regular!==null) avFuels.push({label:'REGULAR',val:mainSt.regular,fill:'#2e9960'}); if(mainSt.premium!==null) avFuels.push({label:'PREMIUM',val:mainSt.premium,fill:'#dc2626'}); if(mainSt.diesel!==null) avFuels.push({label:'DIESEL', val:mainSt.diesel, fill:'#171717'}); var btnGap=10; var availW=W-m*2; var btnW=Math.floor((availW-(avFuels.length-1)*btnGap)/avFuels.length); var startX=m; avFuels.forEach(function(f,fi){ var bx=startX+fi*(btnW+btnGap); svg.push(''); svg.push(''+f.label+' $'+f.val.toFixed(2)+''); }); } // Tabla var ty = 50 + cardH + 6; var cols=[ {x:m, w:220, label:'ESTACI\xD3N'}, {x:m+222,w:54, label:''}, {x:m+280,w:82, label:'REGULAR'}, {x:m+364,w:58, label:'DIF.'}, {x:m+424,w:82, label:'PREMIUM'}, {x:m+508,w:58, label:'DIF.'}, {x:m+568,w:82, label:'DI\xC9SEL'}, {x:m+652,w:58, label:'DIF.'} ]; svg.push(''); cols.forEach(function(col){ if(col.label) svg.push(''+col.label+''); }); function fuelRange3(key){ var vals=all2.map(function(s){return s[key];}).filter(function(v){return v!==null;}); if(!vals.length)return{min:null,max:null}; return{min:Math.min.apply(null,vals),max:Math.max.apply(null,vals)}; } var wR3=fuelRange3('regular'),wP3=fuelRange3('premium'),wD3=fuelRange3('diesel'); // Mejor precio por combustible function getBest(key){ var vals=all2.filter(function(s){return s[key]!==null;}); if(!vals.length) return null; var minVal=Math.min.apply(null,vals.map(function(s){return s[key];})); var tied=vals.filter(function(s){return Math.abs(s[key]-minVal)<0.005;}); var mainTie=tied.filter(function(s){return s.id===miData.main;}); return mainTie.length?miData.main:tied[0].id; } var bestR=getBest('regular'),bestP=getBest('premium'),bestD=getBest('diesel'); var rowH=18; all2.forEach(function(st,ri){ var ry=ty+17+ri*rowH; var isMain=(st.id===miData.main); svg.push(''); if(isMain) svg.push(''); var nm=(cleanName(st.name)||st.name||st.id).slice(0,32); svg.push(''+nm+''); if(isMain){ svg.push(''); svg.push('Principal'); } else { svg.push(''); svg.push('Competidor'); } function priceCol(val, cx, difX, minmax, mainVal, bestId){ if(val===null){svg.push('\u2014');return;} var isMin=(minmax.min!==null&&Math.abs(val-minmax.min)<0.005); var isMax=(minmax.max!==null&&Math.abs(val-minmax.max)<0.005&&minmax.max>minmax.min); var fc=isMin?'#16a34a':isMax?'#dc2626':'#212529'; svg.push('$'+val.toFixed(2)+''); // Corona estrella al mejor precio if(st.id===bestId){ svg.push('\u2605'); } else if(isMax){ svg.push(''); svg.push('\u2191'); } if(!isMain&&mainVal!==null){ var delta=val-mainVal; if(Math.abs(delta)>=0.005){ var sign=delta>0?'+':''; svg.push(''+sign+'$'+Math.abs(delta).toFixed(2)+''); } else { svg.push('='); } } } priceCol(st.regular, m+280, m+364, wR3, mainSt?mainSt.regular:null, bestR); priceCol(st.premium, m+424, m+508, wP3, mainSt?mainSt.premium:null, bestP); priceCol(st.diesel, m+568, m+652, wD3, mainSt?mainSt.diesel:null, bestD); }); // divisor var divY3=ty+17+all2.length*rowH+4; svg.push(''); // Graficas 3 productos - distribuidas verticalmente var gy3=divY3+6; var graphBottom=H-32; // dejar espacio para promedios y footer var ghTotal=graphBottom-gy3; var fuelH=Math.floor(ghTotal/3)-4; var fuels=[ {key:'regular',label:'Regular',color:'#16a34a',mainVal:mainSt?mainSt.regular:null}, {key:'premium',label:'Premium',color:'#dc2626',mainVal:mainSt?mainSt.premium:null}, {key:'diesel', label:'Diesel', color:'#374151',mainVal:mainSt?mainSt.diesel:null} ]; fuels.forEach(function(fuel,fi){ var fy=gy3+fi*(fuelH+4); var fw=W-m*2; if(fuel.mainVal===null||!comps.length) return; var sorted=comps.filter(function(s){return s[fuel.key]!==null;}).sort(function(a,b){return a[fuel.key]-b[fuel.key];}); if(!sorted.length) return; var maxAbs=Math.max.apply(null,sorted.map(function(s){return Math.abs(s[fuel.key]-fuel.mainVal);})); if(maxAbs<0.01) maxAbs=0.5; var labelW2=140, barAreaW=fw-labelW2-60, zeroX=m+labelW2+30+Math.round(barAreaW/2); var barH2=Math.max(8,Math.min(14,Math.floor((fuelH-18)/sorted.length)-2)); var startY=fy+18; // Titulo svg.push(''); svg.push(''+fuel.label+' \u2014 Mi precio: $'+fuel.mainVal.toFixed(2)+''); svg.push(''); sorted.forEach(function(st,bi){ var by=startY+bi*(barH2+2); var delta=st[fuel.key]-fuel.mainVal; var bw=Math.round(Math.abs(delta)/maxAbs*(barAreaW/2)); var bx=delta<0?zeroX-bw:zeroX; var col=delta<0?'#16a34a':delta>0?'#dc2626':'#adb5bd'; var nm=(cleanName(st.name)||st.id).slice(0,22); svg.push(''+nm+''); svg.push(''); var sign=delta>0?'+':''; var lx=delta>=0?bx+Math.max(bw,4)+3:bx-3; svg.push(''+sign+'$'+Math.abs(delta).toFixed(2)+''); }); }); // Promedios nacionales usando array global 'all' var avgY=H-30; svg.push(''); svg.push('PROMEDIOS NACIONALES (CRE):'); var avgX=m+175; try { var srcData = (typeof filtered!=='undefined'&&filtered.length?filtered:(typeof all!=='undefined'?all:[])); if(srcData&&srcData.length){ var natPrices=[ {label:'Regular',key:'regular',color:'#16a34a'}, {label:'Premium',key:'premium',color:'#dc2626'}, {label:'Diesel', key:'diesel', color:'#374151'} ]; natPrices.forEach(function(np){ var vals=srcData.filter(function(s){return s[np.key]!==null&&s[np.key]>0;}).map(function(s){return s[np.key];}); if(vals.length){ var avg=vals.reduce(function(a,b){return a+b;},0)/vals.length; svg.push(''+np.label+': $'+avg.toFixed(2)+''); avgX+=110; } }); } } catch(e) {} // Footer svg.push(''); svg.push('gasolinasmx.com \xB7 Datos: Comisi\xF3n Reguladora de Energ\xEDa \xB7 '+new Date().toLocaleTimeString('es-MX',{hour:'2-digit',minute:'2-digit'})+''); svg.push('\u2605 mejor precio \u2191 precio m\xE1s alto'); svg.push('<\/svg>'); var html='