feat: center globe on viewer'S location (#1715)

This commit is contained in:
Konsti Wohlwend 2026-07-08 10:33:30 -07:00 committed by GitHub
parent f38fde7d05
commit 165d9a0786
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 108 additions and 5 deletions

View File

@ -1,5 +1,6 @@
'use client';
import { useViewerLocation } from '@/hooks/use-viewer-location';
import { hexclaveAppInternalsSymbol } from "@/lib/hexclave-app-internals";
import { cn } from "@/lib/utils";
import { captureError } from "@hexclave/shared/dist/utils/errors";
@ -33,6 +34,7 @@ function GlobeErrorComponent(props: { error: Error }) {
function GlobeSectionWithMetrics({ includeAnonymous, interactive }: { includeAnonymous: boolean, interactive?: boolean }) {
const adminApp = useAdminApp();
const data = (adminApp as any)[hexclaveAppInternalsSymbol].useMetrics(includeAnonymous);
const viewerLocation = useViewerLocation();
return (
<>
@ -42,6 +44,7 @@ function GlobeSectionWithMetrics({ includeAnonymous, interactive }: { includeAno
totalUsers={data.total_users}
activeUsersByCountry={data.active_users_by_country ?? {}}
interactive={interactive}
initialPointOfView={viewerLocation}
/>
</>
);

View File

@ -1,3 +1,4 @@
import { US_CENTER } from '@/hooks/use-viewer-location';
import { useWaitForIdle } from '@/hooks/use-wait-for-idle';
import { useDashboardUser } from '@/lib/dashboard-user';
import { useThemeWatcher } from '@/lib/theme';
@ -359,7 +360,7 @@ type SatelliteHandle = {
lastCountryCheckAt: number,
};
export function GlobeSection({ countryData, totalUsers, activeUsersByCountry, satelliteCount, interactive, children }: {countryData: Record<string, number>, totalUsers: number, activeUsersByCountry?: Record<string, MetricsRecentUser[]>, satelliteCount?: number, interactive?: boolean, children?: React.ReactNode}) {
export function GlobeSection({ countryData, totalUsers, activeUsersByCountry, satelliteCount, interactive, initialPointOfView, children }: {countryData: Record<string, number>, totalUsers: number, activeUsersByCountry?: Record<string, MetricsRecentUser[]>, satelliteCount?: number, interactive?: boolean, initialPointOfView?: { lat: number, lng: number }, children?: React.ReactNode}) {
const hasWaitedForIdle = useWaitForIdle(1000, 5000);
if (!hasWaitedForIdle) {
return <GlobeLoading devReason="waiting for cpu" />;
@ -372,6 +373,7 @@ export function GlobeSection({ countryData, totalUsers, activeUsersByCountry, sa
activeUsersByCountry={activeUsersByCountry ?? {}}
satelliteCount={satelliteCount ?? 2}
interactive={interactive ?? false}
initialPointOfView={initialPointOfView}
/>
</Suspense>
);
@ -466,7 +468,7 @@ function GlobeLoading(props: { devReason: string, className?: string }) {
);
}
function GlobeSectionInner({ countryData, totalUsers, activeUsersByCountry, satelliteCount, interactive, children }: {countryData: Record<string, number>, totalUsers: number, activeUsersByCountry: Record<string, MetricsRecentUser[]>, satelliteCount: number, interactive: boolean, children?: React.ReactNode}) {
function GlobeSectionInner({ countryData, totalUsers, activeUsersByCountry, satelliteCount, interactive, initialPointOfView, children }: {countryData: Record<string, number>, totalUsers: number, activeUsersByCountry: Record<string, MetricsRecentUser[]>, satelliteCount: number, interactive: boolean, initialPointOfView?: { lat: number, lng: number }, children?: React.ReactNode}) {
const countries = use(countriesPromise);
const projectId = useProjectId();
const globeRef = useRef<GlobeMethods | undefined>(undefined);
@ -677,7 +679,6 @@ function GlobeSectionInner({ countryData, totalUsers, activeUsersByCountry, sate
resumeRender();
}, [cameraDistance, shouldShowGlobe, squareSize, interactive]);
const totalUsersInCountries = Object.values(countryData).reduce((acc, curr) => acc + curr, 0);
const totalPopulationInCountries = countries.features.reduce((acc, curr) => acc + curr.properties.POP_EST, 0);
const oneSided95PercentZScore = 1.645;
@ -1156,8 +1157,7 @@ function GlobeSectionInner({ countryData, totalUsers, activeUsersByCountry, sate
controls.enableZoom = interactive;
controls.enableRotate = true;
current.camera().position.z = cameraDistance;
// Little Saint James Island, U.S. Virgin Islands
current.pointOfView({ lat: 18.3076, lng: -64.8267 }, 0);
current.pointOfView({ lat: initialPointOfView?.lat ?? US_CENTER.lat, lng: initialPointOfView?.lng ?? US_CENTER.lng }, 0);
// Fix z-fighting: Enable proper depth testing
const renderer = current.renderer();

View File

@ -0,0 +1,33 @@
import { headers } from "next/headers";
import { NextResponse } from "next/server";
export const runtime = "nodejs";
/**
* Returns the dashboard viewer's approximate latitude and longitude,
* derived from IP-based geolocation headers injected by the CDN/proxy
* (Vercel, Cloudflare). No browser permissions are required.
*
* Returns `{ lat, lng }` when geo headers are available, otherwise
* `{ lat: null, lng: null }` so the caller can fall back.
*/
export async function GET() {
const allHeaders = await headers();
// Vercel injects lat/lng headers on every edge/serverless request.
const vercelLat = allHeaders.get("x-vercel-ip-latitude");
const vercelLng = allHeaders.get("x-vercel-ip-longitude");
if (vercelLat != null && vercelLng != null) {
const lat = parseFloat(vercelLat);
const lng = parseFloat(vercelLng);
if (Number.isFinite(lat) && Number.isFinite(lng)) {
return NextResponse.json({ lat, lng });
}
}
// Cloudflare provides a cf-ipcountry header but no lat/lng, so we can't
// do much with it here. Fall back gracefully.
return NextResponse.json({ lat: null, lng: null });
}

View File

@ -0,0 +1,67 @@
"use client";
import { captureError } from "@hexclave/shared/dist/utils/errors";
import { runAsynchronously } from "@hexclave/shared/dist/utils/promises";
import { useEffect, useState } from "react";
type ViewerLocation = { lat: number, lng: number };
// US geographic center fallback
export const US_CENTER: ViewerLocation = { lat: 39.5, lng: -98.35 };
// Cache the result so we only fetch once per page load, even if multiple
// components mount/unmount the hook.
let cachedLocation: ViewerLocation | null = null;
let fetchPromise: Promise<ViewerLocation> | null = null;
function fetchViewerLocation(): Promise<ViewerLocation> {
if (fetchPromise != null) return fetchPromise;
fetchPromise = (async () => {
try {
const res = await fetch("/api/viewer-location");
if (!res.ok) {
fetchPromise = null;
return US_CENTER;
}
const data = await res.json();
if (typeof data.lat === "number" && typeof data.lng === "number") {
return { lat: data.lat, lng: data.lng };
}
} catch (e) {
captureError("viewer-location-fetch", e instanceof Error ? e : new Error(String(e)));
fetchPromise = null;
}
// Header data was missing or malformed; allow retry on next mount.
fetchPromise = null;
return US_CENTER;
})();
return fetchPromise;
}
/**
* Returns the dashboard viewer's approximate location based on IP
* geolocation (via CDN headers), falling back to the US center.
* The fetch runs once per page load and the result is cached.
*/
export function useViewerLocation(): ViewerLocation {
const [location, setLocation] = useState<ViewerLocation>(() => cachedLocation ?? US_CENTER);
useEffect(() => {
if (cachedLocation != null) return;
let cancelled = false;
runAsynchronously(async () => {
const loc = await fetchViewerLocation();
// Only cache a successful (non-fallback) result so transient
// failures can be retried on next mount.
if (loc !== US_CENTER) {
cachedLocation = loc;
}
if (!cancelled) setLocation(loc);
});
return () => {
cancelled = true;
};
}, []);
return location;
}