/* ── 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 = ` `; 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( `
${d.display_name || 'Conductor'}
Disponible
Lat: ${parseFloat(d.lat).toFixed(5)}, Lng: ${parseFloat(d.lng).toFixed(5)}
` ); 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 = '
Sin alertas activas
'; return; } el.innerHTML = sos.map(s => `
🚨 ${this._esc(s.user_name || 'Usuario desconocido')} ${this._ago(s.created_at)}
${this._esc(s.type || 'SOS')} — Viaje #${s.trip_id || '—'}
${s.description ? `
${this._esc(s.description)}
` : ''}
${s.trip_id ? `` : ''}
`).join(''); }, updateTripsList(trips) { const el = document.getElementById('trips-list'); if (!trips.length) { el.innerHTML = '
Sin viajes activos
'; return; } el.innerHTML = trips.map(t => `
${this._esc(t.driver_name || 'Conductor #' + t.driver_id)}
👤 ${this._esc(t.passenger_name || 'Pasajero #' + t.passenger_id)}
📍 ${this._esc((t.pickup_address || '').substring(0, 42))}
${t.status}${!t.driver_lat ? ' · Sin GPS' : ''}
`).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 = `
${this._esc(t.driver_name || '#' + t.driver_id)}
${this._esc(t.passenger_name || '#' + t.passenger_id)}
${this._esc(t.driver_phone || 'N/A')}
${this._esc(t.passenger_phone || 'N/A')}
${this._esc(t.dropoff_address || 'Consultando...')}
${this._esc(t.status || 'N/A')}
${t.started_at ? this._time(t.started_at) : 'N/A'}
`; document.getElementById('cc-panel-pattern').innerHTML = this._patternHtml(t); document.getElementById('cc-panel-btns').innerHTML = ` `; 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) => '
' + '' + label + '' + '' + val + '
'; return '
' + '
🚗 ' + driver + '
' + 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)) : '') + '
'; }, _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 '
Análisis de Ruta
' + '
Sin datos de posición actual
'; } 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 = '
Tiempo en viaje' + mins + ' min
'; } return '
' + '
Análisis de Ruta
' + '
Dist. al destino' + dist.toFixed(1) + ' km
' + timeRow + '
' + label + '
' + '
'; }, _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, '&') .replace(//g, '>') .replace(/"/g, '"'); }, }; /* 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);