From 165d9a07869758bcc9a4642088c40a027ba7c374 Mon Sep 17 00:00:00 2001 From: Konsti Wohlwend Date: Wed, 8 Jul 2026 10:33:30 -0700 Subject: [PATCH] feat: center globe on viewer'S location (#1715) --- .../(overview)/globe-section-with-data.tsx | 3 + .../projects/[projectId]/(overview)/globe.tsx | 10 +-- .../src/app/api/viewer-location/route.ts | 33 +++++++++ .../src/hooks/use-viewer-location.tsx | 67 +++++++++++++++++++ 4 files changed, 108 insertions(+), 5 deletions(-) create mode 100644 apps/dashboard/src/app/api/viewer-location/route.ts create mode 100644 apps/dashboard/src/hooks/use-viewer-location.tsx diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/globe-section-with-data.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/globe-section-with-data.tsx index 1518b5b0a..8798567e6 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/globe-section-with-data.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/globe-section-with-data.tsx @@ -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} /> ); diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/globe.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/globe.tsx index b94e4e102..6628ca964 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/globe.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/globe.tsx @@ -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, totalUsers: number, activeUsersByCountry?: Record, satelliteCount?: number, interactive?: boolean, children?: React.ReactNode}) { +export function GlobeSection({ countryData, totalUsers, activeUsersByCountry, satelliteCount, interactive, initialPointOfView, children }: {countryData: Record, totalUsers: number, activeUsersByCountry?: Record, satelliteCount?: number, interactive?: boolean, initialPointOfView?: { lat: number, lng: number }, children?: React.ReactNode}) { const hasWaitedForIdle = useWaitForIdle(1000, 5000); if (!hasWaitedForIdle) { return ; @@ -372,6 +373,7 @@ export function GlobeSection({ countryData, totalUsers, activeUsersByCountry, sa activeUsersByCountry={activeUsersByCountry ?? {}} satelliteCount={satelliteCount ?? 2} interactive={interactive ?? false} + initialPointOfView={initialPointOfView} /> ); @@ -466,7 +468,7 @@ function GlobeLoading(props: { devReason: string, className?: string }) { ); } -function GlobeSectionInner({ countryData, totalUsers, activeUsersByCountry, satelliteCount, interactive, children }: {countryData: Record, totalUsers: number, activeUsersByCountry: Record, satelliteCount: number, interactive: boolean, children?: React.ReactNode}) { +function GlobeSectionInner({ countryData, totalUsers, activeUsersByCountry, satelliteCount, interactive, initialPointOfView, children }: {countryData: Record, totalUsers: number, activeUsersByCountry: Record, satelliteCount: number, interactive: boolean, initialPointOfView?: { lat: number, lng: number }, children?: React.ReactNode}) { const countries = use(countriesPromise); const projectId = useProjectId(); const globeRef = useRef(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(); diff --git a/apps/dashboard/src/app/api/viewer-location/route.ts b/apps/dashboard/src/app/api/viewer-location/route.ts new file mode 100644 index 000000000..b763f1516 --- /dev/null +++ b/apps/dashboard/src/app/api/viewer-location/route.ts @@ -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 }); +} diff --git a/apps/dashboard/src/hooks/use-viewer-location.tsx b/apps/dashboard/src/hooks/use-viewer-location.tsx new file mode 100644 index 000000000..107b08678 --- /dev/null +++ b/apps/dashboard/src/hooks/use-viewer-location.tsx @@ -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 | null = null; + +function fetchViewerLocation(): Promise { + 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(() => 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; +}