| Server IP : 52.9.100.119 / Your IP : 216.73.216.135 Web Server : Apache/2.4.61 (Amazon) OpenSSL/1.0.2k-fips PHP/7.3.30 Phusion_Passenger/5.1.5 System : Linux ip-172-31-9-35 4.14.355-196.618.amzn1.x86_64 #1 SMP Mon Apr 7 17:09:50 UTC 2025 x86_64 User : root ( 0) PHP Version : 7.3.30 Disable Function : exec,passthru,shell_exec,system,popen,proc_open MySQL : OFF | cURL : ON | WGET : ON | Perl : ON | Python : ON | Sudo : ON | Pkexec : OFF Directory : /data/html/cayo.mx/ |
Upload File : |
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Mapa de vivienda — Mexicali B.C.</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f5f4f1; color: #222; }
/* LAYOUT */
.app { display: flex; height: 100vh; overflow: hidden; }
.sidebar { width: 320px; flex-shrink: 0; background: #fff; border-right: 0.5px solid #ddd; display: flex; flex-direction: column; overflow: hidden; }
.main { flex: 1; position: relative; }
/* SIDEBAR HEADER */
.s-header { padding: 16px 16px 12px; border-bottom: 0.5px solid #eee; }
.s-header h1 { font-size: 15px; font-weight: 500; margin-bottom: 2px; }
.s-header p { font-size: 11px; color: #888; }
/* TABS */
.tabs { display: flex; border-bottom: 0.5px solid #eee; }
.tab { flex: 1; padding: 9px 0; font-size: 12px; text-align: center; cursor: pointer; color: #888; border-bottom: 2px solid transparent; transition: all 0.15s; }
.tab.active { color: #185fa5; border-bottom-color: #185fa5; font-weight: 500; }
/* FILTER PANEL */
.panel { flex: 1; overflow-y: auto; padding: 12px 14px; }
.sec-title { font-size: 10px; font-weight: 500; color: #aaa; text-transform: uppercase; letter-spacing: 0.06em; margin: 14px 0 8px; }
.sec-title:first-child { margin-top: 0; }
/* MODE BUTTONS */
.mode-btns { display: flex; gap: 6px; }
.mode-btn { flex: 1; font-size: 11px; padding: 6px 0; cursor: pointer; border-radius: 6px; border: 0.5px solid #ccc; background: transparent; color: #666; transition: all 0.15s; text-align: center; }
.mode-btn.active { background: #e6f1fb; color: #185fa5; border-color: #b3d4f5; font-weight: 500; }
/* FILTER TOGGLES */
.filter-group { display: flex; flex-wrap: wrap; gap: 5px; margin-bottom: 4px; }
.filter-chip { font-size: 11px; padding: 4px 10px; border-radius: 20px; border: 0.5px solid #ddd; cursor: pointer; color: #555; background: #fafafa; transition: all 0.15s; display: flex; align-items: center; gap: 5px; }
.filter-chip .dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.filter-chip.active { font-weight: 500; }
.filter-chip.active.hosp { background: #fde8e8; border-color: #e05c5c; color: #b32020; }
.filter-chip.active.plaza { background: #e8f3fd; border-color: #378add; color: #185fa5; }
.filter-chip.active.ind { background: #fff3e0; border-color: #e0901a; color: #9a5a00; }
.filter-chip.active.univ { background: #e8f8ef; border-color: #27a85a; color: #145c2e; }
.filter-chip.active.dev { background: #f3eafd; border-color: #9b6fd4; color: #5c2ea0; }
/* LEGEND */
.legend-row { display: flex; align-items: center; gap: 8px; font-size: 11px; color: #555; margin-bottom: 5px; }
.legend-sym { width: 16px; height: 16px; border-radius: 50%; flex-shrink: 0; display: flex; align-items: center; justify-content: center; font-size: 9px; }
/* COMPARE PANEL */
.compare-panel { flex: 1; overflow-y: auto; padding: 12px 14px; display: none; }
.compare-slot { border: 1.5px dashed #ddd; border-radius: 8px; padding: 10px; margin-bottom: 10px; min-height: 60px; font-size: 12px; color: #aaa; cursor: pointer; transition: border-color 0.15s; }
.compare-slot.filled { border-style: solid; border-color: #9b6fd4; color: #222; cursor: default; }
.compare-slot .slot-name { font-weight: 500; color: #222; font-size: 13px; margin-bottom: 4px; }
.compare-slot .slot-sub { font-size: 11px; color: #888; }
.compare-table { width: 100%; border-collapse: collapse; font-size: 11px; margin-top: 10px; }
.compare-table th { background: #f5f4f1; padding: 6px 8px; text-align: left; font-weight: 500; color: #555; border-bottom: 0.5px solid #eee; }
.compare-table td { padding: 6px 8px; border-bottom: 0.5px solid #eee; color: #333; vertical-align: top; }
.compare-table tr:last-child td { border-bottom: none; }
.compare-table .hl { color: #185fa5; font-weight: 500; }
.compare-table .hl2 { color: #9b6fd4; font-weight: 500; }
.win { color: #27a85a !important; font-weight: 500; }
.lose { color: #e05c5c !important; }
.clear-btn { font-size: 11px; color: #e05c5c; cursor: pointer; text-decoration: underline; margin-top: 6px; display: inline-block; }
/* CANVAS */
canvas { display: block; width: 100%; height: 100%; }
/* TOOLTIP */
.tooltip { position: absolute; background: #fff; border: 0.5px solid #ccc; border-radius: 10px; padding: 10px 14px; font-size: 12px; pointer-events: none; opacity: 0; transition: opacity 0.15s; z-index: 20; min-width: 180px; max-width: 220px; box-shadow: 0 4px 16px rgba(0,0,0,0.1); }
.tooltip.show { opacity: 1; }
.tt-title { font-weight: 500; font-size: 13px; color: #111; margin-bottom: 5px; line-height: 1.3; }
.tt-row { color: #666; margin-bottom: 2px; font-size: 11px; }
.tt-row b { color: #222; }
.tt-badge { display: inline-block; font-size: 10px; padding: 2px 8px; border-radius: 4px; color: #fff; margin-top: 5px; }
.tt-hint { font-size: 10px; color: #aaa; margin-top: 5px; font-style: italic; }
/* STATUS BAR */
.statusbar { position: absolute; bottom: 12px; left: 12px; background: rgba(255,255,255,0.92); border: 0.5px solid #ddd; border-radius: 8px; padding: 6px 12px; font-size: 11px; color: #666; backdrop-filter: blur(4px); }
@media (max-width: 680px) {
.app { flex-direction: column; }
.sidebar { width: 100%; height: auto; border-right: none; border-bottom: 0.5px solid #ddd; }
.main { flex: 1; min-height: 380px; }
}
</style>
</head>
<body>
<div class="app">
<!-- SIDEBAR -->
<div class="sidebar">
<div class="s-header">
<h1>Vivienda Mexicali B.C. 2026</h1>
<p>Desarrollos · Nodos · Comparador</p>
</div>
<div class="tabs">
<div class="tab active" id="tab-filtros" onclick="switchTab('filtros')">Filtros</div>
<div class="tab" id="tab-comparar" onclick="switchTab('comparar')">Comparar <span id="compare-count" style="background:#9b6fd4;color:#fff;border-radius:10px;padding:1px 6px;font-size:10px;display:none;">0</span></div>
</div>
<!-- FILTER PANEL -->
<div class="panel" id="panel-filtros">
<p class="sec-title">Vista del mapa</p>
<div class="mode-btns">
<div class="mode-btn active" id="btn-bubble" onclick="setMode('bubble')">Burbujas precio</div>
<div class="mode-btn" id="btn-cluster" onclick="setMode('cluster')">Segmento</div>
</div>
<p class="sec-title">Desarrollos</p>
<div class="filter-group" id="dev-filters"></div>
<p class="sec-title">Leyenda</p>
<div style="margin-top:0;">
<div class="legend-row"><span style="width:16px;height:16px;border-radius:50%;background:#EAF3DE;border:1.5px solid #639922;flex-shrink:0;display:inline-block;"></span>Accesible <$1.5M</div>
<div class="legend-row"><span style="width:16px;height:16px;border-radius:50%;background:#E6F1FB;border:1.5px solid #378add;flex-shrink:0;display:inline-block;"></span>Medio $1.5M–$2.2M</div>
<div class="legend-row"><span style="width:16px;height:16px;border-radius:50%;background:#FAEEDA;border:2.5px solid #BA7517;flex-shrink:0;display:inline-block;"></span>Medio-alto $2.2M–$3M</div>
<div class="legend-row"><span style="width:16px;height:16px;border-radius:50%;background:#FAECE7;border:1.5px solid #D85A30;flex-shrink:0;display:inline-block;"></span>Premium $3M+</div>
</div>
<p class="sec-title" style="margin-top:14px;">Instrucciones</p>
<p style="font-size:11px;color:#aaa;line-height:1.6;">• Haz clic en un <b style="color:#9b6fd4;">desarrollo</b> para seleccionarlo y comparar<br>• Usa la pestaña <b style="color:#555;">Comparar</b> para ver métricas lado a lado<br>• Pasa el cursor sobre cualquier punto para ver detalles</p>
</div>
<!-- COMPARE PANEL -->
<div class="compare-panel" id="panel-comparar">
<p class="sec-title" style="margin-top:0;">Selecciona dos desarrollos en el mapa</p>
<div class="compare-slot" id="slot-a" onclick="clearSlot('a')">
<div style="text-align:center;padding:10px 0;">Haz clic en un desarrollo</div>
</div>
<div style="text-align:center;font-size:18px;color:#ccc;margin:4px 0;">⇄</div>
<div class="compare-slot" id="slot-b" onclick="clearSlot('b')">
<div style="text-align:center;padding:10px 0;">Haz clic en otro desarrollo</div>
</div>
<div id="compare-result"></div>
</div>
</div>
<!-- MAP -->
<div class="main">
<canvas id="mapCanvas"></canvas>
<div class="tooltip" id="tooltip"></div>
<div class="statusbar" id="statusbar">Mexicali B.C. · 1er Bimestre 2026</div>
</div>
</div>
<script>
// ─── DATA ───────────────────────────────────────────────
const segments = [
{ label:'Accesible', range:'<$1.5M', min:0, max:1500000, color:'#639922', bg:'#EAF3DE' },
{ label:'Medio', range:'$1.5M–$2.2M', min:1500000, max:2200000, color:'#378ADD', bg:'#E6F1FB' },
{ label:'Medio-alto', range:'$2.2M–$3M', min:2200000, max:3000000, color:'#BA7517', bg:'#FAEEDA' },
{ label:'Premium', range:'$3M+', min:3000000, max:Infinity, color:'#D85A30', bg:'#FAECE7' },
];
const devs = [
{ id:'aria', name:'ARIA', des:'Coto Sur', lat:32.5863, lng:-115.4250, avgP:2433250, models:4, minP:1898000, maxP:2905000, recs:'2–3', cpm:20750, terrenoMin:126, terrenoMax:126, constMin:64.66, constMax:120.42 },
{ id:'cadena1',name:'CADENA', des:'Huertas Colorado', lat:32.5635, lng:-115.4111, avgP:1182500, models:2, minP:1150000, maxP:1215000, recs:'2', cpm:18869, terrenoMin:120, terrenoMax:120, constMin:38, constMax:63 },
{ id:'cadena2',name:'CADENA', des:'Valle Oriente', lat:32.5718, lng:-115.3613, avgP:1200000, models:2, minP:1150000, maxP:1250000, recs:'2', cpm:19048, terrenoMin:120, terrenoMax:120, constMin:61, constMax:63 },
{ id:'eco', name:'ECOHOGARES', des:'Lussela', lat:32.5712, lng:-115.4706, avgP:2145000, models:2, minP:1800000, maxP:2490000, recs:'2', cpm:23956, terrenoMin:160, terrenoMax:160, constMin:65.54, constMax:94.5 },
{ id:'exe', name:'EXE', des:'El Marqués', lat:32.5844, lng:-115.3437, avgP:1910422, models:5, minP:1643045, maxP:2247393, recs:'2–3', cpm:23416, terrenoMin:138, terrenoMax:138, constMin:58.32, constMax:91.06 },
{ id:'foresta',name:'FORESTA', des:'Gran Foresta', lat:32.5633, lng:-115.4345, avgP:4243250, models:4, minP:3700000, maxP:4953000, recs:'2–3', cpm:27882, terrenoMin:231, terrenoMax:266, constMin:118, constMax:173 },
{ id:'hogme', name:'GRUPO HOGME', des:'Laguna Residencial', lat:32.5941, lng:-115.4583, avgP:2170000, models:3, minP:1830000, maxP:2530000, recs:'2–3', cpm:24832, terrenoMin:120, terrenoMax:120, constMin:60.84, constMax:94.65 },
{ id:'idu', name:'IDU', des:'San Andrés', lat:32.5777, lng:-115.4248, avgP:2450000, models:4, minP:1900000, maxP:2850000, recs:'2–3', cpm:26017, terrenoMin:130.41,terrenoMax:189.94,constMin:67.21, constMax:96.96 },
{ id:'ruba', name:'RUBA', des:'Real Campestre', lat:32.5804, lng:-115.4260, avgP:2802000, models:1, minP:2802000, maxP:2802000, recs:'3', cpm:25706, terrenoMin:133, terrenoMax:133, constMin:109, constMax:109 },
];
// ─── STATE ───────────────────────────────────────────────
let mode = 'bubble';
let visibleDevs = new Set(devs.map(d=>d.id));
let compareA = null, compareB = null;
let activeTab = 'filtros';
// ─── MAP SETUP ───────────────────────────────────────────
const minLat=32.550, maxLat=32.615, minLng=-115.500, maxLng=-115.330;
const PAD=36;
let W, H;
const canvas = document.getElementById('mapCanvas');
const ctx = canvas.getContext('2d');
const tooltip = document.getElementById('tooltip');
function getSeg(p) { return segments.find(s => p >= s.min && p < s.max); }
function project(lat, lng) {
const x = PAD + (lng - minLng)/(maxLng - minLng)*(W - PAD*2);
const y = PAD + (1-(lat - minLat)/(maxLat - minLat))*(H - PAD*2);
return {x, y};
}
const maxAvg = Math.max(...devs.map(d=>d.avgP));
const minAvg = Math.min(...devs.map(d=>d.avgP));
function bubbleR(p){ return 18 + (p-minAvg)/(maxAvg-minAvg)*28; }
function resize(){
const rect = canvas.parentElement.getBoundingClientRect();
W = rect.width * devicePixelRatio; H = rect.height * devicePixelRatio;
canvas.width = W; canvas.height = H;
ctx.scale(devicePixelRatio, devicePixelRatio);
W = rect.width; H = rect.height;
draw();
}
function draw(){
ctx.clearRect(0,0,W,H);
ctx.fillStyle='#f0efe9'; ctx.fillRect(0,0,W,H);
// Grid
ctx.strokeStyle='rgba(0,0,0,0.04)'; ctx.lineWidth=0.5;
for(let i=0;i<=6;i++){
const x=PAD+i*(W-PAD*2)/6; ctx.beginPath();ctx.moveTo(x,PAD);ctx.lineTo(x,H-PAD);ctx.stroke();
const y=PAD+i*(H-PAD*2)/6; ctx.beginPath();ctx.moveTo(PAD,y);ctx.lineTo(W-PAD,y);ctx.stroke();
}
// Cardinal labels
ctx.fillStyle='rgba(0,0,0,0.18)'; ctx.font='10px sans-serif';
ctx.textAlign='center'; ctx.fillText('NORTE',W/2,14);
ctx.textAlign='left'; ctx.fillText('← O',PAD,H-8);
ctx.textAlign='right'; ctx.fillText('E →',W-PAD,H-8);
// Devs
devs.forEach(d => {
if(!visibleDevs.has(d.id)) return;
const {x,y} = project(d.lat, d.lng);
const r = mode==='bubble' ? bubbleR(d.avgP) : 22;
const seg = getSeg(d.avgP);
const isIDU = d.id==='idu';
const isSelA = compareA && compareA.id===d.id;
const isSelB = compareB && compareB.id===d.id;
// Shadow
ctx.beginPath(); ctx.arc(x,y+2,r,0,Math.PI*2);
ctx.fillStyle='rgba(0,0,0,0.06)'; ctx.fill();
// Fill
ctx.beginPath(); ctx.arc(x,y,r,0,Math.PI*2);
ctx.fillStyle = seg.bg+'f0'; ctx.fill();
// Stroke
ctx.strokeStyle = isSelA ? '#9b6fd4' : isSelB ? '#6fd4b3' : isIDU ? '#BA7517' : seg.color;
ctx.lineWidth = (isSelA||isSelB) ? 3 : isIDU ? 2.5 : 1.5;
ctx.stroke();
// Compare indicator
if(isSelA){ ctx.fillStyle='#9b6fd4'; ctx.font='bold 9px sans-serif'; ctx.textAlign='center'; ctx.textBaseline='middle'; ctx.fillText('A',x,y-r+8); }
if(isSelB){ ctx.fillStyle='#6fd4b3'; ctx.font='bold 9px sans-serif'; ctx.textAlign='center'; ctx.textBaseline='middle'; ctx.fillText('B',x,y-r+8); }
// Label
ctx.fillStyle = '#1a1a1a';
ctx.font = isIDU ? '500 10px sans-serif' : '10px sans-serif';
ctx.textAlign='center'; ctx.textBaseline='middle';
const label = d.name.length>7 ? d.name.substring(0,6)+'.' : d.name;
ctx.fillText(label, x, y-(mode==='bubble'?3:2));
if(mode==='bubble'){
ctx.font='8px sans-serif'; ctx.fillStyle='rgba(0,0,0,0.4)';
ctx.fillText('$'+(d.avgP/1000000).toFixed(1)+'M', x, y+9);
}
});
}
// ─── INTERACTIONS ─────────────────────────────────────────
function getDevAt(mx, my){
let found = null;
devs.forEach(d => {
if(!visibleDevs.has(d.id)) return;
const {x,y} = project(d.lat, d.lng);
const r = mode==='bubble' ? bubbleR(d.avgP) : 22;
if(Math.sqrt((mx-x)**2+(my-y)**2)<r) found=d;
});
return found;
}
canvas.addEventListener('mousemove', e => {
const rect = canvas.getBoundingClientRect();
const mx = e.clientX-rect.left, my = e.clientY-rect.top;
const dev = getDevAt(mx, my);
if(dev){
const seg = getSeg(dev.avgP);
const fmt = p => '$'+p.toLocaleString('es-MX');
const isA = compareA && compareA.id===dev.id;
const isB = compareB && compareB.id===dev.id;
tooltip.innerHTML = `
<div class="tt-title">${dev.name} — ${dev.des}</div>
<div class="tt-row">Precio promedio: <b>${fmt(dev.avgP)}</b></div>
<div class="tt-row">Rango: ${fmt(dev.minP)} – ${fmt(dev.maxP)}</div>
<div class="tt-row">Modelos: <b>${dev.models}</b> · Rec: ${dev.recs}</div>
<div class="tt-row">Costo/m² const.: <b>$${dev.cpm.toLocaleString('es-MX')}</b></div>
<div><span class="tt-badge" style="background:${seg.color};">${seg.label} ${seg.range}</span></div>
<div class="tt-hint">${isA?'✓ Seleccionado A':isB?'✓ Seleccionado B':'Clic para comparar'}</div>
`;
canvas.style.cursor='pointer';
tooltip.className='tooltip show';
} else {
canvas.style.cursor='default';
tooltip.className='tooltip';
}
// Position tooltip
if(dev){
const tw=220, th=140;
tooltip.style.left = (mx+14+tw>rect.width ? mx-tw-8 : mx+14)+'px';
tooltip.style.top = (my-10+th>rect.height ? my-th-4 : my-10)+'px';
}
});
canvas.addEventListener('mouseleave', ()=>{ tooltip.className='tooltip'; });
canvas.addEventListener('click', e => {
const rect = canvas.getBoundingClientRect();
const mx = e.clientX-rect.left, my = e.clientY-rect.top;
const dev = getDevAt(mx, my);
if(!dev) return;
if(compareA && compareA.id===dev.id){ compareA=null; }
else if(compareB && compareB.id===dev.id){ compareB=null; }
else if(!compareA){ compareA=dev; }
else if(!compareB){ compareB=dev; }
else { compareA=compareB; compareB=dev; }
updateComparePanel();
draw();
});
// ─── COMPARE PANEL ────────────────────────────────────────
function updateComparePanel(){
const cnt = (compareA?1:0)+(compareB?1:0);
const cc = document.getElementById('compare-count');
cc.textContent = cnt; cc.style.display = cnt>0?'inline':'none';
const slotA = document.getElementById('slot-a');
const slotB = document.getElementById('slot-b');
if(compareA){
slotA.className='compare-slot filled';
slotA.innerHTML=`<div class="slot-name" style="color:#9b6fd4;">A · ${compareA.name}</div><div class="slot-sub">${compareA.des}</div><div class="slot-sub">$${compareA.avgP.toLocaleString('es-MX')} promedio</div><span class="clear-btn">✕ Quitar</span>`;
} else {
slotA.className='compare-slot';
slotA.innerHTML='<div style="text-align:center;padding:10px 0;color:#bbb;">Haz clic en un desarrollo (A)</div>';
}
if(compareB){
slotB.className='compare-slot filled';
slotB.style.borderColor='#6fd4b3';
slotB.innerHTML=`<div class="slot-name" style="color:#2a9a7a;">B · ${compareB.name}</div><div class="slot-sub">${compareB.des}</div><div class="slot-sub">$${compareB.avgP.toLocaleString('es-MX')} promedio</div><span class="clear-btn">✕ Quitar</span>`;
} else {
slotB.className='compare-slot';
slotB.style.borderColor='#ddd';
slotB.innerHTML='<div style="text-align:center;padding:10px 0;color:#bbb;">Haz clic en otro desarrollo (B)</div>';
}
const res = document.getElementById('compare-result');
if(compareA && compareB){
const a=compareA, b=compareB;
const fmt=p=>'$'+p.toLocaleString('es-MX');
const wA=(va,vb,lower=false)=>{
if(va===vb) return ['',''];
const aWins = lower ? va<vb : va>vb;
return aWins ? ['win','lose'] : ['lose','win'];
};
const [p1,p2]=wA(a.avgP,b.avgP,true);
const [pmin1,pmin2]=wA(a.minP,b.minP,true);
const [pmax1,pmax2]=wA(a.maxP,b.maxP,true);
const [c1,c2]=wA(a.cpm,b.cpm,true);
const [m1,m2]=wA(a.models,b.models);
const [tmax1,tmax2]=wA(a.terrenoMax,b.terrenoMax);
const [tmin1,tmin2]=wA(a.terrenoMin,b.terrenoMin);
const [cmax1,cmax2]=wA(a.constMax,b.constMax);
const [cmin1,cmin2]=wA(a.constMin,b.constMin);
const rng=(min,max,unit='m²')=> min===max ? `${min} ${unit}` : `${min}–${max} ${unit}`;
res.innerHTML=`
<p class="sec-title" style="margin-top:14px;">Comparativa</p>
<table class="compare-table">
<thead><tr><th>Métrica</th><th class="hl">A · ${a.name}</th><th class="hl2">B · ${b.name}</th></tr></thead>
<tbody>
<tr><td style="color:#aaa;font-size:10px;padding-top:8px;" colspan="3">PRECIO</td></tr>
<tr><td>Precio mínimo</td><td class="${pmin1}">${fmt(a.minP)}</td><td class="${pmin2}">${fmt(b.minP)}</td></tr>
<tr><td>Precio máximo</td><td class="${pmax1}">${fmt(a.maxP)}</td><td class="${pmax2}">${fmt(b.maxP)}</td></tr>
<tr><td>Precio promedio</td><td class="${p1}">${fmt(a.avgP)}</td><td class="${p2}">${fmt(b.avgP)}</td></tr>
<tr><td style="color:#aaa;font-size:10px;padding-top:8px;" colspan="3">SUPERFICIE</td></tr>
<tr><td>Terreno mín.</td><td class="${tmin1}">${a.terrenoMin} m²</td><td class="${tmin2}">${b.terrenoMin} m²</td></tr>
<tr><td>Terreno máx.</td><td class="${tmax1}">${a.terrenoMax} m²</td><td class="${tmax2}">${b.terrenoMax} m²</td></tr>
<tr><td>Construcción mín.</td><td class="${cmin1}">${a.constMin} m²</td><td class="${cmin2}">${b.constMin} m²</td></tr>
<tr><td>Construcción máx.</td><td class="${cmax1}">${a.constMax} m²</td><td class="${cmax2}">${b.constMax} m²</td></tr>
<tr><td style="color:#aaa;font-size:10px;padding-top:8px;" colspan="3">PRODUCTO</td></tr>
<tr><td>$/m² construido</td><td class="${c1}">${fmt(a.cpm)}</td><td class="${c2}">${fmt(b.cpm)}</td></tr>
<tr><td>Modelos</td><td class="${m1}">${a.models}</td><td class="${m2}">${b.models}</td></tr>
<tr><td>Recámaras</td><td>${a.recs}</td><td>${b.recs}</td></tr>
<tr><td>Segmento</td><td>${getSeg(a.avgP).label}</td><td>${getSeg(b.avgP).label}</td></tr>
</tbody>
</table>
<p style="font-size:10px;color:#aaa;margin-top:6px;line-height:1.5;"><span style="color:#27a85a;font-weight:500;">Verde</span> = ventaja · <span style="color:#e05c5c;font-weight:500;">Rojo</span> = desventaja</p>
`;
} else {
res.innerHTML='';
}
}
function clearSlot(s){
if(s==='a') compareA=null;
else compareB=null;
updateComparePanel(); draw();
}
// ─── FILTERS ──────────────────────────────────────────────
// Build dev filter chips
const devFilters = document.getElementById('dev-filters');
const devGroups = {};
devs.forEach(d=>{ if(!devGroups[d.name]) devGroups[d.name]=[]; devGroups[d.name].push(d); });
Object.entries(devGroups).forEach(([name, list])=>{
const chip = document.createElement('div');
chip.className='filter-chip active dev';
chip.innerHTML=`<span class="dot" style="background:#9b6fd4;"></span>${name}`;
chip.onclick=()=>{
const allVisible = list.every(d=>visibleDevs.has(d.id));
list.forEach(d=>{ allVisible ? visibleDevs.delete(d.id) : visibleDevs.add(d.id); });
chip.className='filter-chip dev'+(list.every(d=>visibleDevs.has(d.id))?' active':'');
draw();
};
devFilters.appendChild(chip);
});
function setMode(m){
mode=m;
document.getElementById('btn-bubble').className='mode-btn'+(m==='bubble'?' active':'');
document.getElementById('btn-cluster').className='mode-btn'+(m==='cluster'?' active':'');
draw();
}
function switchTab(t){
activeTab=t;
document.getElementById('tab-filtros').className='tab'+(t==='filtros'?' active':'');
document.getElementById('tab-comparar').className='tab'+(t==='comparar'?' active':'');
document.getElementById('panel-filtros').style.display=t==='filtros'?'block':'none';
document.getElementById('panel-comparar').style.display=t==='comparar'?'flex':'none';
document.getElementById('panel-comparar').style.flexDirection='column';
}
// ─── INIT ─────────────────────────────────────────────────
window.addEventListener('resize', ()=>{ canvas.getContext('2d').setTransform(1,0,0,1,0,0); resize(); });
resize();
updateComparePanel();
</script>
</body>
</html>