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;