feat: AutoBooking initial commit — PHP WordPress Plugin (REST API, wpdb, WP_User_Query)
This commit is contained in:
@@ -0,0 +1,616 @@
|
||||
/* ── 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, '&')
|
||||
.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);
|
||||
Reference in New Issue
Block a user