Files
AutoBooking/command-center.js

617 lines
28 KiB
JavaScript

/* ── Command Center JS ───────────────────────────────────────── */
const CC_MAP_THEMES = {
dark: [
{ elementType: 'geometry', stylers: [{ color: '#1a1a2e' }] },
{ elementType: 'labels.text.stroke', stylers: [{ color: '#1a1a2e' }] },
{ elementType: 'labels.text.fill', stylers: [{ color: '#9a9abf' }] },
{ featureType: 'road', elementType: 'geometry', stylers: [{ color: '#2d2d44' }] },
{ featureType: 'road.highway', elementType: 'geometry', stylers: [{ color: '#3d3020' }] },
{ featureType: 'water', elementType: 'geometry', stylers: [{ color: '#0d1321' }] },
{ featureType: 'poi', stylers: [{ visibility: 'off' }] },
],
improved: [
{ elementType: 'geometry', stylers: [{ color: '#212121' }] },
{ elementType: 'labels.text.stroke', stylers: [{ color: '#212121' }] },
{ elementType: 'labels.text.fill', stylers: [{ color: '#ffffff' }] },
{ featureType: 'road', elementType: 'geometry', stylers: [{ color: '#3a3a3a' }] },
{ featureType: 'road', elementType: 'labels.text.fill', stylers: [{ color: '#ffffff' }] },
{ featureType: 'road.highway', elementType: 'geometry', stylers: [{ color: '#4a3d20' }] },
{ featureType: 'water', elementType: 'geometry', stylers: [{ color: '#0d2137' }] },
{ featureType: 'landscape', elementType: 'geometry', stylers: [{ color: '#2a2a2a' }] },
{ featureType: 'poi', elementType: 'geometry', stylers: [{ color: '#2a2a2a' }] },
{ featureType: 'poi', elementType: 'labels.text.fill', stylers: [{ color: '#aaaaaa' }] },
{ featureType: 'transit', elementType: 'geometry', stylers: [{ color: '#2f3948' }] },
],
normal: [],
};
const CC = {
map: null,
markers: {},
selectedTrip: null,
lastSosCount: 0,
audioCtx: null,
_curTrip: null,
_hoverWin: null,
_geocoder: null,
_geoCache: {},
/* ── Map init (called by Google Maps callback) ───────────── */
init() {
const savedTheme = localStorage.getItem('cc_map_theme') || 'dark';
const mapEl = document.getElementById('cc-map');
this.map = new google.maps.Map(mapEl, {
zoom: 10,
center: { lat: 25.8100, lng: -80.2780 },
mapTypeId: 'roadmap',
styles: CC_MAP_THEMES[savedTheme] || CC_MAP_THEMES.dark,
streetViewControl: false,
mapTypeControl: false,
gestureHandling: 'greedy',
});
google.maps.event.trigger(this.map, 'resize');
// Marcar botón activo según preferencia guardada
document.querySelectorAll('.cc-theme-btn').forEach(b => {
b.classList.toggle('active', b.dataset.theme === savedTheme);
});
document.querySelectorAll('.cc-theme-btn').forEach(b => {
b.addEventListener('click', () => this.setMapTheme(b.dataset.theme));
});
this.startClock();
this.poll();
setInterval(() => this.pollMap(), 10000);
setInterval(() => this.pollAlerts(), 5000);
setInterval(() => this.pollStats(), 30000);
},
setMapTheme(theme) {
this.map.setOptions({ styles: CC_MAP_THEMES[theme] || CC_MAP_THEMES.dark });
localStorage.setItem('cc_map_theme', theme);
document.querySelectorAll('.cc-theme-btn').forEach(b => {
b.classList.toggle('active', b.dataset.theme === theme);
});
},
/* ── Clock ───────────────────────────────────────────────── */
startClock() {
const tick = () => {
document.getElementById('cc-clock').textContent =
new Date().toLocaleTimeString('es-CO');
};
tick();
setInterval(tick, 1000);
},
/* ── Polling ─────────────────────────────────────────────── */
poll() {
this.pollStats();
this.pollMap();
this.pollAlerts();
},
pollStats() {
this._get('command/stats').then(d => {
if (!d) return;
document.getElementById('kpi-active').textContent = d.active_trips;
document.getElementById('kpi-free').textContent = d.available_drivers;
document.getElementById('kpi-sos').textContent = d.open_sos;
document.getElementById('kpi-today').textContent = d.trips_today;
const box = document.getElementById('kpi-sos-box');
d.open_sos > 0 ? box.classList.add('sos-active') : box.classList.remove('sos-active');
});
},
pollMap() {
this._get('command/live-map').then(d => {
if (!d) return;
this.updateMap(d);
this.updateTripsList(d.trips || []);
document.getElementById('kpi-active').textContent = (d.trips || []).length;
document.getElementById('kpi-free').textContent = (d.drivers || []).length;
});
},
pollAlerts() {
this._get('command/alerts').then(d => {
if (!d) return;
const sos = d.sos || [];
if (sos.length > this.lastSosCount && this.lastSosCount >= 0) {
this.playAlert();
const sec = document.getElementById('cc-sos-section');
sec.classList.add('sos-flash');
setTimeout(() => sec.classList.remove('sos-flash'), 3000);
}
this.lastSosCount = sos.length;
document.getElementById('sos-count-badge').textContent = sos.length;
this.updateSosList(sos);
});
},
/* ── Map markers ─────────────────────────────────────────── */
carIcon(color) {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 40" width="24" height="40">
<path d="M4,12 Q4,4 12,2 Q20,4 20,12 L20,30 Q20,38 12,38 Q4,38 4,30 Z" fill="${color}"/>
<path d="M7,10 Q12,6 17,10 L16,17 Q12,15 8,17 Z" fill="rgba(255,255,255,0.45)"/>
<path d="M7,23 Q12,25 17,23 L16,30 Q12,34 8,30 Z" fill="rgba(255,255,255,0.3)"/>
<rect x="1" y="11" width="4" height="7" rx="2" fill="#111"/>
<rect x="19" y="11" width="4" height="7" rx="2" fill="#111"/>
<rect x="1" y="24" width="4" height="7" rx="2" fill="#111"/>
<rect x="19" y="24" width="4" height="7" rx="2" fill="#111"/>
<circle cx="12" cy="20" r="2" fill="rgba(255,255,255,0.25)"/>
</svg>`;
return {
url: 'data:image/svg+xml;charset=UTF-8,' + encodeURIComponent(svg),
scaledSize: new google.maps.Size(24, 40),
anchor: new google.maps.Point(12, 38),
};
},
updateMap(data) {
const active = new Set();
(data.trips || []).forEach(t => {
if (!t.driver_lat || !t.driver_lng) return;
const key = 'trip_' + t.id;
active.add(key);
const pos = { lat: parseFloat(t.driver_lat), lng: parseFloat(t.driver_lng) };
if (this.markers[key]) {
const prev = this.markers[key].getPosition();
this.markers[key]._prevPos = { lat: prev.lat(), lng: prev.lng() };
this.markers[key].setPosition(pos);
this.markers[key]._tripData = t;
} else {
const m = new google.maps.Marker({
position: pos,
map: this.map,
title: (t.driver_name || 'Conductor') + ' → ' + (t.passenger_name || 'Pasajero'),
icon: this.carIcon('#FF6F00'),
});
m._tripData = t;
m._prevPos = null;
m.addListener('click', () => this.selectTrip(t.id));
m.addListener('mouseover', () => this._showTripHover(m));
m.addListener('mouseout', () => { if (this._hoverWin) this._hoverWin.close(); });
this.markers[key] = m;
}
});
(data.drivers || []).forEach(d => {
if (!d.lat || !d.lng) return;
const key = 'drv_' + d.user_id;
active.add(key);
const pos = { lat: parseFloat(d.lat), lng: parseFloat(d.lng) };
if (this.markers[key]) {
this.markers[key].setPosition(pos);
} else {
const m = new google.maps.Marker({
position: pos,
map: this.map,
title: (d.display_name || 'Conductor') + ' (Disponible)',
icon: this.carIcon('#00cc66'),
});
m.addListener('click', () => {
if (!this._infoWin) this._infoWin = new google.maps.InfoWindow();
this._infoWin.setContent(
`<div style="font-family:sans-serif;padding:4px 8px;min-width:160px">
<strong style="color:#FF6F00">${d.display_name || 'Conductor'}</strong><br>
<span style="color:#888;font-size:12px">Disponible</span><br>
<span style="font-size:11px">Lat: ${parseFloat(d.lat).toFixed(5)}, Lng: ${parseFloat(d.lng).toFixed(5)}</span>
</div>`
);
this._infoWin.open(this.map, m);
});
this.markers[key] = m;
}
});
Object.keys(this.markers).forEach(k => {
if (!active.has(k)) {
this.markers[k].setMap(null);
delete this.markers[k];
}
});
},
/* ── Sidebar lists ───────────────────────────────────────── */
updateSosList(sos) {
const el = document.getElementById('sos-list');
if (!sos.length) {
el.innerHTML = '<div class="cc-empty">Sin alertas activas</div>';
return;
}
el.innerHTML = sos.map(s => `
<div class="sos-card">
<div class="sos-card-top">
<span class="sos-card-name">🚨 ${this._esc(s.user_name || 'Usuario desconocido')}</span>
<span class="sos-card-time">${this._ago(s.created_at)}</span>
</div>
<div class="sos-card-type">${this._esc(s.type || 'SOS')} — Viaje #${s.trip_id || '—'}</div>
${s.description ? `<div class="sos-card-type">${this._esc(s.description)}</div>` : ''}
<div class="sos-card-actions">
${s.trip_id ? `<button class="ccbs ccbs-select" onclick="CC.selectTrip(${s.trip_id})">Ver viaje</button>` : ''}
<button class="ccbs ccbs-resolve" onclick="CC.resolveAlert(${s.id})">Resolver</button>
</div>
</div>
`).join('');
},
updateTripsList(trips) {
const el = document.getElementById('trips-list');
if (!trips.length) {
el.innerHTML = '<div class="cc-empty">Sin viajes activos</div>';
return;
}
el.innerHTML = trips.map(t => `
<div class="trip-card ${this.selectedTrip === t.id ? 'selected' : ''}" onclick="CC.selectTrip(${t.id})">
<div class="trip-card-driver">${this._esc(t.driver_name || 'Conductor #' + t.driver_id)}</div>
<div class="trip-card-passenger">👤 ${this._esc(t.passenger_name || 'Pasajero #' + t.passenger_id)}</div>
<div class="trip-card-route">📍 ${this._esc((t.pickup_address || '').substring(0, 42))}</div>
<div class="trip-card-status">${t.status}${!t.driver_lat ? ' · <span style="color:#ff4444;font-size:9px">Sin GPS</span>' : ''}</div>
</div>
`).join('');
},
/* ── Trip detail ─────────────────────────────────────────── */
selectTrip(id) {
this.selectedTrip = id;
this._get('command/trip/' + id).then(data => {
if (!data || !data.trip) return;
this._showDetail(data.trip);
});
},
_showDetail(t) {
this._curTrip = t;
document.getElementById('cc-panel-title').textContent = 'Viaje #' + t.id + (t.status ? ' · ' + t.status : '');
document.getElementById('cc-panel-info').innerHTML = `
<div class="cc-info-item"><label>Conductor</label><span>${this._esc(t.driver_name || '#' + t.driver_id)}</span></div>
<div class="cc-info-item"><label>Pasajero</label><span>${this._esc(t.passenger_name || '#' + t.passenger_id)}</span></div>
<div class="cc-info-item"><label>Tel. Conductor</label><span>${this._esc(t.driver_phone || 'N/A')}</span></div>
<div class="cc-info-item"><label>Tel. Pasajero</label><span>${this._esc(t.passenger_phone || 'N/A')}</span></div>
<div class="cc-info-item"><label>Destino</label><span id="cc-panel-dest">${this._esc(t.dropoff_address || 'Consultando...')}</span></div>
<div class="cc-info-item"><label>Estado</label><span>${this._esc(t.status || 'N/A')}</span></div>
<div class="cc-info-item"><label>Inicio</label><span>${t.started_at ? this._time(t.started_at) : 'N/A'}</span></div>
`;
document.getElementById('cc-panel-pattern').innerHTML = this._patternHtml(t);
document.getElementById('cc-panel-btns').innerHTML = `
<button class="ccb ccb-green" onclick="CC.callDriver()">📞 Conductor</button>
<button class="ccb ccb-green" onclick="CC.callPassenger()">📞 Pasajero</button>
<button class="ccb ccb-red" onclick="CC.callPolice()">🚔 Policía</button>
<button class="ccb ccb-orange" onclick="CC.openProtocol()">⚠️ Protocolo</button>
<button class="ccb ccb-blue" onclick="CC.openMessage()">💬 Mensaje</button>
<button class="ccb ccb-dark" onclick="CC.blockDriver()">🚫 Bloquear</button>
`;
document.getElementById('cc-trip-panel').style.display = '';
if (!t.dropoff_address && t.dropoff_lat && t.dropoff_lng)
this._geocodeAndUpdate(t.dropoff_lat, t.dropoff_lng, 'cc-panel-dest');
},
closeDetail() {
document.getElementById('cc-trip-panel').style.display = 'none';
this.selectedTrip = null;
this._curTrip = null;
},
/* ── Actions ─────────────────────────────────────────────── */
callDriver() {
const t = this._curTrip;
if (!t) return;
if (t.driver_phone) window.location.href = 'tel:' + t.driver_phone;
else this._toast('Teléfono del conductor no disponible', 'warning');
},
callPassenger() {
const t = this._curTrip;
if (!t) return;
if (t.passenger_phone) window.location.href = 'tel:' + t.passenger_phone;
else this._toast('Teléfono del pasajero no disponible', 'warning');
},
callPolice() {
if (!confirm('¿Confirmar llamada de emergencia a la Policía Nacional (123)?')) return;
window.location.href = 'tel:123';
if (this._curTrip) {
this._post('command/incident', {
trip_id: this._curTrip.id,
type: 'police_called',
description: 'Operador llamó a la Policía desde consola de comando',
});
}
},
openProtocol() {
if (!this._curTrip) return;
document.querySelectorAll('.proto-opt').forEach(b => b.classList.remove('selected'));
document.getElementById('proto-notes').value = '';
document.getElementById('cc-modal-protocol').classList.add('open');
},
selectProtocol(btn) {
document.querySelectorAll('.proto-opt').forEach(b => b.classList.remove('selected'));
btn.classList.add('selected');
},
confirmProtocol() {
const sel = document.querySelector('.proto-opt.selected');
if (!sel) { this._toast('Selecciona un protocolo', 'warning'); return; }
const protocol = sel.dataset.p;
const notes = document.getElementById('proto-notes').value.trim();
this._post('command/incident', {
trip_id: this._curTrip.id,
type: 'protocol_activated',
description: notes,
protocol: protocol,
}).then(() => {
this.closeModal('protocol');
this._toast('Protocolo "' + protocol + '" activado', 'success');
this.poll();
});
},
openMessage() {
if (!this._curTrip) return;
document.getElementById('msg-body').value = '';
document.getElementById('cc-modal-message').classList.add('open');
},
sendMessage() {
const t = this._curTrip;
const to = document.getElementById('msg-to').value;
const msg = document.getElementById('msg-body').value.trim();
if (!msg) { this._toast('Escribe un mensaje', 'warning'); return; }
const toId = to === 'driver' ? t.driver_id : t.passenger_id;
this._post('command/message', {
trip_id: t.id,
to_user_id: toId,
message: msg,
}).then(() => {
this.closeModal('message');
this._toast('Mensaje enviado', 'success');
});
},
blockDriver() {
const t = this._curTrip;
if (!t) return;
const name = t.driver_name || '#' + t.driver_id;
const reason = prompt('¿Razón para bloquear al conductor ' + name + '?');
if (reason === null) return;
this._post('command/block-driver', { driver_id: t.driver_id, reason }).then(() => {
this._toast('Conductor bloqueado', 'warning');
this.closeDetail();
this.poll();
});
},
resolveAlert(id) {
const notes = prompt('Notas de resolución (opcional):') ?? '';
this._post('command/resolve/' + id, { notes }).then(() => {
this._toast('Alerta resuelta', 'success');
this.pollAlerts();
this.pollStats();
});
},
closeModal(name) {
document.getElementById('cc-modal-' + name).classList.remove('open');
},
/* ── Hover tooltip ───────────────────────────────────────────── */
_showTripHover(m) {
const td = m._tripData;
if (!this._hoverWin) this._hoverWin = new google.maps.InfoWindow({ disableAutoPan: true });
if (!this._geocoder) this._geocoder = new google.maps.Geocoder();
const driverName = this._esc(td.driver_name || 'Conductor');
const paxName = this._esc(td.passenger_name || 'Pasajero');
const curLat = parseFloat(td.driver_lat);
const curLng = parseFloat(td.driver_lng);
const curKey = curLat.toFixed(4) + ',' + curLng.toFixed(4);
const destKey = (td.dropoff_lat || '') + ',' + (td.dropoff_lng || '');
const dirStr = m._prevPos
? this._bearingDir(this._bearing(m._prevPos.lat, m._prevPos.lng, curLat, curLng))
: null;
const distMi = (td.dropoff_lat && td.dropoff_lng)
? (this._calcDist(curLat, curLng, parseFloat(td.dropoff_lat), parseFloat(td.dropoff_lng)) * 0.621371).toFixed(1)
: null;
const refresh = () => {
if (!this._hoverWin.getMap()) return;
const curAddr = this._geoCache[curKey] || (curLat.toFixed(4) + ', ' + curLng.toFixed(4));
const destAddr = this._geoCache[destKey] || 'Cargando...';
this._hoverWin.setContent(this._hoverHtml(driverName, paxName, curAddr, destAddr, distMi, dirStr));
};
if (!this._geoCache[curKey]) {
this._geocoder.geocode({ location: { lat: curLat, lng: curLng } }, (res, st) => {
this._geoCache[curKey] = (st === 'OK' && res[0]) ? res[0].formatted_address : curKey;
refresh();
});
}
if (!this._geoCache[destKey] && td.dropoff_lat && td.dropoff_lng) {
this._geocoder.geocode({ location: { lat: parseFloat(td.dropoff_lat), lng: parseFloat(td.dropoff_lng) } }, (res, st) => {
this._geoCache[destKey] = (st === 'OK' && res[0]) ? res[0].formatted_address : destKey;
refresh();
});
}
refresh();
this._hoverWin.open(this.map, m);
},
_hoverHtml(driver, pax, curPos, dest, distMi, dir) {
const arrow = dir ? this._dirArrow(dir) : '';
const row = (label, val) =>
'<div style="display:flex;justify-content:space-between;gap:10px;margin-bottom:3px">'
+ '<span style="color:#666;font-size:10px;white-space:nowrap">' + label + '</span>'
+ '<span style="color:#d0d0d0;font-size:11px;text-align:right">' + val + '</span></div>';
return '<div style="padding:10px 14px;min-width:210px;max-width:290px;font-family:\'Segoe UI\',sans-serif;">'
+ '<div style="color:#FF6F00;font-size:12px;font-weight:700;margin-bottom:7px;border-bottom:1px solid rgba(255,111,0,0.25);padding-bottom:5px">🚗 ' + driver + '</div>'
+ row('Pasajero', '👤 ' + pax)
+ row('Posición', curPos)
+ row('Destino', this._esc(dest))
+ (distMi ? row('Distancia', distMi + ' mi') : '')
+ (dir ? row('Dirección', arrow + ' ' + this._esc(dir)) : '')
+ '</div>';
},
_dirArrow(dir) {
const map = { 'N':'↑','NE':'↗','E':'→','SE':'↘','S':'↓','SO':'↙','O':'←','NO':'↖' };
return (map[dir.split(' ')[0]] || '↗');
},
/* ── Pattern analysis ────────────────────────────────────────── */
_patternHtml(t) {
if (!t.dropoff_lat || !t.dropoff_lng || !t.current_lat || !t.current_lng) {
return '<div class="cc-pattern"><div class="cc-pattern-hdr">Análisis de Ruta</div>'
+ '<div style="color:#555;font-size:11px">Sin datos de posición actual</div></div>';
}
const dist = this._calcDist(
parseFloat(t.current_lat), parseFloat(t.current_lng),
parseFloat(t.dropoff_lat), parseFloat(t.dropoff_lng)
);
let cls, label;
if (dist < 0.5) { cls = 'cc-p-ok'; label = '✔ Llegando al destino'; }
else if (dist < 8) { cls = 'cc-p-ok'; label = '✔ En ruta normal'; }
else if (dist < 20) { cls = 'cc-p-warn'; label = '⚠ Distancia elevada — verificar'; }
else { cls = 'cc-p-alert'; label = '✘ Posible desvío de ruta'; }
let timeRow = '';
if (t.started_at) {
const mins = Math.floor((Date.now() - new Date(t.started_at).getTime()) / 60000);
timeRow = '<div class="cc-pattern-row"><span>Tiempo en viaje</span><strong>' + mins + ' min</strong></div>';
}
return '<div class="cc-pattern">'
+ '<div class="cc-pattern-hdr">Análisis de Ruta</div>'
+ '<div class="cc-pattern-row"><span>Dist. al destino</span><strong>' + dist.toFixed(1) + ' km</strong></div>'
+ timeRow
+ '<div class="' + cls + '">' + label + '</div>'
+ '</div>';
},
_calcDist(lat1, lng1, lat2, lng2) {
const R = 6371;
const dLat = (lat2 - lat1) * Math.PI / 180;
const dLon = (lng2 - lng1) * Math.PI / 180;
const a = Math.sin(dLat/2)**2 + Math.cos(lat1*Math.PI/180) * Math.cos(lat2*Math.PI/180) * Math.sin(dLon/2)**2;
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
},
_bearing(lat1, lng1, lat2, lng2) {
const φ1 = lat1 * Math.PI/180, φ2 = lat2 * Math.PI/180;
const Δλ = (lng2 - lng1) * Math.PI/180;
const y = Math.sin(Δλ) * Math.cos(φ2);
const x = Math.cos(φ1)*Math.sin(φ2) - Math.sin(φ1)*Math.cos(φ2)*Math.cos(Δλ);
return ((Math.atan2(y, x) * 180/Math.PI) + 360) % 360;
},
_bearingDir(deg) {
const dirs = ['N','NE','E','SE','S','SO','O','NO'];
return dirs[Math.round(deg / 45) % 8] + ' (' + Math.round(deg) + '°)';
},
_geocodeAndUpdate(lat, lng, elId) {
if (!this._geocoder) this._geocoder = new google.maps.Geocoder();
const key = lat + ',' + lng;
if (this._geoCache[key]) {
const el = document.getElementById(elId);
if (el) el.textContent = this._geoCache[key];
return;
}
this._geocoder.geocode({ location: { lat: parseFloat(lat), lng: parseFloat(lng) } }, (results, status) => {
const addr = (status === 'OK' && results[0]) ? results[0].formatted_address : lat + ', ' + lng;
this._geoCache[key] = addr;
const el = document.getElementById(elId);
if (el) el.textContent = addr;
});
},
/* ── Audio alert ─────────────────────────────────────────── */
playAlert() {
try {
if (!this.audioCtx)
this.audioCtx = new (window.AudioContext || window.webkitAudioContext)();
[0, 0.45, 0.9].forEach(delay => {
const o = this.audioCtx.createOscillator();
const g = this.audioCtx.createGain();
o.connect(g); g.connect(this.audioCtx.destination);
o.type = 'square';
o.frequency.value = 880;
const t = this.audioCtx.currentTime + delay;
g.gain.setValueAtTime(0.12, t);
g.gain.exponentialRampToValueAtTime(0.001, t + 0.35);
o.start(t); o.stop(t + 0.35);
});
} catch (_) {}
},
/* ── Helpers ─────────────────────────────────────────────── */
_get(endpoint) {
return fetch(ccConfig.rest + endpoint, {
headers: { 'X-WP-Nonce': ccConfig.nonce },
}).then(r => r.ok ? r.json() : null).catch(() => null);
},
_post(endpoint, data) {
return fetch(ccConfig.rest + endpoint, {
method: 'POST',
headers: { 'X-WP-Nonce': ccConfig.nonce, 'Content-Type': 'application/json' },
body: JSON.stringify(data),
}).then(r => r.json()).catch(() => null);
},
_toast(msg, type = 'info') {
const el = document.createElement('div');
el.className = 'cc-toast t-' + type;
el.textContent = msg;
document.body.appendChild(el);
requestAnimationFrame(() => requestAnimationFrame(() => el.classList.add('show')));
setTimeout(() => {
el.classList.remove('show');
setTimeout(() => el.remove(), 350);
}, 3000);
},
_ago(dt) {
const m = Math.floor((Date.now() - new Date(dt).getTime()) / 60000);
if (m < 1) return 'ahora';
if (m < 60) return m + 'min';
return Math.floor(m / 60) + 'h';
},
_time(dt) { return new Date(dt).toLocaleTimeString('es-CO'); },
_esc(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
},
};
/* Wait for google.maps.Map, then give it 1500ms to stabilize before init */
(function waitForMaps(attempts) {
if (CC.map) return;
if (window.google && window.google.maps && typeof window.google.maps.Map === 'function') {
setTimeout(function() { if (!CC.map) CC.init(); }, 1500);
} else if (attempts < 40) {
setTimeout(function(){ waitForMaps(attempts + 1); }, 250);
}
})(0);