new globe metrics

This commit is contained in:
KingKDot
2026-06-06 20:31:24 -05:00
parent de67516285
commit bbb0d4b154
5 changed files with 897 additions and 92 deletions
+815 -61
View File
@@ -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 =
'<div class="text-slate-400 text-sm p-4">Failed to load map data.</div>';
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
? `<div class="text-slate-500">${countryUnknownCount.toLocaleString()} unknown-location client${countryUnknownCount === 1 ? "" : "s"} included</div>`
: "";
tooltip.innerHTML = `
<div class="font-semibold">${escapeHtml(entry.name)}</div>
<div class="text-slate-400">${escapeHtml(entry.code)} · ${count.toLocaleString()} client${count === 1 ? "" : "s"}</div>
<div class="text-slate-500">${formatPercent(share)} of plotted clients</div>
${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)