250 lines
13 KiB
HTML
250 lines
13 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="fr">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>World Map – Live Email Flux</title>
|
||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"/>
|
||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||
<style>
|
||
*{margin:0;padding:0;box-sizing:border-box}
|
||
body{background:#0a0e17;color:#e0e6f0;font-family:'Segoe UI',system-ui,sans-serif;overflow:hidden;height:100vh}
|
||
.header{display:flex;align-items:center;justify-content:space-between;padding:10px 20px;background:#0d1220;border-bottom:1px solid #1a2340;z-index:10;position:relative}
|
||
.header h1{font-size:18px;font-weight:600;color:#d4a843}
|
||
.header .sub{font-size:12px;color:#6b7a99;margin-left:12px}
|
||
.clock{font-size:22px;font-weight:300;color:#8fa4c8;font-variant-numeric:tabular-nums}
|
||
.main{display:grid;grid-template-columns:300px 1fr 340px;height:calc(100vh - 48px - 80px)}
|
||
.panel{padding:16px;overflow-y:auto}
|
||
.panel-left{background:#0d1220;border-right:1px solid #1a2340}
|
||
.panel-right{background:#0d1220;border-left:1px solid #1a2340}
|
||
.panel h3{font-size:13px;font-weight:600;color:#8fa4c8;text-transform:uppercase;letter-spacing:1px;margin-bottom:12px}
|
||
#map{width:100%;height:100%;background:#0a0e17}
|
||
.country-row{display:flex;align-items:center;padding:6px 0;border-bottom:1px solid #111a2e}
|
||
.country-flag{width:22px;margin-right:8px;font-size:16px}
|
||
.country-name{flex:1;font-size:13px;color:#c0c8d8}
|
||
.country-cnt{font-size:13px;font-weight:600;color:#d4a843;min-width:70px;text-align:right}
|
||
.country-bar{width:80px;height:6px;background:#111a2e;border-radius:3px;margin-left:8px;overflow:hidden}
|
||
.country-bar-fill{height:100%;border-radius:3px;background:linear-gradient(90deg,#1e88e5,#42a5f5)}
|
||
.feed-item{padding:6px 0;border-bottom:1px solid #111a2e;font-size:12px;font-variant-numeric:tabular-nums}
|
||
.feed-time{color:#5a6a88;margin-right:6px}
|
||
.feed-type{padding:1px 6px;border-radius:3px;font-size:10px;font-weight:700;margin:0 4px}
|
||
.feed-open{background:#1b5e20;color:#66bb6a}
|
||
.feed-click{background:#e65100;color:#ff9800}
|
||
.feed-send{background:#1565c0;color:#42a5f5}
|
||
.feed-bounce{background:#b71c1c;color:#ef5350}
|
||
.feed-tid{color:#6b7a99;font-family:monospace;font-size:10px}
|
||
.bottom-bar{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:1px;background:#0d1220;border-top:1px solid #1a2340;height:80px}
|
||
.kpi{display:flex;flex-direction:column;align-items:center;justify-content:center;background:#0a0e17}
|
||
.kpi-val{font-size:24px;font-weight:700;color:#d4a843;font-variant-numeric:tabular-nums}
|
||
.kpi-val.green{color:#66bb6a}
|
||
.kpi-val.red{color:#ef5350}
|
||
.kpi-val.blue{color:#42a5f5}
|
||
.kpi-label{font-size:9px;color:#5a6a88;text-transform:uppercase;letter-spacing:1px;margin-top:2px}
|
||
.dot{display:inline-block;width:6px;height:6px;border-radius:50%;margin-right:4px}
|
||
.dot-live{background:#66bb6a;animation:pulse 2s infinite}
|
||
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.3}}
|
||
.leaflet-control-attribution{display:none!important}
|
||
.leaflet-tile-pane{filter:saturate(0.2) brightness(0.4) contrast(1.2)}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="header">
|
||
<div style="display:flex;align-items:center">
|
||
<h1>World Map – Live Email Flux</h1>
|
||
<span class="sub">Données temps réel depuis la base — zéro simulation</span>
|
||
</div>
|
||
<div class="clock" id="clock"></div>
|
||
<div style="display:flex;align-items:center;gap:8px">
|
||
<span class="dot dot-live"></span>
|
||
<span style="font-size:12px;color:#66bb6a">LIVE DB</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="main">
|
||
<div class="panel panel-left">
|
||
<h3>Contacts par pays</h3>
|
||
<div id="countries">Chargement...</div>
|
||
</div>
|
||
<div id="map"></div>
|
||
<div class="panel panel-right">
|
||
<h3>Live Feed — Tracking Events</h3>
|
||
<div id="feed">Chargement...</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="bottom-bar" id="kpis">
|
||
<div class="kpi"><div class="kpi-val" id="k-sends">—</div><div class="kpi-label">Envois Total</div></div>
|
||
<div class="kpi"><div class="kpi-val green" id="k-inbox">—</div><div class="kpi-label">Inbox Rate</div></div>
|
||
<div class="kpi"><div class="kpi-val blue" id="k-isps">—</div><div class="kpi-label">ISPs Actifs</div></div>
|
||
<div class="kpi"><div class="kpi-val" id="k-methods">—</div><div class="kpi-label">Méthodes</div></div>
|
||
<div class="kpi"><div class="kpi-val blue" id="k-contacts">—</div><div class="kpi-label">Contacts</div></div>
|
||
<div class="kpi"><div class="kpi-val" id="k-senders">—</div><div class="kpi-label">Senders</div></div>
|
||
<div class="kpi"><div class="kpi-val red" id="k-bounce">—</div><div class="kpi-label">Bounce Rate</div></div>
|
||
<div class="kpi"><div class="kpi-val blue" id="k-countries">—</div><div class="kpi-label">Pays</div></div>
|
||
</div>
|
||
|
||
<script>
|
||
const API = '/api/realtime-stats.php';
|
||
const FLAGS = {US:'🇺🇸','United States':'🇺🇸',Germany:'🇩🇪',France:'🇫🇷',UK:'🇬🇧','United Kingdom':'🇬🇧',Sweden:'🇸🇪',Italy:'🇮🇹',Spain:'🇪🇸',Netherlands:'🇳🇱',Belgium:'🇧🇪',Switzerland:'🇨🇭',Russia:'🇷🇺',Canada:'🇨🇦',Brazil:'🇧🇷',Morocco:'🇲🇦',Tunisia:'🇹🇳',Algeria:'🇩🇿',Portugal:'🇵🇹',Poland:'🇵🇱',Austria:'🇦🇹',Norway:'🇳🇴',Denmark:'🇩🇰',Finland:'🇫🇮',Ireland:'🇮🇪',Japan:'🇯🇵',China:'🇨🇳',India:'🇮🇳',Australia:'🇦🇺',Mexico:'🇲🇽',SWEDEN:'🇸🇪',FRANCE:'🇫🇷',GERMANY:'🇩🇪'};
|
||
const COORDS = {US:[39,-98],'United States':[39,-98],Germany:[51,10],France:[46,2],UK:[54,-2],'United Kingdom':[54,-2],Sweden:[62,15],Italy:[42,12],Spain:[40,-4],Netherlands:[52,5],Belgium:[50,4],Switzerland:[47,8],Russia:[55,37],Canada:[56,-106],Brazil:[-14,-51],Morocco:[32,-5],Tunisia:[34,9],Algeria:[28,2],Portugal:[39,-8],Poland:[52,20],Austria:[47,14],Norway:[62,10],Denmark:[56,10],Finland:[64,26],Ireland:[53,-8],Japan:[36,138],China:[35,105],India:[20,77],Australia:[-25,134],Mexico:[23,-102],SWEDEN:[62,15],FRANCE:[46,2],GERMANY:[51,10]};
|
||
|
||
function N(n){if(!n||n==0)return'0';if(n>=1e6)return(n/1e6).toFixed(1)+'M';if(n>=1e3)return(n/1e3).toFixed(0)+'K';return n.toString()}
|
||
|
||
// Clock
|
||
setInterval(()=>{const d=new Date();document.getElementById('clock').textContent=d.toLocaleTimeString('fr-FR')},1000);
|
||
document.getElementById('clock').textContent=new Date().toLocaleTimeString('fr-FR');
|
||
|
||
// Map
|
||
const map = L.map('map',{zoomControl:false,attributionControl:false}).setView([48,10],4);
|
||
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}{r}.png',{maxZoom:18}).addTo(map);
|
||
L.control.zoom({position:'bottomright'}).addTo(map);
|
||
const markers = L.layerGroup().addTo(map);
|
||
|
||
async function fetchJSON(q){try{const r=await fetch(API+'?q='+q);return await r.json()}catch(e){return null}}
|
||
|
||
async function loadOverview(){
|
||
const d = await fetchJSON('overview');
|
||
if(!d) return;
|
||
document.getElementById('k-sends').textContent = N(d.sends_total);
|
||
document.getElementById('k-inbox').textContent = d.inbox_rate+'%';
|
||
document.getElementById('k-isps').textContent = d.isps_active;
|
||
document.getElementById('k-methods').textContent = d.methods||'5';
|
||
document.getElementById('k-contacts').textContent = N(d.contacts_total);
|
||
document.getElementById('k-senders').textContent = N(d.senders_total);
|
||
document.getElementById('k-bounce').textContent = d.bounce_rate+'%';
|
||
document.getElementById('k-countries').textContent = d.countries;
|
||
}
|
||
|
||
async function loadCountries(){
|
||
const rows = await fetchJSON('by_country');
|
||
if(!rows||!rows.length){document.getElementById('countries').textContent='Pas de données';return}
|
||
const max = Math.max(...rows.map(r=>parseInt(r.cnt)));
|
||
let html = '';
|
||
rows.forEach(r=>{
|
||
const name = r.country;
|
||
const cnt = parseInt(r.cnt);
|
||
const pct = Math.round(cnt/max*100);
|
||
const flag = FLAGS[name]||'🌍';
|
||
html += `<div class="country-row">
|
||
<span class="country-flag">${flag}</span>
|
||
<span class="country-name">${name}</span>
|
||
<span class="country-cnt">${N(cnt)}</span>
|
||
<div class="country-bar"><div class="country-bar-fill" style="width:${pct}%"></div></div>
|
||
</div>`;
|
||
});
|
||
document.getElementById('countries').innerHTML = html;
|
||
|
||
// Add map markers
|
||
markers.clearLayers();
|
||
rows.forEach(r=>{
|
||
const coord = COORDS[r.country];
|
||
if(!coord) return;
|
||
const cnt = parseInt(r.cnt);
|
||
const radius = Math.max(8, Math.min(40, Math.sqrt(cnt/max)*40));
|
||
L.circleMarker(coord,{
|
||
radius: radius,
|
||
fillColor:'#42a5f5',
|
||
fillOpacity:0.25,
|
||
color:'#42a5f5',
|
||
weight:1,
|
||
opacity:0.5
|
||
}).bindPopup(`<b>${r.country}</b><br>${N(cnt)} contacts`).addTo(markers);
|
||
});
|
||
}
|
||
|
||
async function loadFeed(){
|
||
const rows = await fetchJSON('recent_events');
|
||
if(!rows||!rows.length){document.getElementById('feed').textContent='Aucun événement';return}
|
||
let html = '';
|
||
rows.forEach(r=>{
|
||
const time = new Date(r.created_at).toLocaleTimeString('fr-FR',{hour:'2-digit',minute:'2-digit',second:'2-digit'});
|
||
const type = r.event_type||'unknown';
|
||
const cls = type==='open'?'feed-open':type==='click'?'feed-click':type==='send'?'feed-send':'feed-bounce';
|
||
const label = type.toUpperCase();
|
||
html += `<div class="feed-item">
|
||
<span class="feed-time">${time}</span>
|
||
<span class="feed-type ${cls}">${label}</span>
|
||
<span class="feed-tid">${r.tracking_id||'—'}</span>
|
||
</div>`;
|
||
});
|
||
document.getElementById('feed').innerHTML = html;
|
||
}
|
||
|
||
// Initial load
|
||
loadOverview();
|
||
loadCountries();
|
||
loadFeed();
|
||
|
||
// Refresh every 30s
|
||
setInterval(loadOverview, 30000);
|
||
setInterval(loadFeed, 15000);
|
||
setInterval(loadCountries, 60000);
|
||
</script>
|
||
|
||
<!-- === OPUS UNIVERSAL DRILL-DOWN v1 19avr — append-only, doctrine #14 === -->
|
||
<script>
|
||
(function(){
|
||
if (window.__opusUniversalDrill) return; window.__opusUniversalDrill = true;
|
||
var d = document;
|
||
var m = d.createElement('div');
|
||
m.id = 'opus-udrill';
|
||
m.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.82);backdrop-filter:blur(6px);display:none;align-items:center;justify-content:center;z-index:99995;padding:20px;cursor:pointer';
|
||
var inner = d.createElement('div');
|
||
inner.id = 'opus-udrill-in';
|
||
inner.style.cssText = 'max-width:900px;width:100%;max-height:90vh;overflow:auto;background:#0b0d15;border:1px solid rgba(99,102,241,0.35);border-radius:14px;padding:28px;cursor:default;box-shadow:0 20px 60px rgba(0,0,0,0.6);color:#e2e8f0;font:14px/1.55 Inter,system-ui,sans-serif';
|
||
inner.addEventListener('click', function(e){ e.stopPropagation(); });
|
||
m.appendChild(inner);
|
||
m.addEventListener('click', function(){ m.style.display='none'; });
|
||
d.addEventListener('keydown', function(e){ if(e.key==='Escape') m.style.display='none'; });
|
||
(d.body || d.documentElement).appendChild(m);
|
||
|
||
function openCard(card) {
|
||
// Clone card content + show close btn + increase font-size
|
||
var html = '<div style="display:flex;justify-content:flex-end;margin-bottom:14px"><button id="opus-udrill-close" style="padding:6px 14px;background:#171b2a;border:1px solid rgba(99,102,241,0.25);color:#e2e8f0;border-radius:8px;cursor:pointer;font-size:12px">✕ Fermer (Esc)</button></div>';
|
||
html += '<div style="transform-origin:top left;font-size:1.05em">' + card.outerHTML + '</div>';
|
||
inner.innerHTML = html;
|
||
d.getElementById('opus-udrill-close').onclick = function(){ m.style.display='none'; };
|
||
m.style.display = 'flex';
|
||
}
|
||
|
||
function wire(root) {
|
||
var sels = '.card,[class*="card"],.kpi,[class*="kpi"],.stat,[class*="stat"],.tile,[class*="tile"],.metric,[class*="metric"],.widget,[class*="widget"]';
|
||
var cards = root.querySelectorAll(sels);
|
||
for (var i = 0; i < cards.length; i++) {
|
||
var c = cards[i];
|
||
if (c.__opusWired) continue;
|
||
if (c.closest('button, a, input, select, textarea, #opus-udrill')) continue;
|
||
var r = c.getBoundingClientRect();
|
||
if (r.width < 60 || r.height < 40) continue;
|
||
c.__opusWired = true;
|
||
c.style.cursor = 'pointer';
|
||
c.setAttribute('role','button');
|
||
c.setAttribute('tabindex','0');
|
||
c.addEventListener('click', function(ev){
|
||
// If a more-specific drill is already active (e.g. pp-card custom), let it handle
|
||
if (ev.target.closest('[data-pp-id]') && window.__opusDrillInit) return;
|
||
if (ev.target.closest('a,button,input,select')) return;
|
||
ev.preventDefault(); ev.stopPropagation();
|
||
openCard(this);
|
||
});
|
||
c.addEventListener('keydown', function(ev){ if(ev.key==='Enter'||ev.key===' '){ev.preventDefault();openCard(this);} });
|
||
}
|
||
}
|
||
|
||
// Initial + mutation observer
|
||
var initRun = function(){ wire(d.body || d.documentElement); };
|
||
if (d.readyState === 'loading') d.addEventListener('DOMContentLoaded', initRun);
|
||
else initRun();
|
||
var mo = new MutationObserver(function(muts){
|
||
var newCard = false;
|
||
for (var i=0;i<muts.length;i++) if (muts[i].addedNodes.length) { newCard = true; break; }
|
||
if (newCard) initRun();
|
||
});
|
||
mo.observe(d.body || d.documentElement, {childList:true, subtree:true});
|
||
})();
|
||
</script>
|
||
<!-- === OPUS UNIVERSAL DRILL-DOWN END === -->
|
||
|
||
</body>
|
||
</html>
|