diff --git a/Overlord-Server/bun.lock b/Overlord-Server/bun.lock
index 218ad01..ba44958 100644
--- a/Overlord-Server/bun.lock
+++ b/Overlord-Server/bun.lock
@@ -40,7 +40,6 @@
"flag-icons": "latest",
"highlight.js": "11.9.0",
"html-minifier-terser": "^7.2.0",
- "leaflet": "1.9.4",
"monaco-editor": "^0.55.1",
"postcss": "^8.5.6",
"tabulator-tables": "^6.4.0",
@@ -373,8 +372,6 @@
"lazy": ["lazy@1.0.11", "", {}, "sha512-Y+CjUfLmIpoUCCRl0ub4smrYtGGr5AOa2AKOaWelGHOGz33X/Y/KizefGqbkwfz44+cnq/+9habclf8vOmu2LA=="],
- "leaflet": ["leaflet@1.9.4", "", {}, "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA=="],
-
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
diff --git a/Overlord-Server/package.json b/Overlord-Server/package.json
index 2ed224c..7d20efb 100644
--- a/Overlord-Server/package.json
+++ b/Overlord-Server/package.json
@@ -51,7 +51,6 @@
"flag-icons": "latest",
"highlight.js": "11.9.0",
"html-minifier-terser": "^7.2.0",
- "leaflet": "1.9.4",
"monaco-editor": "^0.55.1",
"postcss": "^8.5.6",
"tabulator-tables": "^6.4.0",
diff --git a/Overlord-Server/public/assets/metrics.js b/Overlord-Server/public/assets/metrics.js
index bad738f..f86ff84 100644
--- a/Overlord-Server/public/assets/metrics.js
+++ b/Overlord-Server/public/assets/metrics.js
@@ -7,11 +7,28 @@ let memoryChart = null;
let eventLoopChart = null;
let sessionsChart = null;
let osChart = null;
-let countryMap = null;
-let countriesLayer = null;
+let countryGlobeCanvas = null;
+let countryGlobeCtx = null;
+let countryGlobeFrame = null;
+let countryGlobeResizeObserver = null;
+let countryGlobePhi = 0;
+let countryGlobeTheta = -0.18;
+let countryGlobeBaseScale = 1;
+let countryGlobeZoom = 1;
+let countryGlobeFeatures = [];
let countryCounts = {};
+let countryUnknownCount = 0;
let maxCountryCount = 0;
+let totalCountryCount = 0;
let latestByCountry = {};
+let countryGlobeIsDragging = false;
+let countryGlobeLastPointer = null;
+let countryGlobeAutoSpinPausedUntil = 0;
+let countryGlobeLastHoverPointer = null;
+let countryGlobeHoverEntry = null;
+let countryGlobeSortedFeatures = [];
+let countryGlobeLastSpinFrameAt = 0;
+let countryGlobeLastHoverFrameAt = 0;
const GEOJSON_URL = "/vendor/geo-countries/countries.geojson";
const MAX_CHART_POINTS = 240;
@@ -19,6 +36,33 @@ const METRICS_POLL_INTERVAL_MS = 5000;
const SESSION_LABELS = ["Console", "Remote Desktop", "Files", "Processes"];
const SESSION_COLORS = ["#34d399", "#c084fc", "#60a5fa", "#fb923c"];
const SESSION_EMPTY_COLOR = "rgba(100, 116, 139, 0.35)";
+const UNKNOWN_COUNTRY_CODE = "ZZ";
+const SOMALIA_LOCATION = [5.152149, 46.199616];
+const UNKNOWN_COUNTRY_LOCATION = {
+ location: SOMALIA_LOCATION,
+ name: "Unknown location (shown over Somalia)",
+};
+const GLOBE_MIN_THETA = -1.15;
+const GLOBE_MAX_THETA = 1.15;
+const GLOBE_DRAG_SENSITIVITY = 0.006;
+const GLOBE_AUTOSPIN_SPEED = 0.0035;
+const GLOBE_AUTOSPIN_RESUME_DELAY_MS = 2500;
+const GLOBE_ZOOM_MIN = 0.72;
+const GLOBE_ZOOM_MAX = 2.2;
+const GLOBE_ZOOM_STEP = 1.18;
+const GLOBE_BORDER_COLOR = "rgba(148, 163, 184, 0.42)";
+const GLOBE_HOVER_BORDER_COLOR = "rgba(248, 250, 252, 0.92)";
+const GLOBE_EMPTY_COUNTRY_FILL = "rgba(30, 41, 59, 0.58)";
+const GLOBE_SPHERE_FILL = "#07111f";
+const GLOBE_HORIZON_VISIBLE_Z = 0.025;
+const GLOBE_SIMPLIFY_TOLERANCE_DEGREES = 0.18;
+const GLOBE_MIN_RING_POINTS = 3;
+const GLOBE_AUTOSPIN_FRAME_MS = 50;
+const GLOBE_HOVER_FRAME_MS = 80;
+
+let countryLocations = {
+ [UNKNOWN_COUNTRY_CODE]: UNKNOWN_COUNTRY_LOCATION,
+};
if (typeof Chart !== "undefined") {
Chart.defaults.color = "#cbd5e1";
@@ -798,91 +842,801 @@ function getFeatureName(feature) {
);
}
-function countryFillOpacity(count) {
- if (maxCountryCount <= 0) return 0.1;
- const intensity = Math.min(count / maxCountryCount, 1);
- return 0.1 + intensity * 0.75;
+function normalizeLongitude(lon) {
+ let value = Number(lon);
+ if (!Number.isFinite(value)) return 0;
+ while (value > 180) value -= 360;
+ while (value < -180) value += 360;
+ return value;
}
-function styleCountry(feature) {
- const code = getFeatureCode(feature);
- const count = code ? countryCounts[code] || 0 : 0;
+function ringCentroid(ring) {
+ if (!Array.isArray(ring) || ring.length === 0) return null;
+ let area = 0;
+ let lonSum = 0;
+ let latSum = 0;
+ let pointCount = 0;
+ let centroidLon = 0;
+ let centroidLat = 0;
+
+ for (let i = 0; i < ring.length; i++) {
+ const current = ring[i];
+ const next = ring[(i + 1) % ring.length];
+ if (!Array.isArray(current) || !Array.isArray(next)) continue;
+ const x0 = normalizeLongitude(current[0]);
+ const y0 = Number(current[1]);
+ const x1 = normalizeLongitude(next[0]);
+ const y1 = Number(next[1]);
+ if (![x0, y0, x1, y1].every(Number.isFinite)) continue;
+
+ const cross = x0 * y1 - x1 * y0;
+ area += cross;
+ centroidLon += (x0 + x1) * cross;
+ centroidLat += (y0 + y1) * cross;
+ lonSum += x0;
+ latSum += y0;
+ pointCount += 1;
+ }
+
+ area /= 2;
+ if (Math.abs(area) > 0.000001) {
+ return {
+ area: Math.abs(area),
+ location: [centroidLat / (6 * area), normalizeLongitude(centroidLon / (6 * area))],
+ };
+ }
+
+ if (pointCount === 0) return null;
return {
- weight: 0.7,
- color: "#1e293b",
- fillColor: "#3b82f6",
- fillOpacity: countryFillOpacity(count),
+ area: 0,
+ location: [latSum / pointCount, normalizeLongitude(lonSum / pointCount)],
};
}
+function getFeatureLocation(feature) {
+ const geometry = feature?.geometry;
+ if (!geometry) return null;
+
+ const polygons =
+ geometry.type === "Polygon"
+ ? [geometry.coordinates]
+ : geometry.type === "MultiPolygon"
+ ? geometry.coordinates
+ : [];
+
+ let best = null;
+ for (const polygon of polygons) {
+ const outerRing = Array.isArray(polygon) ? polygon[0] : null;
+ const centroid = ringCentroid(outerRing);
+ if (centroid && (!best || centroid.area > best.area)) {
+ best = centroid;
+ }
+ }
+
+ return best?.location || null;
+}
+
+function squaredDistanceToSegment(point, start, end) {
+ const dx = end[0] - start[0];
+ const dy = end[1] - start[1];
+ if (dx === 0 && dy === 0) {
+ const sx = point[0] - start[0];
+ const sy = point[1] - start[1];
+ return sx * sx + sy * sy;
+ }
+ const t = clamp(((point[0] - start[0]) * dx + (point[1] - start[1]) * dy) / (dx * dx + dy * dy), 0, 1);
+ const x = start[0] + t * dx;
+ const y = start[1] + t * dy;
+ const px = point[0] - x;
+ const py = point[1] - y;
+ return px * px + py * py;
+}
+
+function simplifyRing(points, tolerance = GLOBE_SIMPLIFY_TOLERANCE_DEGREES) {
+ if (!Array.isArray(points) || points.length <= 8) return points || [];
+ const clean = points
+ .map((point) => [normalizeLongitude(point?.[0]), Number(point?.[1])])
+ .filter(([lon, lat]) => Number.isFinite(lon) && Number.isFinite(lat));
+ if (clean.length <= 8) return clean;
+
+ const closed = clean[0][0] === clean[clean.length - 1][0] && clean[0][1] === clean[clean.length - 1][1];
+ const working = closed ? clean.slice(0, -1) : clean;
+ if (working.length <= 8) return clean;
+
+ const keep = new Uint8Array(working.length);
+ const toleranceSquared = tolerance * tolerance;
+ keep[0] = 1;
+ keep[working.length - 1] = 1;
+ const stack = [[0, working.length - 1]];
+
+ while (stack.length > 0) {
+ const [startIndex, endIndex] = stack.pop();
+ let maxDistance = 0;
+ let maxIndex = -1;
+ for (let i = startIndex + 1; i < endIndex; i++) {
+ const distance = squaredDistanceToSegment(working[i], working[startIndex], working[endIndex]);
+ if (distance > maxDistance) {
+ maxDistance = distance;
+ maxIndex = i;
+ }
+ }
+ if (maxDistance > toleranceSquared && maxIndex > -1) {
+ keep[maxIndex] = 1;
+ stack.push([startIndex, maxIndex], [maxIndex, endIndex]);
+ }
+ }
+
+ const simplified = working.filter((_, index) => keep[index]);
+ if (closed && simplified.length > 0) simplified.push(simplified[0]);
+ return simplified.length >= GLOBE_MIN_RING_POINTS ? simplified : clean;
+}
+
+function vectorFromLonLat(lon, lat) {
+ const latRad = lat * Math.PI / 180;
+ const lonRad = lon * Math.PI / 180 - Math.PI;
+ const cosLat = Math.cos(latRad);
+ return {
+ lon,
+ lat,
+ x: -cosLat * Math.cos(lonRad),
+ y: Math.sin(latRad),
+ z: cosLat * Math.sin(lonRad),
+ };
+}
+
+function buildRenderableRings(feature) {
+ const geometry = feature?.geometry;
+ if (!geometry) return [];
+ const polygons =
+ geometry.type === "Polygon"
+ ? [geometry.coordinates]
+ : geometry.type === "MultiPolygon"
+ ? geometry.coordinates
+ : [];
+
+ const renderPolygons = [];
+ for (const polygon of polygons) {
+ const rings = [];
+ for (const ring of polygon) {
+ const simplified = simplifyRing(ring);
+ if (simplified.length < GLOBE_MIN_RING_POINTS) continue;
+ rings.push(simplified.map(([lon, lat]) => vectorFromLonLat(lon, lat)));
+ }
+ if (rings.length > 0) renderPolygons.push(rings);
+ }
+ return renderPolygons;
+}
+
+function interpolateColor(a, b, t) {
+ return a.map((value, index) => value + (b[index] - value) * t);
+}
+
+function heatColor(intensity) {
+ const cool = [0.22, 0.82, 1];
+ const warm = [0.28, 0.95, 0.62];
+ const amber = [1, 0.72, 0.18];
+ const hot = [1, 0.08, 0.08];
+ if (intensity < 0.45) {
+ return interpolateColor(cool, warm, intensity / 0.45);
+ }
+ if (intensity < 0.8) {
+ return interpolateColor(warm, amber, (intensity - 0.45) / 0.35);
+ }
+ return interpolateColor(amber, hot, (intensity - 0.8) / 0.2);
+}
+
+function colorString(color, alpha = 1) {
+ return `rgba(${Math.round(color[0] * 255)}, ${Math.round(color[1] * 255)}, ${Math.round(color[2] * 255)}, ${alpha})`;
+}
+
+function countryHeatIntensity(count) {
+ if (totalCountryCount <= 0) return 0;
+ const share = Math.min(Math.max(count / totalCountryCount, 0), 1);
+ return Math.sqrt(share);
+}
+
+function clamp(value, min, max) {
+ return Math.min(max, Math.max(min, value));
+}
+
+function updateCountryGlobeTooltip() {
+ const globeEl = document.getElementById("country-globe");
+ if (!globeEl) return;
+ const topCountries = Object.entries(countryCounts)
+ .sort((a, b) => b[1] - a[1])
+ .slice(0, 5)
+ .map(([code, count]) => {
+ const name = countryLocations[code]?.name || code;
+ return `${name} (${code}): ${count}`;
+ });
+
+ globeEl.title = topCountries.length
+ ? topCountries.join("\n")
+ : "No client geography data yet";
+}
+
function updateCountryMap(byCountry) {
latestByCountry = byCountry || {};
countryCounts = {};
+ countryUnknownCount = 0;
for (const [code, count] of Object.entries(latestByCountry || {})) {
const cc = normalizeCountry(code);
- if (!cc || cc === "ZZ") continue;
- countryCounts[cc] = Number(count) || 0;
+ if (!cc) continue;
+ const value = Math.max(0, Number(count) || 0);
+ const targetCode = cc === UNKNOWN_COUNTRY_CODE ? "SO" : cc;
+ if (cc === UNKNOWN_COUNTRY_CODE) countryUnknownCount += value;
+ countryCounts[targetCode] = (countryCounts[targetCode] || 0) + value;
}
maxCountryCount = Math.max(0, ...Object.values(countryCounts));
-
- if (!countriesLayer) return;
-
- countriesLayer.setStyle(styleCountry);
- countriesLayer.eachLayer((layer) => {
- const feature = layer.feature;
- const code = getFeatureCode(feature) || "--";
- const name = getFeatureName(feature);
- const count = code ? countryCounts[code] || 0 : 0;
- layer.bindTooltip(`${escapeHtml(name)} (${code}): ${count}`, {
- sticky: true,
- direction: "auto",
- });
+ totalCountryCount = Object.values(countryCounts).reduce((sum, count) => sum + count, 0);
+ countryGlobeSortedFeatures = [...countryGlobeFeatures].sort((a, b) => {
+ const aCount = countryCounts[a.code] || 0;
+ const bCount = countryCounts[b.code] || 0;
+ return aCount - bCount;
});
+
+ updateCountryGlobeTooltip();
+ drawCountryGlobe();
+ updateCountryGlobeHoverTooltip();
}
-async function initCountryMap() {
- const mapEl = document.getElementById("country-map");
- if (!mapEl || typeof L === "undefined") return;
-
- countryMap = L.map(mapEl, {
- zoomControl: false,
- attributionControl: false,
- minZoom: 1,
- maxZoom: 5,
- worldCopyJump: true,
- }).setView([20, 0], 2);
-
- L.tileLayer(
- "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png",
- {
- maxZoom: 5,
- minZoom: 1,
- },
- ).addTo(countryMap);
-
+async function loadCountryLocations() {
try {
const res = await fetch(GEOJSON_URL);
if (!res.ok) throw new Error("GeoJSON fetch failed");
const geojson = await res.json();
+ const features = Array.isArray(geojson.features) ? geojson.features : [];
+ const locations = {};
- countriesLayer = L.geoJSON(geojson, {
- style: styleCountry,
- }).addTo(countryMap);
-
- updateCountryMap(latestByCountry);
-
- if (countriesLayer.getBounds) {
- countryMap.fitBounds(countriesLayer.getBounds(), {
- padding: [10, 10],
- });
+ for (const feature of features) {
+ const code = getFeatureCode(feature);
+ const location = getFeatureLocation(feature);
+ if (!code || !location) continue;
+ locations[code] = {
+ location,
+ name: getFeatureName(feature),
+ };
}
+
+ countryGlobeFeatures = features
+ .map((feature) => {
+ const code = getFeatureCode(feature);
+ if (!code) return null;
+ const renderPolygons = buildRenderableRings(feature);
+ if (renderPolygons.length === 0) return null;
+ return {
+ feature,
+ code,
+ name: getFeatureName(feature),
+ location: locations[code]?.location || getFeatureLocation(feature),
+ renderPolygons,
+ };
+ })
+ .filter((entry) => entry && entry.location);
+ countryGlobeSortedFeatures = [...countryGlobeFeatures];
+
+ const somaliaLocation = locations.SO?.location || SOMALIA_LOCATION;
+ countryLocations = {
+ ...locations,
+ [UNKNOWN_COUNTRY_CODE]: {
+ ...UNKNOWN_COUNTRY_LOCATION,
+ location: somaliaLocation,
+ },
+ };
+ updateCountryMap(latestByCountry);
} catch (err) {
- console.error("Failed to load country map:", err);
- mapEl.innerHTML =
- '
Failed to load map data.
';
+ console.error("Failed to load country geography:", err);
}
}
+function getGlobeSize() {
+ const rect = countryGlobeCanvas?.getBoundingClientRect();
+ const width = Math.max(320, Math.round(rect?.width || 720));
+ const height = Math.max(320, Math.round(rect?.height || 384));
+ return { width, height };
+}
+
+function getBaseGlobeScale(width, height) {
+ return Math.min(1.08, width / Math.max(height, 1) > 1.6 ? 1.04 : 0.96);
+}
+
+function getGlobeScale() {
+ return countryGlobeBaseScale * countryGlobeZoom;
+}
+
+function updateGlobeScale() {
+ drawCountryGlobe();
+}
+
+function updateGlobeSize() {
+ if (!countryGlobeCtx || !countryGlobeCanvas) return;
+ const { width, height } = getGlobeSize();
+ const devicePixelRatio = Math.min(window.devicePixelRatio || 1, 2);
+ countryGlobeCanvas.width = Math.round(width * devicePixelRatio);
+ countryGlobeCanvas.height = Math.round(height * devicePixelRatio);
+ countryGlobeCtx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0);
+ countryGlobeBaseScale = getBaseGlobeScale(width, height);
+ drawCountryGlobe();
+ updateCountryGlobeHoverTooltip();
+}
+
+function updateGlobeRotation() {
+ drawCountryGlobe();
+}
+
+function pauseCountryGlobeAutoSpin() {
+ countryGlobeAutoSpinPausedUntil = performance.now() + GLOBE_AUTOSPIN_RESUME_DELAY_MS;
+}
+
+function setCountryGlobeZoom(nextZoom) {
+ countryGlobeZoom = clamp(nextZoom, GLOBE_ZOOM_MIN, GLOBE_ZOOM_MAX);
+ updateGlobeScale();
+ pauseCountryGlobeAutoSpin();
+ updateCountryGlobeHoverTooltip();
+}
+
+function globeVector(location) {
+ const lat = location[0] * Math.PI / 180;
+ const lon = location[1] * Math.PI / 180 - Math.PI;
+ const cosLat = Math.cos(lat);
+ return [
+ -cosLat * Math.cos(lon),
+ Math.sin(lat),
+ cosLat * Math.sin(lon),
+ ];
+}
+
+function rotateGlobeVector(vector) {
+ const [x, y, z] = vector;
+ const cosTheta = Math.cos(countryGlobeTheta);
+ const sinTheta = Math.sin(countryGlobeTheta);
+ const cosPhi = Math.cos(countryGlobePhi);
+ const sinPhi = Math.sin(countryGlobePhi);
+ return {
+ x: cosPhi * x + sinPhi * z,
+ y: sinPhi * sinTheta * x + cosTheta * y - cosPhi * sinTheta * z,
+ z: -sinPhi * cosTheta * x + sinTheta * y + cosPhi * cosTheta * z,
+ };
+}
+
+function inverseRotateGlobeVector(vector) {
+ const { x, y, z } = vector;
+ const cosTheta = Math.cos(countryGlobeTheta);
+ const sinTheta = Math.sin(countryGlobeTheta);
+ const cosPhi = Math.cos(countryGlobePhi);
+ const sinPhi = Math.sin(countryGlobePhi);
+ return {
+ x: cosPhi * x + sinPhi * sinTheta * y - sinPhi * cosTheta * z,
+ y: cosTheta * y + sinTheta * z,
+ z: sinPhi * x - cosPhi * sinTheta * y + cosPhi * cosTheta * z,
+ };
+}
+
+function getGlobeFrame() {
+ const { width, height } = getGlobeSize();
+ return {
+ width,
+ height,
+ centerX: width / 2,
+ centerY: height / 2,
+ radius: Math.min(width, height) * 0.43 * getGlobeScale(),
+ };
+}
+
+function getGlobeRenderState() {
+ return {
+ frame: getGlobeFrame(),
+ cosTheta: Math.cos(countryGlobeTheta),
+ sinTheta: Math.sin(countryGlobeTheta),
+ cosPhi: Math.cos(countryGlobePhi),
+ sinPhi: Math.sin(countryGlobePhi),
+ };
+}
+
+function projectGlobeLocation(location) {
+ if (!countryGlobeCanvas) return null;
+ const frame = getGlobeFrame();
+ const projected = rotateGlobeVector(globeVector(location));
+
+ return {
+ visible: projected.z >= GLOBE_HORIZON_VISIBLE_Z,
+ x: frame.centerX + projected.x * frame.radius,
+ y: frame.centerY - projected.y * frame.radius,
+ z: projected.z,
+ };
+}
+
+function projectRenderablePoint(point, state) {
+ const projectedX = state.cosPhi * point.x + state.sinPhi * point.z;
+ const projectedY = state.sinPhi * state.sinTheta * point.x +
+ state.cosTheta * point.y -
+ state.cosPhi * state.sinTheta * point.z;
+ const projectedZ = -state.sinPhi * state.cosTheta * point.x +
+ state.sinTheta * point.y +
+ state.cosPhi * state.cosTheta * point.z;
+ return {
+ visible: projectedZ >= GLOBE_HORIZON_VISIBLE_Z,
+ x: state.frame.centerX + projectedX * state.frame.radius,
+ y: state.frame.centerY - projectedY * state.frame.radius,
+ z: projectedZ,
+ };
+}
+
+function interpolateProjectedPoint(a, b, threshold = GLOBE_HORIZON_VISIBLE_Z) {
+ const denominator = b.z - a.z;
+ const t = denominator === 0 ? 0 : clamp((threshold - a.z) / denominator, 0, 1);
+ return {
+ visible: true,
+ x: a.x + (b.x - a.x) * t,
+ y: a.y + (b.y - a.y) * t,
+ z: threshold,
+ };
+}
+
+function clipProjectedRingToHorizon(projectedRing) {
+ if (projectedRing.length < GLOBE_MIN_RING_POINTS) return [];
+ const clipped = [];
+
+ for (let i = 0; i < projectedRing.length; i++) {
+ const current = projectedRing[i];
+ const next = projectedRing[(i + 1) % projectedRing.length];
+ const currentVisible = current.z >= GLOBE_HORIZON_VISIBLE_Z;
+ const nextVisible = next.z >= GLOBE_HORIZON_VISIBLE_Z;
+
+ if (currentVisible) clipped.push(current);
+ if (currentVisible !== nextVisible) {
+ clipped.push(interpolateProjectedPoint(current, next));
+ }
+ }
+
+ return clipped;
+}
+
+function drawCountryGeometry(entry, fillStyle, strokeStyle, lineWidth, state) {
+ const ctx = countryGlobeCtx;
+ if (!ctx || !entry.renderPolygons?.length) return false;
+ let drewFill = false;
+ let drewStroke = false;
+
+ ctx.fillStyle = fillStyle;
+ ctx.beginPath();
+ for (const polygon of entry.renderPolygons) {
+ for (const ring of polygon) {
+ const projectedRing = ring.map((point) => projectRenderablePoint(point, state));
+ const clippedRing = clipProjectedRingToHorizon(projectedRing);
+ if (clippedRing.length < GLOBE_MIN_RING_POINTS) continue;
+ ctx.moveTo(clippedRing[0].x, clippedRing[0].y);
+ for (let i = 1; i < clippedRing.length; i++) {
+ ctx.lineTo(clippedRing[i].x, clippedRing[i].y);
+ }
+ ctx.closePath();
+ drewFill = true;
+ }
+ }
+ if (drewFill) {
+ ctx.fill("evenodd");
+ }
+
+ ctx.strokeStyle = strokeStyle;
+ ctx.lineWidth = lineWidth;
+ ctx.beginPath();
+ for (const polygon of entry.renderPolygons) {
+ for (const ring of polygon) {
+ let started = false;
+ let visiblePoints = 0;
+ let allVisible = true;
+ for (const point of ring) {
+ const projected = projectRenderablePoint(point, state);
+ if (!projected.visible) {
+ allVisible = false;
+ started = false;
+ continue;
+ }
+ if (!started) {
+ ctx.moveTo(projected.x, projected.y);
+ started = true;
+ } else {
+ ctx.lineTo(projected.x, projected.y);
+ }
+ visiblePoints += 1;
+ }
+ if (started && allVisible) ctx.closePath();
+ if (visiblePoints >= 2) drewStroke = true;
+ }
+ }
+ if (drewStroke) {
+ ctx.stroke();
+ }
+
+ return drewFill || drewStroke;
+}
+
+function getCountryFill(code, isHover = false) {
+ const count = countryCounts[code] || 0;
+ if (count <= 0) return isHover ? "rgba(51, 65, 85, 0.78)" : GLOBE_EMPTY_COUNTRY_FILL;
+ const intensity = countryHeatIntensity(count);
+ return colorString(heatColor(intensity), isHover ? 0.98 : 0.82);
+}
+
+function drawCountryGlobe() {
+ const ctx = countryGlobeCtx;
+ if (!ctx || !countryGlobeCanvas) return;
+
+ const state = getGlobeRenderState();
+ const { frame } = state;
+ ctx.clearRect(0, 0, frame.width, frame.height);
+
+ const glow = ctx.createRadialGradient(
+ frame.centerX,
+ frame.centerY,
+ frame.radius * 0.2,
+ frame.centerX,
+ frame.centerY,
+ frame.radius * 1.22,
+ );
+ glow.addColorStop(0, "rgba(14, 165, 233, 0.16)");
+ glow.addColorStop(0.72, "rgba(20, 184, 166, 0.08)");
+ glow.addColorStop(1, "rgba(15, 23, 42, 0)");
+ ctx.fillStyle = glow;
+ ctx.beginPath();
+ ctx.arc(frame.centerX, frame.centerY, frame.radius * 1.2, 0, Math.PI * 2);
+ ctx.fill();
+
+ const ocean = ctx.createRadialGradient(
+ frame.centerX - frame.radius * 0.28,
+ frame.centerY - frame.radius * 0.32,
+ frame.radius * 0.12,
+ frame.centerX,
+ frame.centerY,
+ frame.radius,
+ );
+ ocean.addColorStop(0, "rgba(15, 118, 148, 0.35)");
+ ocean.addColorStop(0.5, GLOBE_SPHERE_FILL);
+ ocean.addColorStop(1, "#020617");
+
+ ctx.save();
+ ctx.beginPath();
+ ctx.arc(frame.centerX, frame.centerY, frame.radius, 0, Math.PI * 2);
+ ctx.fillStyle = ocean;
+ ctx.fill();
+ ctx.clip();
+
+ for (const entry of countryGlobeSortedFeatures) {
+ const isHover = countryGlobeHoverEntry === entry;
+ drawCountryGeometry(
+ entry,
+ getCountryFill(entry.code, isHover),
+ isHover ? GLOBE_HOVER_BORDER_COLOR : GLOBE_BORDER_COLOR,
+ isHover ? 1.5 : 0.65,
+ state,
+ );
+ }
+
+ ctx.restore();
+ ctx.beginPath();
+ ctx.arc(frame.centerX, frame.centerY, frame.radius, 0, Math.PI * 2);
+ ctx.strokeStyle = "rgba(125, 211, 252, 0.34)";
+ ctx.lineWidth = 1.2;
+ ctx.stroke();
+}
+
+function unprojectGlobePointer(pointer) {
+ if (!countryGlobeCanvas || !pointer) return null;
+ const frame = getGlobeFrame();
+ const rect = countryGlobeCanvas.getBoundingClientRect();
+ const x = (pointer.clientX - rect.left - frame.centerX) / frame.radius;
+ const y = -(pointer.clientY - rect.top - frame.centerY) / frame.radius;
+ const distanceSquared = x * x + y * y;
+ if (distanceSquared > 1) return null;
+ const z = Math.sqrt(1 - distanceSquared);
+ const world = inverseRotateGlobeVector({ x, y, z });
+ const lat = Math.asin(clamp(world.y, -1, 1)) * 180 / Math.PI;
+ const lon = normalizeLongitude((Math.atan2(world.z, -world.x) + Math.PI) * 180 / Math.PI);
+ return { lat, lon };
+}
+
+function ringContainsLonLat(ring, lon, lat) {
+ let inside = false;
+ for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
+ const xi = normalizeLongitude(ring[i].lon ?? ring[i][0]);
+ const yi = Number(ring[i].lat ?? ring[i][1]);
+ const xj = normalizeLongitude(ring[j].lon ?? ring[j][0]);
+ const yj = Number(ring[j].lat ?? ring[j][1]);
+ if (![xi, yi, xj, yj].every(Number.isFinite)) continue;
+ const intersects = ((yi > lat) !== (yj > lat)) &&
+ (lon < (xj - xi) * (lat - yi) / ((yj - yi) || 1e-9) + xi);
+ if (intersects) inside = !inside;
+ }
+ return inside;
+}
+
+function featureContainsLonLat(entry, lon, lat) {
+ for (const polygon of entry.renderPolygons || []) {
+ if (!Array.isArray(polygon) || polygon.length === 0) continue;
+ if (!ringContainsLonLat(polygon[0], lon, lat)) continue;
+ const inHole = polygon.slice(1).some((ring) => ringContainsLonLat(ring, lon, lat));
+ if (!inHole) return true;
+ }
+ return false;
+}
+
+function findCountryAtPointer(pointer) {
+ const location = unprojectGlobePointer(pointer);
+ if (!location) return null;
+ for (const entry of countryGlobeFeatures) {
+ if (featureContainsLonLat(entry, location.lon, location.lat)) {
+ return entry;
+ }
+ }
+ return null;
+}
+
+function formatPercent(value) {
+ if (!Number.isFinite(value) || value <= 0) return "0%";
+ if (value < 0.001) return "<0.1%";
+ return `${(value * 100).toFixed(value < 0.01 ? 1 : 0)}%`;
+}
+
+function hideCountryGlobeTooltip() {
+ const tooltip = document.getElementById("country-globe-tooltip");
+ if (!tooltip) return;
+ tooltip.classList.add("hidden");
+}
+
+function updateCountryGlobeHoverTooltip(pointer = countryGlobeLastHoverPointer) {
+ const globeEl = document.getElementById("country-globe");
+ const tooltip = document.getElementById("country-globe-tooltip");
+ if (!globeEl || !tooltip || !countryGlobeCanvas || !pointer || countryGlobeIsDragging) {
+ countryGlobeHoverEntry = null;
+ hideCountryGlobeTooltip();
+ return;
+ }
+
+ const globeRect = globeEl.getBoundingClientRect();
+ const canvasRect = countryGlobeCanvas.getBoundingClientRect();
+ const entry = findCountryAtPointer(pointer);
+ if (!entry) {
+ const hadHover = Boolean(countryGlobeHoverEntry);
+ countryGlobeHoverEntry = null;
+ hideCountryGlobeTooltip();
+ if (hadHover) drawCountryGlobe();
+ return;
+ }
+ const hoverChanged = countryGlobeHoverEntry !== entry;
+ countryGlobeHoverEntry = entry;
+
+ const count = countryCounts[entry.code] || 0;
+ const share = totalCountryCount > 0 ? count / totalCountryCount : 0;
+ const unknownLine = entry.code === "SO" && countryUnknownCount > 0
+ ? `${countryUnknownCount.toLocaleString()} unknown-location client${countryUnknownCount === 1 ? "" : "s"} included
`
+ : "";
+ tooltip.innerHTML = `
+ ${escapeHtml(entry.name)}
+ ${escapeHtml(entry.code)} · ${count.toLocaleString()} client${count === 1 ? "" : "s"}
+ ${formatPercent(share)} of plotted clients
+ ${unknownLine}
+ `;
+ const projected = projectGlobeLocation(entry.location);
+ tooltip.style.left = `${canvasRect.left - globeRect.left + (projected?.x || 0)}px`;
+ tooltip.style.top = `${canvasRect.top - globeRect.top + (projected?.y || 0)}px`;
+ tooltip.classList.remove("hidden");
+ if (hoverChanged) drawCountryGlobe();
+}
+
+function bindCountryGlobeControls() {
+ document.getElementById("country-globe-zoom-in")?.addEventListener("click", () => {
+ setCountryGlobeZoom(countryGlobeZoom * GLOBE_ZOOM_STEP);
+ });
+ document.getElementById("country-globe-zoom-out")?.addEventListener("click", () => {
+ setCountryGlobeZoom(countryGlobeZoom / GLOBE_ZOOM_STEP);
+ });
+ document.getElementById("country-globe-zoom-reset")?.addEventListener("click", () => {
+ setCountryGlobeZoom(1);
+ });
+}
+
+function bindCountryGlobeDrag() {
+ if (!countryGlobeCanvas) return;
+
+ countryGlobeCanvas.addEventListener("pointerdown", (event) => {
+ countryGlobeIsDragging = true;
+ countryGlobeLastPointer = { x: event.clientX, y: event.clientY };
+ countryGlobeAutoSpinPausedUntil = Number.POSITIVE_INFINITY;
+ countryGlobeCanvas.classList.add("is-dragging");
+ countryGlobeHoverEntry = null;
+ hideCountryGlobeTooltip();
+ countryGlobeCanvas.setPointerCapture?.(event.pointerId);
+ event.preventDefault();
+ });
+
+ countryGlobeCanvas.addEventListener("pointermove", (event) => {
+ countryGlobeLastHoverPointer = event;
+ if (!countryGlobeIsDragging || !countryGlobeLastPointer) {
+ const now = performance.now();
+ if (now - countryGlobeLastHoverFrameAt >= GLOBE_HOVER_FRAME_MS) {
+ countryGlobeLastHoverFrameAt = now;
+ updateCountryGlobeHoverTooltip(event);
+ }
+ return;
+ }
+ const dx = event.clientX - countryGlobeLastPointer.x;
+ const dy = event.clientY - countryGlobeLastPointer.y;
+ countryGlobeLastPointer = { x: event.clientX, y: event.clientY };
+ countryGlobePhi += dx * GLOBE_DRAG_SENSITIVITY;
+ countryGlobeTheta = clamp(
+ countryGlobeTheta + dy * GLOBE_DRAG_SENSITIVITY,
+ GLOBE_MIN_THETA,
+ GLOBE_MAX_THETA,
+ );
+ updateGlobeRotation();
+ event.preventDefault();
+ });
+
+ const endDrag = (event) => {
+ if (!countryGlobeIsDragging) return;
+ countryGlobeIsDragging = false;
+ countryGlobeLastPointer = null;
+ countryGlobeAutoSpinPausedUntil = performance.now() + GLOBE_AUTOSPIN_RESUME_DELAY_MS;
+ countryGlobeCanvas.classList.remove("is-dragging");
+ if (countryGlobeCanvas.hasPointerCapture?.(event.pointerId)) {
+ countryGlobeCanvas.releasePointerCapture(event.pointerId);
+ }
+ };
+
+ countryGlobeCanvas.addEventListener("pointerup", endDrag);
+ countryGlobeCanvas.addEventListener("pointercancel", endDrag);
+ countryGlobeCanvas.addEventListener("lostpointercapture", endDrag);
+ countryGlobeCanvas.addEventListener("pointerleave", () => {
+ countryGlobeLastHoverPointer = null;
+ countryGlobeHoverEntry = null;
+ hideCountryGlobeTooltip();
+ drawCountryGlobe();
+ });
+ countryGlobeCanvas.addEventListener(
+ "wheel",
+ (event) => {
+ event.preventDefault();
+ const direction = event.deltaY < 0 ? GLOBE_ZOOM_STEP : 1 / GLOBE_ZOOM_STEP;
+ setCountryGlobeZoom(countryGlobeZoom * direction);
+ },
+ { passive: false },
+ );
+}
+
+function animateCountryGlobe(timestamp = performance.now()) {
+ if (!countryGlobeCtx) return;
+ if (document.hidden) {
+ countryGlobeFrame = requestAnimationFrame(animateCountryGlobe);
+ return;
+ }
+ const shouldSpin = !countryGlobeIsDragging && timestamp >= countryGlobeAutoSpinPausedUntil;
+ if (shouldSpin && timestamp - countryGlobeLastSpinFrameAt >= GLOBE_AUTOSPIN_FRAME_MS) {
+ countryGlobePhi += GLOBE_AUTOSPIN_SPEED;
+ countryGlobeLastSpinFrameAt = timestamp;
+ updateGlobeRotation();
+ }
+ if (countryGlobeLastHoverPointer && timestamp - countryGlobeLastHoverFrameAt >= GLOBE_HOVER_FRAME_MS) {
+ countryGlobeLastHoverFrameAt = timestamp;
+ updateCountryGlobeHoverTooltip();
+ }
+ countryGlobeFrame = requestAnimationFrame(animateCountryGlobe);
+}
+
+async function initCountryMap() {
+ countryGlobeCanvas = document.getElementById("country-globe-canvas");
+ if (!countryGlobeCanvas) return;
+ countryGlobeCtx = countryGlobeCanvas.getContext("2d");
+ updateGlobeSize();
+
+ countryGlobeResizeObserver = new ResizeObserver(updateGlobeSize);
+ countryGlobeResizeObserver.observe(countryGlobeCanvas);
+ bindCountryGlobeControls();
+ bindCountryGlobeDrag();
+ countryGlobeFrame = requestAnimationFrame(animateCountryGlobe);
+
+ await loadCountryLocations();
+}
+
function updateCharts(history, snapshot) {
const points = Array.isArray(history) && history.length > 0
? history.slice(-MAX_CHART_POINTS)
diff --git a/Overlord-Server/public/metrics.html b/Overlord-Server/public/metrics.html
index 1fc48f1..8c2d1f0 100644
--- a/Overlord-Server/public/metrics.html
+++ b/Overlord-Server/public/metrics.html
@@ -12,7 +12,6 @@
-
@@ -21,18 +20,60 @@
position: relative;
z-index: 50;
}
- #country-map,
- .leaflet-container,
- .leaflet-pane,
- .leaflet-map-pane,
- .leaflet-tile-pane,
- .leaflet-overlay-pane,
- .leaflet-shadow-pane,
- .leaflet-marker-pane {
+ #country-globe {
+ position: relative;
z-index: 0;
}
- .leaflet-control {
- z-index: 2;
+ #country-globe-canvas {
+ cursor: grab;
+ touch-action: none;
+ user-select: none;
+ }
+ #country-globe-canvas.is-dragging {
+ cursor: grabbing;
+ }
+ .country-globe-controls {
+ position: absolute;
+ right: 1rem;
+ top: 1rem;
+ display: flex;
+ gap: 0.375rem;
+ z-index: 3;
+ }
+ .country-globe-control {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 2rem;
+ height: 2rem;
+ border-radius: 0.375rem;
+ border: 1px solid rgba(51, 65, 85, 0.95);
+ background: rgba(15, 23, 42, 0.82);
+ color: #cbd5e1;
+ }
+ .country-globe-control:hover {
+ color: #f8fafc;
+ border-color: rgba(20, 184, 166, 0.72);
+ background: rgba(15, 23, 42, 0.94);
+ }
+ #country-globe-tooltip {
+ position: absolute;
+ z-index: 4;
+ min-width: 9rem;
+ max-width: 14rem;
+ padding: 0.5rem 0.625rem;
+ border: 1px solid rgba(51, 65, 85, 0.95);
+ border-radius: 0.375rem;
+ background: rgba(15, 23, 42, 0.94);
+ color: #e2e8f0;
+ font-size: 0.75rem;
+ line-height: 1.25;
+ pointer-events: none;
+ box-shadow: 0 12px 30px rgba(2, 6, 23, 0.35);
+ transform: translate(-50%, calc(-100% - 0.75rem));
+ }
+ #country-globe-tooltip.hidden {
+ display: none;
}
@@ -340,17 +381,42 @@
-
+
- Client Geography Map
+ Client Geography Globe
-
Darker = more clients
+
Warmer color = higher client concentration
-
-
+
+
+
+
+
+
+
+
+
+ Low
+
+ High
+
Data is based on IP geo lookup and client-reported country.
@@ -376,7 +442,6 @@
-