693 lines
24 KiB
PHP
693 lines
24 KiB
PHP
<!DOCTYPE html>
|
|
<html lang="es">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Mapa de Calor - Conexiones en Tiempo Real</title>
|
|
<!-- MapLibre GL JS -->
|
|
<script src="https://unpkg.com/maplibre-gl@2.4.0/dist/maplibre-gl.js"></script>
|
|
<link href="https://unpkg.com/maplibre-gl@2.4.0/dist/maplibre-gl.css" rel="stylesheet">
|
|
<!-- Font Awesome para iconos -->
|
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
|
<style>
|
|
* {
|
|
margin: 0;
|
|
padding: 0;
|
|
box-sizing: border-box;
|
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
|
}
|
|
|
|
body {
|
|
background: linear-gradient(135deg, #1a2a6c, #2c3e50);
|
|
color: #fff;
|
|
min-height: 100vh;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.container {
|
|
display: flex;
|
|
flex-direction: column;
|
|
height: 100vh;
|
|
padding: 20px;
|
|
}
|
|
|
|
header {
|
|
text-align: center;
|
|
padding: 15px 0;
|
|
background: rgba(0, 0, 0, 0.3);
|
|
border-radius: 15px;
|
|
margin-bottom: 20px;
|
|
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
|
|
backdrop-filter: blur(10px);
|
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
}
|
|
|
|
header h1 {
|
|
font-size: 2.2rem;
|
|
margin-bottom: 8px;
|
|
color: #fff;
|
|
text-shadow: 0 0 10px rgba(0, 255, 255, 0.7);
|
|
}
|
|
|
|
header p {
|
|
font-size: 1.1rem;
|
|
opacity: 0.9;
|
|
max-width: 800px;
|
|
margin: 0 auto;
|
|
}
|
|
|
|
.content {
|
|
display: flex;
|
|
flex: 1;
|
|
gap: 20px;
|
|
height: calc(100% - 130px);
|
|
}
|
|
|
|
#map {
|
|
flex: 3;
|
|
border-radius: 15px;
|
|
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
|
|
overflow: hidden;
|
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
}
|
|
|
|
.panel {
|
|
flex: 1;
|
|
background: rgba(0, 10, 20, 0.7);
|
|
border-radius: 15px;
|
|
padding: 20px;
|
|
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 20px;
|
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
backdrop-filter: blur(10px);
|
|
max-width: 350px;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.stats-card {
|
|
background: rgba(0, 30, 60, 0.6);
|
|
border-radius: 12px;
|
|
padding: 20px;
|
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
|
border: 1px solid rgba(0, 200, 255, 0.2);
|
|
}
|
|
|
|
.stats-card h2 {
|
|
font-size: 1.4rem;
|
|
margin-bottom: 15px;
|
|
color: #4fc3f7;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
}
|
|
|
|
.stats-card h2 i {
|
|
font-size: 1.6rem;
|
|
}
|
|
|
|
.stats-grid {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 15px;
|
|
}
|
|
|
|
.stat-item {
|
|
background: rgba(0, 50, 100, 0.4);
|
|
padding: 12px;
|
|
border-radius: 10px;
|
|
text-align: center;
|
|
}
|
|
|
|
.stat-value {
|
|
font-size: 2.2rem;
|
|
font-weight: bold;
|
|
color: #4fc3f7;
|
|
margin: 5px 0;
|
|
}
|
|
|
|
.stat-label {
|
|
font-size: 0.9rem;
|
|
opacity: 0.8;
|
|
}
|
|
|
|
.connection-list {
|
|
max-height: 300px;
|
|
overflow-y: auto;
|
|
background: rgba(0, 20, 40, 0.5);
|
|
border-radius: 10px;
|
|
padding: 15px;
|
|
}
|
|
|
|
.connection-item {
|
|
padding: 10px;
|
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
|
display: flex;
|
|
justify-content: space-between;
|
|
}
|
|
|
|
.connection-item:last-child {
|
|
border-bottom: none;
|
|
}
|
|
|
|
.connection-ip {
|
|
font-weight: bold;
|
|
color: #4fc3f7;
|
|
}
|
|
|
|
.connection-location {
|
|
font-size: 0.9rem;
|
|
opacity: 0.9;
|
|
}
|
|
|
|
.connection-count {
|
|
background: rgba(79, 195, 247, 0.2);
|
|
padding: 2px 10px;
|
|
border-radius: 20px;
|
|
font-weight: bold;
|
|
}
|
|
|
|
.controls {
|
|
display: flex;
|
|
gap: 10px;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.control-btn {
|
|
flex: 1;
|
|
padding: 12px;
|
|
background: rgba(0, 100, 200, 0.6);
|
|
border: none;
|
|
border-radius: 8px;
|
|
color: white;
|
|
font-weight: bold;
|
|
cursor: pointer;
|
|
transition: all 0.3s ease;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 8px;
|
|
}
|
|
|
|
.control-btn:hover {
|
|
background: rgba(0, 150, 255, 0.8);
|
|
transform: translateY(-2px);
|
|
}
|
|
|
|
.control-btn:active {
|
|
transform: translateY(1px);
|
|
}
|
|
|
|
.control-btn.active {
|
|
background: rgba(0, 200, 255, 0.8);
|
|
box-shadow: 0 0 15px rgba(79, 195, 247, 0.5);
|
|
}
|
|
|
|
.timestamp {
|
|
text-align: center;
|
|
font-size: 0.9rem;
|
|
opacity: 0.7;
|
|
margin-top: 10px;
|
|
}
|
|
|
|
.loading {
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
height: 100%;
|
|
flex-direction: column;
|
|
gap: 20px;
|
|
}
|
|
|
|
.spinner {
|
|
width: 50px;
|
|
height: 50px;
|
|
border: 5px solid rgba(79, 195, 247, 0.3);
|
|
border-top: 5px solid #4fc3f7;
|
|
border-radius: 50%;
|
|
animation: spin 1s linear infinite;
|
|
}
|
|
|
|
@keyframes spin {
|
|
0% { transform: rotate(0deg); }
|
|
100% { transform: rotate(360deg); }
|
|
}
|
|
|
|
.map-overlay {
|
|
position: absolute;
|
|
bottom: 20px;
|
|
right: 20px;
|
|
background: rgba(0, 10, 20, 0.8);
|
|
border-radius: 10px;
|
|
padding: 15px;
|
|
max-width: 250px;
|
|
z-index: 1;
|
|
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3);
|
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
backdrop-filter: blur(5px);
|
|
}
|
|
|
|
.map-overlay h3 {
|
|
color: #4fc3f7;
|
|
margin-bottom: 10px;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
|
|
.legend-item {
|
|
display: flex;
|
|
align-items: center;
|
|
margin: 8px 0;
|
|
}
|
|
|
|
.legend-color {
|
|
width: 25px;
|
|
height: 15px;
|
|
margin-right: 10px;
|
|
border-radius: 3px;
|
|
}
|
|
|
|
.status-indicator {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
margin-top: 10px;
|
|
padding: 8px;
|
|
background: rgba(0, 30, 60, 0.6);
|
|
border-radius: 8px;
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
.status-dot {
|
|
width: 10px;
|
|
height: 10px;
|
|
border-radius: 50%;
|
|
background-color: #4CAF50;
|
|
}
|
|
|
|
.status-dot.active {
|
|
background-color: #4CAF50;
|
|
box-shadow: 0 0 10px #4CAF50;
|
|
}
|
|
|
|
.status-dot.inactive {
|
|
background-color: #f44336;
|
|
}
|
|
|
|
@media (max-width: 900px) {
|
|
.content {
|
|
flex-direction: column;
|
|
}
|
|
|
|
.panel {
|
|
max-width: 100%;
|
|
}
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<header>
|
|
<h1><i class="fas fa-fire"></i> Mapa de Calor de Conexiones</h1>
|
|
<p>Visualización en tiempo real de conexiones alrededor del mundo - Actualizando cada 5 segundos</p>
|
|
</header>
|
|
|
|
<div class="content">
|
|
<div id="map"></div>
|
|
|
|
<div class="panel">
|
|
<div class="stats-card">
|
|
<h2><i class="fas fa-chart-bar"></i> Estadísticas</h2>
|
|
<div class="stats-grid">
|
|
<div class="stat-item">
|
|
<div class="stat-value" id="total-connections">0</div>
|
|
<div class="stat-label">Conexiones totales</div>
|
|
</div>
|
|
<div class="stat-item">
|
|
<div class="stat-value" id="unique-ips">0</div>
|
|
<div class="stat-label">IPs únicas</div>
|
|
</div>
|
|
<div class="stat-item">
|
|
<div class="stat-value" id="max-connections">0</div>
|
|
<div class="stat-label">Máx. conexiones</div>
|
|
</div>
|
|
<div class="stat-item">
|
|
<div class="stat-value" id="cuba-connections">0</div>
|
|
<div class="stat-label">Conexiones en Cuba</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="stats-card">
|
|
<h2><i class="fas fa-globe-americas"></i> Últimas conexiones</h2>
|
|
<div class="connection-list" id="connection-list">
|
|
<div class="loading">
|
|
<div class="spinner"></div>
|
|
<div>Cargando datos...</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="stats-card">
|
|
<h2><i class="fas fa-sliders-h"></i> Controles</h2>
|
|
<div class="controls">
|
|
<button id="heatmap-btn" class="control-btn active">
|
|
<i class="fas fa-fire"></i> Calor
|
|
</button>
|
|
<button id="points-btn" class="control-btn">
|
|
<i class="fas fa-map-marker-alt"></i> Puntos
|
|
</button>
|
|
<button id="refresh-btn" class="control-btn">
|
|
<i class="fas fa-sync-alt"></i> Actualizar
|
|
</button>
|
|
</div>
|
|
<div class="status-indicator">
|
|
<div class="status-dot active" id="status-dot"></div>
|
|
<span id="status-text">Conectado a la API</span>
|
|
</div>
|
|
<div class="timestamp">
|
|
Última actualización: <span id="last-update">--:--:--</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="map-overlay">
|
|
<h3><i class="fas fa-layer-group"></i> Leyenda del Mapa</h3>
|
|
<div class="legend-item">
|
|
<div class="legend-color" style="background: rgba(0, 0, 255, 0.5);"></div>
|
|
<span>Baja densidad</span>
|
|
</div>
|
|
<div class="legend-item">
|
|
<div class="legend-color" style="background: rgba(0, 255, 255, 0.5);"></div>
|
|
<span>Media densidad</span>
|
|
</div>
|
|
<div class="legend-item">
|
|
<div class="legend-color" style="background: rgba(0, 255, 0, 0.5);"></div>
|
|
<span>Alta densidad</span>
|
|
</div>
|
|
<div class="legend-item">
|
|
<div class="legend-color" style="background: rgba(255, 255, 0, 0.5);"></div>
|
|
<span>Muy alta densidad</span>
|
|
</div>
|
|
<div class="legend-item">
|
|
<div class="legend-color" style="background: rgba(255, 0, 0, 0.5);"></div>
|
|
<span>Máxima densidad</span>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
// Variables globales
|
|
let map;
|
|
let refreshInterval;
|
|
let currentData = [];
|
|
let isHeatmapActive = true;
|
|
let isPointsActive = false;
|
|
|
|
// Inicializar el mapa
|
|
function initMap() {
|
|
map = new maplibregl.Map({
|
|
container: 'map',
|
|
style: 'https://demotiles.maplibre.org/style.json',
|
|
center: [-79.5, 21.5], // Centro en Cuba
|
|
zoom: 6,
|
|
attributionControl: false
|
|
});
|
|
|
|
map.addControl(new maplibregl.AttributionControl({
|
|
compact: true
|
|
}));
|
|
|
|
map.addControl(new maplibregl.NavigationControl(), 'top-right');
|
|
|
|
map.on('load', () => {
|
|
// Cargar datos iniciales
|
|
fetchData();
|
|
|
|
// Configurar intervalo para actualizar cada 5 segundos
|
|
refreshInterval = setInterval(fetchData, 5000);
|
|
});
|
|
}
|
|
|
|
// Obtener datos de la API
|
|
async function fetchData() {
|
|
try {
|
|
updateStatus('Conectando a la API...', 'connecting');
|
|
|
|
const response = await fetch('http://192.168.6.63/mapa/data.php');
|
|
if (!response.ok) throw new Error('Error en la respuesta de la API');
|
|
|
|
const data = await response.json();
|
|
currentData = data;
|
|
|
|
updateMap(data);
|
|
updateStats(data);
|
|
updateConnectionList(data);
|
|
updateStatus('Datos actualizados correctamente', 'success');
|
|
|
|
// Actualizar marca de tiempo
|
|
const now = new Date();
|
|
document.getElementById('last-update').textContent =
|
|
now.toLocaleTimeString();
|
|
} catch (error) {
|
|
console.error('Error al obtener datos:', error);
|
|
updateStatus('Error al obtener datos', 'error');
|
|
}
|
|
}
|
|
|
|
// Actualizar el mapa con nuevos datos
|
|
function updateMap(data) {
|
|
// Filtrar puntos con coordenadas válidas (excluir (0,0))
|
|
const filteredData = data.filter(item => item.lat !== 0 && item.lon !== 0);
|
|
|
|
// Convertir a GeoJSON
|
|
const geojson = {
|
|
type: 'FeatureCollection',
|
|
features: filteredData.map(item => ({
|
|
type: 'Feature',
|
|
geometry: {
|
|
type: 'Point',
|
|
coordinates: [item.lon, item.lat]
|
|
},
|
|
properties: {
|
|
weight: item.connections,
|
|
ip: item.ip,
|
|
country: item.country,
|
|
connections: item.connections
|
|
}
|
|
}))
|
|
};
|
|
|
|
// Si la fuente ya existe, actualizar los datos
|
|
if (map.getSource('heatmap-source')) {
|
|
map.getSource('heatmap-source').setData(geojson);
|
|
} else {
|
|
// Crear la fuente de datos
|
|
map.addSource('heatmap-source', {
|
|
type: 'geojson',
|
|
data: geojson
|
|
});
|
|
|
|
// Añadir capa de calor
|
|
map.addLayer({
|
|
id: 'heatmap',
|
|
type: 'heatmap',
|
|
source: 'heatmap-source',
|
|
paint: {
|
|
'heatmap-weight': [
|
|
'interpolate',
|
|
['linear'],
|
|
['get', 'weight'],
|
|
0, 0.1,
|
|
8, 1.0
|
|
],
|
|
'heatmap-color': [
|
|
'interpolate',
|
|
['linear'],
|
|
['heatmap-density'],
|
|
0, 'rgba(0, 0, 255, 0)',
|
|
0.2, 'rgba(0, 0, 255, 0.5)',
|
|
0.4, 'rgba(0, 255, 255, 0.5)',
|
|
0.6, 'rgba(0, 255, 0, 0.5)',
|
|
0.8, 'rgba(255, 255, 0, 0.5)',
|
|
1, 'rgba(255, 0, 0, 0.5)'
|
|
],
|
|
'heatmap-radius': [
|
|
'interpolate',
|
|
['linear'],
|
|
['zoom'],
|
|
0, 5,
|
|
9, 20
|
|
],
|
|
'heatmap-opacity': 0.7
|
|
}
|
|
});
|
|
|
|
// Añadir capa de puntos (inicialmente oculta)
|
|
map.addLayer({
|
|
id: 'points',
|
|
type: 'circle',
|
|
source: 'heatmap-source',
|
|
paint: {
|
|
'circle-radius': [
|
|
'interpolate',
|
|
['linear'],
|
|
['get', 'connections'],
|
|
1, 4,
|
|
8, 12
|
|
],
|
|
'circle-color': [
|
|
'interpolate',
|
|
['linear'],
|
|
['get', 'connections'],
|
|
1, '#4287f5',
|
|
4, '#42f5ef',
|
|
8, '#f54242'
|
|
],
|
|
'circle-stroke-width': 1,
|
|
'circle-stroke-color': '#fff',
|
|
'circle-opacity': 0.7
|
|
},
|
|
filter: ['==', '$type', 'Point']
|
|
});
|
|
|
|
// Ocultar capa de puntos inicialmente
|
|
map.setLayoutProperty('points', 'visibility', 'none');
|
|
|
|
// Añadir popups al hacer clic en los puntos
|
|
map.on('click', 'points', (e) => {
|
|
const ip = e.features[0].properties.ip;
|
|
const country = e.features[0].properties.country;
|
|
const connections = e.features[0].properties.connections;
|
|
|
|
new maplibregl.Popup()
|
|
.setLngLat(e.lngLat)
|
|
.setHTML(`
|
|
<div class="popup-content">
|
|
<strong>${ip}</strong><br>
|
|
${country}<br>
|
|
<span style="color: #f54242">${connections} conexión${connections > 1 ? 'es' : ''}</span>
|
|
</div>
|
|
`)
|
|
.addTo(map);
|
|
});
|
|
|
|
// Cambiar el cursor al pasar sobre puntos
|
|
map.on('mouseenter', 'points', () => {
|
|
map.getCanvas().style.cursor = 'pointer';
|
|
});
|
|
|
|
map.on('mouseleave', 'points', () => {
|
|
map.getCanvas().style.cursor = '';
|
|
});
|
|
}
|
|
}
|
|
|
|
// Actualizar estadísticas
|
|
function updateStats(data) {
|
|
// Total de conexiones
|
|
const totalConnections = data.reduce((sum, item) => sum + item.connections, 0);
|
|
document.getElementById('total-connections').textContent = totalConnections;
|
|
|
|
// IPs únicas
|
|
const uniqueIps = new Set(data.map(item => item.ip)).size;
|
|
document.getElementById('unique-ips').textContent = uniqueIps;
|
|
|
|
// Máximo de conexiones
|
|
const maxConnections = Math.max(...data.map(item => item.connections));
|
|
document.getElementById('max-connections').textContent = maxConnections;
|
|
|
|
// Conexiones en Cuba
|
|
const cubaConnections = data
|
|
.filter(item => item.country.includes('CU'))
|
|
.reduce((sum, item) => sum + item.connections, 0);
|
|
document.getElementById('cuba-connections').textContent = cubaConnections;
|
|
}
|
|
|
|
// Actualizar lista de conexiones
|
|
function updateConnectionList(data) {
|
|
const listElement = document.getElementById('connection-list');
|
|
listElement.innerHTML = '';
|
|
|
|
// Ordenar por timestamp (más recientes primero)
|
|
const sortedData = [...data].sort((a, b) => b.timestamp - a.timestamp);
|
|
|
|
// Tomar los últimos 10 elementos
|
|
sortedData.slice(0, 10).forEach(item => {
|
|
const connectionItem = document.createElement('div');
|
|
connectionItem.className = 'connection-item';
|
|
|
|
// Convertir timestamp a hora legible
|
|
const date = new Date(item.timestamp * 1000);
|
|
const timeString = date.toLocaleTimeString();
|
|
|
|
connectionItem.innerHTML = `
|
|
<div>
|
|
<div class="connection-ip">${item.ip}</div>
|
|
<div class="connection-location">${item.country} • ${timeString}</div>
|
|
</div>
|
|
<div class="connection-count">${item.connections}</div>
|
|
`;
|
|
|
|
listElement.appendChild(connectionItem);
|
|
});
|
|
}
|
|
|
|
// Actualizar estado de conexión
|
|
function updateStatus(message, status) {
|
|
const statusText = document.getElementById('status-text');
|
|
const statusDot = document.getElementById('status-dot');
|
|
|
|
statusText.textContent = message;
|
|
|
|
// Limpiar clases anteriores
|
|
statusDot.className = 'status-dot';
|
|
|
|
if (status === 'success') {
|
|
statusDot.classList.add('active');
|
|
} else if (status === 'error') {
|
|
statusDot.classList.add('inactive');
|
|
} else if (status === 'connecting') {
|
|
statusDot.classList.add('active');
|
|
statusDot.style.animation = 'pulse 1.5s infinite';
|
|
}
|
|
}
|
|
|
|
// Inicializar controles
|
|
function initControls() {
|
|
// Botón de mapa de calor
|
|
document.getElementById('heatmap-btn').addEventListener('click', () => {
|
|
isHeatmapActive = !isHeatmapActive;
|
|
map.setLayoutProperty('heatmap', 'visibility', isHeatmapActive ? 'visible' : 'none');
|
|
|
|
const btn = document.getElementById('heatmap-btn');
|
|
btn.classList.toggle('active', isHeatmapActive);
|
|
});
|
|
|
|
// Botón de puntos
|
|
document.getElementById('points-btn').addEventListener('click', () => {
|
|
isPointsActive = !isPointsActive;
|
|
map.setLayoutProperty('points', 'visibility', isPointsActive ? 'visible' : 'none');
|
|
|
|
const btn = document.getElementById('points-btn');
|
|
btn.classList.toggle('active', isPointsActive);
|
|
});
|
|
|
|
// Botón de actualización
|
|
document.getElementById('refresh-btn').addEventListener('click', () => {
|
|
fetchData();
|
|
});
|
|
}
|
|
|
|
// Iniciar la aplicación cuando el DOM esté listo
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
initMap();
|
|
initControls();
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|