From bbb0d4b154c1250ca5a63df2108396dd57e3818d Mon Sep 17 00:00:00 2001 From: KingKDot Date: Sat, 6 Jun 2026 20:31:24 -0500 Subject: [PATCH] new globe metrics --- Overlord-Server/bun.lock | 3 - Overlord-Server/package.json | 1 - Overlord-Server/public/assets/metrics.js | 876 +++++++++++++++++++++-- Overlord-Server/public/metrics.html | 99 ++- Overlord-Server/scripts/vendor.ts | 10 - 5 files changed, 897 insertions(+), 92 deletions(-) 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 @@ - diff --git a/Overlord-Server/scripts/vendor.ts b/Overlord-Server/scripts/vendor.ts index 90c6722..a761516 100644 --- a/Overlord-Server/scripts/vendor.ts +++ b/Overlord-Server/scripts/vendor.ts @@ -156,16 +156,6 @@ copyFile( path.join(VENDOR, "chart.js", "chart.umd.js"), ); -/* ── Leaflet ─────────────────────────────────────────────────────── */ - -console.log("Copying Leaflet ..."); -const leafRoot = path.join(NM, "leaflet", "dist"); -copyFile(path.join(leafRoot, "leaflet.js"), path.join(VENDOR, "leaflet", "leaflet.js")); -copyFile(path.join(leafRoot, "leaflet.css"), path.join(VENDOR, "leaflet", "leaflet.css")); -if (existsSync(path.join(leafRoot, "images"))) { - copyDir(path.join(leafRoot, "images"), path.join(VENDOR, "leaflet", "images")); -} - /* ── Monaco Editor ───────────────────────────────────────────────── */ console.log("Copying Monaco Editor ...");