276 lines
9.5 KiB
HTML
276 lines
9.5 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<title>Connections Animation Map</title>
|
|
<link rel="stylesheet" href="https://unpkg.com/leaflet/dist/leaflet.css"/>
|
|
<style>
|
|
body { margin: 0; font-family: Arial, sans-serif; }
|
|
#controls {
|
|
position: absolute;
|
|
top: 10px;
|
|
left: 48px;
|
|
z-index: 1000;
|
|
background: rgba(255,255,255,0.95);
|
|
padding: 8px;
|
|
border-radius: 8px;
|
|
box-shadow: 0 2px 6px rgba(0,0,0,0.3);
|
|
max-width: 320px;
|
|
font-size: 13px;
|
|
pointer-events: auto;
|
|
}
|
|
#controls label { display: block; margin: 6px 0; }
|
|
#map { height: 100vh; width: 100vw; }
|
|
.fade-marker { transition: opacity 1s linear; opacity: 1; }
|
|
.fade-out { opacity: 0 !important; }
|
|
.arc-path { transition: opacity 1s linear; opacity: 1; stroke-width: 2; }
|
|
.arc-fade { opacity: 0 !important; }
|
|
#timeDisplay { font-weight: 600; margin-left: 6px; }
|
|
.small { font-size: 12px; color: #333; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="controls">
|
|
<label>Service: <input type="text" id="service" placeholder="/api/service1"></label>
|
|
<label>Start: <input type="datetime-local" id="start"></label>
|
|
<label>End: <input type="datetime-local" id="end"></label>
|
|
<label>Duration (seconds): <input type="number" id="duration" value="10" min="1"></label>
|
|
|
|
<label class="small">Time:
|
|
<input type="range" id="timeSlider" min="0" max="100" value="0" style="width: 220px;">
|
|
<span id="timeDisplay">—</span>
|
|
</label>
|
|
|
|
<div style="margin-top:6px;">
|
|
<button id="startBtn">Start Simulation</button>
|
|
<button id="stopBtn">Stop</button>
|
|
</div>
|
|
</div>
|
|
<div id="map"></div>
|
|
|
|
<script src="https://unpkg.com/leaflet/dist/leaflet.js"></script>
|
|
<script>
|
|
const OUR_COORDS = [37.7749, -122.4194];
|
|
const MAP_CENTER = [20, 0];
|
|
const MAP_ZOOM = 2;
|
|
|
|
var map = L.map('map').setView(MAP_CENTER, MAP_ZOOM);
|
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|
attribution: "© OpenStreetMap contributors"
|
|
}).addTo(map);
|
|
|
|
const serverMarker = L.circleMarker(OUR_COORDS, {
|
|
radius: 7,
|
|
color: 'red',
|
|
fillColor: 'red',
|
|
fillOpacity: 1
|
|
}).addTo(map);
|
|
|
|
serverMarker.bindTooltip("SRV", {
|
|
permanent: true,
|
|
direction: "right",
|
|
offset: [8, 0],
|
|
className: "srv-label"
|
|
}).openTooltip();
|
|
|
|
var markersLayer = L.layerGroup().addTo(map);
|
|
var arcsLayer = L.layerGroup().addTo(map);
|
|
|
|
const startBtn = document.getElementById('startBtn');
|
|
const stopBtn = document.getElementById('stopBtn');
|
|
const timeSlider = document.getElementById('timeSlider');
|
|
const timeDisplay = document.getElementById('timeDisplay');
|
|
|
|
let allData = [];
|
|
let animTimers = [];
|
|
let isPlaying = false;
|
|
let simStartMs = 0, simEndMs = 0;
|
|
|
|
async function fetchConnections(service, start, end) {
|
|
let url = `http://localhost:8000/connections?`;
|
|
if (service) url += `service=${encodeURIComponent(service)}&`;
|
|
if (start) url += `start=${new Date(start).toISOString()}&`;
|
|
if (end) url += `end=${new Date(end).toISOString()}&`;
|
|
let response = await fetch(url);
|
|
return await response.json();
|
|
}
|
|
|
|
function latLonToVec(lat, lon) {
|
|
const rlat = lat * Math.PI / 180;
|
|
const rlon = lon * Math.PI / 180;
|
|
return [Math.cos(rlat) * Math.cos(rlon), Math.cos(rlat) * Math.sin(rlon), Math.sin(rlat)];
|
|
}
|
|
function vecToLatLon(v) {
|
|
const x = v[0], y = v[1], z = v[2];
|
|
const lon = Math.atan2(y, x);
|
|
const hyp = Math.sqrt(x*x + y*y);
|
|
const lat = Math.atan2(z, hyp);
|
|
return [lat * 180/Math.PI, lon * 180/Math.PI];
|
|
}
|
|
function slerp(a, b, t) {
|
|
let dot = a[0]*b[0] + a[1]*b[1] + a[2]*b[2];
|
|
dot = Math.min(1, Math.max(-1, dot));
|
|
const omega = Math.acos(dot);
|
|
if (Math.abs(omega) < 1e-6) return a;
|
|
const so = Math.sin(omega);
|
|
const c1 = Math.sin((1 - t) * omega) / so;
|
|
const c2 = Math.sin(t * omega) / so;
|
|
return [c1 * a[0] + c2 * b[0], c1 * a[1] + c2 * b[1], c1 * a[2] + c2 * b[2]];
|
|
}
|
|
function computeArcPoints(lat1, lon1, lat2, lon2, segments = 60, heightFactor = 0.2) {
|
|
const v1 = latLonToVec(lat1, lon1);
|
|
const v2 = latLonToVec(lat2, lon2);
|
|
const pts = [];
|
|
for (let i = 0; i <= segments; i++) {
|
|
const t = i / segments;
|
|
let vi = slerp(v1, v2, t);
|
|
const bulge = Math.sin(Math.PI * t) * heightFactor;
|
|
const len = Math.sqrt(vi[0]*vi[0] + vi[1]*vi[1] + vi[2]*vi[2]);
|
|
vi = [vi[0]/len * (1 + bulge), vi[1]/len * (1 + bulge), vi[2]/len * (1 + bulge)];
|
|
pts.push(vecToLatLon(vi));
|
|
}
|
|
return pts;
|
|
}
|
|
|
|
function createMarkerAndArc(conn, visibleTimeMs = 2500) {
|
|
const lat = conn.lat, lon = conn.lon;
|
|
const marker = L.circleMarker([lat, lon], { radius: 5, fillOpacity: 0.9 })
|
|
.bindPopup(`<b>IP:</b> ${conn.ip}<br><b>Service:</b> ${conn.path}<br><b>Time:</b> ${conn.timestamp}`)
|
|
.addTo(markersLayer);
|
|
|
|
const arcPoints = computeArcPoints(lat, lon, OUR_COORDS[0], OUR_COORDS[1], 48, 0.18);
|
|
const arc = L.polyline(arcPoints, { color: '#0077ff', weight: 2, opacity: 1, className: 'arc-path' })
|
|
.addTo(arcsLayer);
|
|
|
|
const markerElem = marker._path;
|
|
if (markerElem) markerElem.classList.add('fade-marker');
|
|
const arcElem = arc._path;
|
|
if (arcElem) arcElem.classList.add('arc-path');
|
|
|
|
const t1 = setTimeout(() => {
|
|
if (markerElem) markerElem.classList.add('fade-out');
|
|
if (arcElem) arcElem.classList.add('arc-fade');
|
|
setTimeout(() => {
|
|
try { markersLayer.removeLayer(marker); } catch(e){}
|
|
try { arcsLayer.removeLayer(arc); } catch(e){}
|
|
}, 1000);
|
|
}, visibleTimeMs);
|
|
|
|
return t1;
|
|
}
|
|
|
|
function clearAll() {
|
|
animTimers.forEach(t => clearTimeout(t));
|
|
animTimers = [];
|
|
markersLayer.clearLayers();
|
|
arcsLayer.clearLayers();
|
|
isPlaying = false;
|
|
}
|
|
|
|
// === NEW: 24H format display ===
|
|
function updateTimeDisplay(ms) {
|
|
if (!ms || isNaN(ms)) {
|
|
timeDisplay.textContent = '—';
|
|
return;
|
|
}
|
|
const d = new Date(ms);
|
|
const pad = n => n.toString().padStart(2,'0');
|
|
timeDisplay.textContent = `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ` +
|
|
`${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
|
}
|
|
|
|
function drawUpTo(ms) {
|
|
markersLayer.clearLayers();
|
|
arcsLayer.clearLayers();
|
|
const subset = allData.filter(ev => new Date(ev.timestamp).getTime() <= ms);
|
|
subset.forEach(ev => {
|
|
const m = L.circleMarker([ev.lat, ev.lon], { radius: 5, fillOpacity: 0.9 })
|
|
.bindPopup(`<b>IP:</b> ${ev.ip}<br><b>Service:</b> ${ev.path}<br><b>Time:</b> ${ev.timestamp}`)
|
|
.addTo(markersLayer);
|
|
const arcP = computeArcPoints(ev.lat, ev.lon, OUR_COORDS[0], OUR_COORDS[1], 36, 0.12);
|
|
L.polyline(arcP, { color: '#0077ff', weight: 2, opacity: 0.6 })
|
|
.addTo(arcsLayer);
|
|
});
|
|
updateTimeDisplay(ms);
|
|
}
|
|
|
|
function formatDatetimeLocal(d) {
|
|
const pad = n => n.toString().padStart(2,'0');
|
|
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
}
|
|
|
|
async function startSimulation() {
|
|
clearAll();
|
|
const service = document.getElementById("service").value;
|
|
let startVal = document.getElementById("start").value;
|
|
let endVal = document.getElementById("end").value;
|
|
const durationSec = parseFloat(document.getElementById("duration").value) || 10;
|
|
|
|
if (!startVal && !endVal) {
|
|
const now = new Date();
|
|
const twoWeeksAgo = new Date(now.getTime() - 1000*60*60*24*14);
|
|
startVal = formatDatetimeLocal(twoWeeksAgo);
|
|
endVal = formatDatetimeLocal(now);
|
|
document.getElementById("start").value = startVal;
|
|
document.getElementById("end").value = endVal;
|
|
} else if (!startVal || !endVal) {
|
|
alert('Please set both start & end times');
|
|
return;
|
|
}
|
|
|
|
const startMs = new Date(startVal).getTime();
|
|
const endMs = new Date(endVal).getTime();
|
|
if (endMs <= startMs) { alert('End must be after start'); return; }
|
|
if ((endMs - startMs) > 1000*60*60*24*90) { alert('Max 3 months'); return; }
|
|
|
|
const data = await fetchConnections(service, startVal, endVal);
|
|
if (!data.length) { alert('No data'); return; }
|
|
|
|
allData = data.slice().sort((a,b) => new Date(a.timestamp)-new Date(b.timestamp));
|
|
simStartMs = startMs; simEndMs = endMs;
|
|
|
|
timeSlider.min = simStartMs;
|
|
timeSlider.max = simEndMs;
|
|
timeSlider.value = simStartMs;
|
|
updateTimeDisplay(simStartMs);
|
|
|
|
isPlaying = true;
|
|
const totalEvents = allData.length;
|
|
const totalMs = durationSec * 1000;
|
|
const baseInterval = totalMs / totalEvents;
|
|
|
|
allData.forEach((ev, idx) => {
|
|
const scheduledAt = Math.round(idx * baseInterval);
|
|
const visibleTime = Math.max(600, Math.min(4000, totalMs/8));
|
|
const tshow = setTimeout(() => {
|
|
const timer = createMarkerAndArc(ev, visibleTime);
|
|
animTimers.push(timer);
|
|
const simTime = simStartMs + (scheduledAt/totalMs)*(simEndMs - simStartMs);
|
|
timeSlider.value = simTime;
|
|
updateTimeDisplay(simTime);
|
|
}, scheduledAt);
|
|
animTimers.push(tshow);
|
|
});
|
|
|
|
const finishTimer = setTimeout(() => { isPlaying = false; }, totalMs+2000);
|
|
animTimers.push(finishTimer);
|
|
}
|
|
|
|
function stopSimulation() {
|
|
clearAll();
|
|
if (simStartMs) {
|
|
timeSlider.value = simStartMs;
|
|
updateTimeDisplay(simStartMs);
|
|
}
|
|
}
|
|
|
|
timeSlider.addEventListener('input', e => {
|
|
if (isPlaying) clearAll();
|
|
drawUpTo(parseInt(e.target.value,10));
|
|
});
|
|
|
|
startBtn.addEventListener('click', startSimulation);
|
|
stopBtn.addEventListener('click', stopSimulation);
|
|
</script>
|
|
</body>
|
|
</html>
|