feat(globe): live traffic arcs from real visitors via /api/visitor/recent-arcs + ARCS counter HUD

This commit is contained in:
jae 2026-04-20 01:42:37 +00:00
parent 2336285644
commit df53d85d01
3 changed files with 116 additions and 34 deletions

View file

@ -1,6 +1,23 @@
{
"site": "jaeswift.xyz",
"entries": [
{
"version": "1.38.0",
"date": "20/04/2026",
"category": "FEATURE",
"title": "Globe — Live Traffic Arcs from Real Visitors",
"changes": [
"Homepage 3D globe (globe.gl) now draws glowing arcs from real visitor country centroids into Manchester HQ (53.48°N, -2.24°W) instead of random fake cities",
"Data sourced live from /api/visitor/recent-arcs — tails last 20k lines of nginx access.log, GeoIPs unique IPs, maps country_code → centroid (130+ countries in api/data/country_centroids.json)",
"Frontend polls endpoint every 30s; tactical green arcs (rgba(0,255,60,.85)) fade to turquoise Solana (#14F195) tail for depth, 2.2s dash animation, 0.38 altitude auto-scale",
"Per-arc interactive labels on hover: \"COUNTRY → MCR · Xm ago · /page_path\"",
"New ARCS LIVE counter badge added to globe overlay HUD (turquoise accent), updates with each refresh",
"Sub-degree lat/lon jitter applied so multiple visitors from same country no longer stack on a single line",
"Graceful fallback: if endpoint returns empty/errors, shows 8 demo city arcs (New York/Tokyo/Paris/Sydney/Moscow/Singapore/Berlin/LA) so the globe is never bare",
"Timestamp parser handles nginx common-log format (DD/Mon/YYYY:HH:MM:SS ±ZZZZ) for accurate relative times",
"Backend endpoint cache: 5-min TTL, deduped by IP, capped at 50 most-recent unique visitors"
]
},
{
"version": "1.37.2",
"date": "20/04/2026",

View file

@ -98,6 +98,10 @@
<span class="coord-label">LONG</span>
<span class="coord-value">-2.24°</span>
</div>
<div class="globe-coord-block">
<span class="coord-label">ARCS</span>
<span class="coord-value" style="color: var(--accent-solana, #14F195);"><span id="globeArcCount">0</span> LIVE</span>
</div>
</div>
</div>
</div>

View file

@ -117,51 +117,112 @@
// Fallback: no country data, globe still works
});
// Simulated visitor arcs (random locations connecting to Manchester)
function generateRandomArcs() {
var cities = [
{ lat: 40.71, lng: -74.01 }, // New York
{ lat: 35.68, lng: 139.69 }, // Tokyo
{ lat: 48.86, lng: 2.35 }, // Paris
{ lat: -33.87, lng: 151.21 }, // Sydney
{ lat: 55.76, lng: 37.62 }, // Moscow
{ lat: 1.35, lng: 103.82 }, // Singapore
{ lat: 37.57, lng: 126.98 }, // Seoul
{ lat: 19.43, lng: -99.13 }, // Mexico City
{ lat: 52.52, lng: 13.41 }, // Berlin
{ lat: -23.55, lng: -46.63 }, // São Paulo
{ lat: 28.61, lng: 77.21 }, // Delhi
{ lat: 34.05, lng: -118.24 }, // LA
];
// ─── LIVE TRAFFIC ARCS — fetched from /api/visitor/recent-arcs ───
var currentArcs = [];
var arcs = [];
var count = 2 + Math.floor(Math.random() * 3);
for (var i = 0; i < count; i++) {
var city = cities[Math.floor(Math.random() * cities.length)];
arcs.push({
startLat: city.lat,
startLng: city.lng,
function parseNginxTs(ts) {
// '20/Apr/2026:03:35:38 +0200' → Date
if (!ts) return null;
try {
var parts = ts.match(/(\d+)\/(\w+)\/(\d+):(\d+):(\d+):(\d+)\s*([+-]\d{4})?/);
if (!parts) return new Date(ts);
var months = { Jan:0,Feb:1,Mar:2,Apr:3,May:4,Jun:5,Jul:6,Aug:7,Sep:8,Oct:9,Nov:10,Dec:11 };
return new Date(Date.UTC(
parseInt(parts[3]),
months[parts[2]] || 0,
parseInt(parts[1]),
parseInt(parts[4]),
parseInt(parts[5]),
parseInt(parts[6])
));
} catch (e) { return null; }
}
function timeAgo(ts) {
var d = parseNginxTs(ts);
if (!d) return '—';
var s = Math.floor((Date.now() - d.getTime()) / 1000);
if (s < 60) return s + 's ago';
if (s < 3600) return Math.floor(s / 60) + 'm ago';
if (s < 86400) return Math.floor(s / 3600) + 'h ago';
return Math.floor(s / 86400) + 'd ago';
}
function buildArcsFromVisitors(visitors) {
return (visitors || []).map(function (v) {
// jitter lat/lon slightly so duplicates from same country don't overlap perfectly
var jlat = (Math.random() - 0.5) * 1.2;
var jlon = (Math.random() - 0.5) * 1.2;
return {
startLat: v.lat + jlat,
startLng: v.lon + jlon,
endLat: SERVER_LAT,
endLng: SERVER_LNG,
color: ['rgba(0, 255, 60, 0.7)', 'rgba(0, 255, 60, 0.08)']
color: ['rgba(0, 255, 60, 0.85)', 'rgba(20, 241, 149, 0.15)'],
country_code: v.country_code,
country_name: v.country_name,
page_viewed: v.page_viewed,
timestamp: v.timestamp,
label: (v.country_name || v.country_code || 'Unknown') + ' → MCR · ' + timeAgo(v.timestamp) + ' · ' + (v.page_viewed || '/')
};
});
}
return arcs;
function fallbackArcs() {
var cities = [
{ lat: 40.71, lng: -74.01, name: 'New York' },
{ lat: 35.68, lng: 139.69, name: 'Tokyo' },
{ lat: 48.86, lng: 2.35, name: 'Paris' },
{ lat: -33.87, lng: 151.21, name: 'Sydney' },
{ lat: 55.76, lng: 37.62, name: 'Moscow' },
{ lat: 1.35, lng: 103.82, name: 'Singapore' },
{ lat: 52.52, lng: 13.41, name: 'Berlin' },
{ lat: 34.05, lng: -118.24, name: 'LA' }
];
return cities.map(function (c) {
return {
startLat: c.lat, startLng: c.lng,
endLat: SERVER_LAT, endLng: SERVER_LNG,
color: ['rgba(0, 255, 60, 0.55)', 'rgba(0, 255, 60, 0.05)'],
label: c.name + ' → MCR (demo)'
};
});
}
function refreshArcs() {
fetch('/api/visitor/recent-arcs', { cache: 'no-store' })
.then(function (r) { return r.ok ? r.json() : []; })
.then(function (visitors) {
if (!Array.isArray(visitors) || !visitors.length) {
currentArcs = fallbackArcs();
} else {
currentArcs = buildArcsFromVisitors(visitors.slice(0, 50));
}
globe.arcsData(currentArcs);
var countEl = document.getElementById('globeArcCount');
if (countEl) countEl.textContent = String(currentArcs.length);
})
.catch(function () {
if (!currentArcs.length) {
currentArcs = fallbackArcs();
globe.arcsData(currentArcs);
}
});
}
globe
.arcsData(generateRandomArcs())
.arcsData([])
.arcColor('color')
.arcDashLength(0.5)
.arcDashGap(0.2)
.arcDashAnimateTime(2000)
.arcStroke(0.4)
.arcAltitudeAutoScale(0.3);
.arcDashInitialGap(function () { return Math.random(); })
.arcDashAnimateTime(2200)
.arcStroke(0.45)
.arcAltitudeAutoScale(0.38)
.arcLabel('label');
// Refresh arcs periodically
setInterval(function () {
globe.arcsData(generateRandomArcs());
}, 5000);
refreshArcs();
setInterval(refreshArcs, 30000);
// Handle resize
var resizeTimer;