diff --git a/api/data/changelog.json b/api/data/changelog.json
index da016f0..f1c7bb3 100644
--- a/api/data/changelog.json
+++ b/api/data/changelog.json
@@ -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",
diff --git a/index.html b/index.html
index fb935a8..45c1dcb 100644
--- a/index.html
+++ b/index.html
@@ -98,6 +98,10 @@
LONG
-2.24°
+
+ ARCS
+ 0 LIVE
+
diff --git a/js/globe.js b/js/globe.js
index d4ee1e5..79f71ba 100644
--- a/js/globe.js
+++ b/js/globe.js
@@ -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 || '/')
+ };
+ });
+ }
+
+ 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);
+ }
});
- }
- return arcs;
}
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;