Render Planetora countries with d3-geo orthographic projection

Drops the hand-rolled projection / hemisphere clipper for d3-geo's
geoOrthographic + geoPath. The previous implementation cut a straight
chord across the back of the disc when a polygon (Russia, Greenland,
Antarctica…) crossed from front to back to front, leaving abrupt flat
edges along the limb. d3 walks the limb arc properly via clipAngle(90),
so the silhouette matches the sphere.

Also drops the camera "zoom" by lowering RADIUS from 285 to 250, giving
the globe ~17% more breathing room from the viewport edges.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
sam
2026-05-21 15:05:25 +07:00
parent 3ade1e8126
commit 80b56b3318
3 changed files with 97 additions and 134 deletions
+11
View File
@@ -99,6 +99,7 @@
"@scure/bip39": "^1.6.0",
"@sentry/react": "^10.42.0",
"@tanstack/react-query": "^5.56.2",
"@types/d3-geo": "^3.1.0",
"@unhead/addons": "^2.1.13",
"@unhead/react": "^2.1.13",
"bitcoinjs-lib": "^7.0.1",
@@ -109,6 +110,7 @@
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
"d3-celestial": "^0.7.35",
"d3-geo": "^3.1.1",
"d3-scale": "^4.0.2",
"date-fns": "^3.6.0",
"dompurify": "^3.3.3",
@@ -6670,6 +6672,15 @@
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
"license": "MIT"
},
"node_modules/@types/d3-geo": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz",
"integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==",
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
+2
View File
@@ -106,6 +106,7 @@
"@scure/bip39": "^1.6.0",
"@sentry/react": "^10.42.0",
"@tanstack/react-query": "^5.56.2",
"@types/d3-geo": "^3.1.0",
"@unhead/addons": "^2.1.13",
"@unhead/react": "^2.1.13",
"bitcoinjs-lib": "^7.0.1",
@@ -116,6 +117,7 @@
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
"d3-celestial": "^0.7.35",
"d3-geo": "^3.1.1",
"d3-scale": "^4.0.2",
"date-fns": "^3.6.0",
"dompurify": "^3.3.3",
+84 -134
View File
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useRef } from 'react';
import { geoDistance, geoOrthographic, geoPath } from 'd3-geo';
import type { CountryFeature } from '@/lib/planetora/countryGeo';
import type { PlanetoraRing } from '@/hooks/usePlanetoraPlayback';
import { PLANETORA_RING_TTL_MS } from '@/hooks/usePlanetoraPlayback';
@@ -28,7 +29,12 @@ export interface PlanetoraSvgGlobeProps {
// ── Projection / sizing ─────────────────────────────────────────────────────
const RADIUS = 285;
/**
* SVG viewBox is 600×600 — RADIUS leaves a margin between the sphere and the
* edge of the viewport so the limb doesn't crowd UI elements stacked over
* the canvas (controls, panels). Tweak this to "zoom" the camera.
*/
const RADIUS = 250;
const CENTER = 300;
/** Seconds per full revolution during idle rotation. Slow on purpose. */
const IDLE_ROTATION_PERIOD_SECONDS = 140;
@@ -37,61 +43,11 @@ const IDLE_ROTATION_PERIOD_SECONDS = 140;
* 16ms tick. ~0.06 → critically-damped feel over ~1.5 s.
*/
const FOCUS_EASE = 0.06;
interface Projected {
x: number;
y: number;
/** > 0 = front hemisphere, ≤ 0 = back. */
z: number;
}
/** Orthographic projection rotated by `(rotLng, rotLat)`. */
function project(lat: number, lng: number, rotLng: number, rotLat: number): Projected {
const phi0 = (rotLat * Math.PI) / 180;
const phi = (lat * Math.PI) / 180;
// Subtract rotLng so the globe spins west-to-east.
const lambda = ((lng - rotLng) * Math.PI) / 180;
const cosPhi0 = Math.cos(phi0);
const sinPhi0 = Math.sin(phi0);
const cosPhi = Math.cos(phi);
const sinPhi = Math.sin(phi);
const cosLambda = Math.cos(lambda);
const sinLambda = Math.sin(lambda);
// Standard rotated orthographic.
const x = cosPhi * sinLambda;
const y = cosPhi0 * sinPhi - sinPhi0 * cosPhi * cosLambda;
const z = sinPhi0 * sinPhi + cosPhi0 * cosPhi * cosLambda;
return {
x: CENTER + x * RADIUS,
y: CENTER - y * RADIUS,
z,
};
}
/** Polygon coords as `[lng, lat]` pairs. One per outer ring of a country. */
interface CountryPolyRing {
iso: string;
pts: [number, number][];
}
/** Flatten Polygon / MultiPolygon → list of outer rings (drop holes — not visible at this scale). */
function flattenCountries(features: CountryFeature[]): CountryPolyRing[] {
const out: CountryPolyRing[] = [];
for (const f of features) {
const iso = f.properties.iso;
const g = f.geometry;
if (g.type === 'Polygon') {
const ring = g.coordinates[0];
if (ring) out.push({ iso, pts: ring as [number, number][] });
} else if (g.type === 'MultiPolygon') {
for (const poly of g.coordinates) {
const ring = poly[0];
if (ring) out.push({ iso, pts: ring as [number, number][] });
}
}
}
return out;
}
/**
* Latitude tilt — keeps the poles off-centre so the globe never looks like
* a flat dial. Matches the WebGL camera's resting altitude.
*/
const VIEW_TILT_DEG = 20;
// ── Component ───────────────────────────────────────────────────────────────
@@ -118,8 +74,17 @@ export function PlanetoraSvgGlobe({
onUserInteract,
className,
}: PlanetoraSvgGlobeProps) {
// Pre-flatten countries to a render list. Stable across renders.
const polyRings = useMemo(() => flattenCountries(countries), [countries]);
// d3-geo orthographic projection + path generator. Recreated only when
// the viewport-relative scale / centre changes (currently never).
// `clipAngle(90)` is the default for ortho — d3 walks the limb arc when a
// polygon crosses the back hemisphere instead of cutting a chord.
const { projection, pathBuilder } = useMemo(() => {
const proj = geoOrthographic()
.scale(RADIUS)
.translate([CENTER, CENTER])
.clipAngle(90);
return { projection: proj, pathBuilder: geoPath(proj) };
}, []);
// Refs to imperatively update each polygon / ring without React re-renders.
const landGroupRef = useRef<SVGGElement | null>(null);
@@ -142,7 +107,6 @@ export function PlanetoraSvgGlobe({
// Animated camera state (longitude only — latitude tilt stays at a
// flattering fixed angle so the poles never end up dead-centre).
const rotLngRef = useRef(10);
const rotLatRef = useRef(20);
useEffect(() => {
const prefersReducedMotion =
@@ -174,68 +138,41 @@ export function PlanetoraSvgGlobe({
}
const rotLng = rotLngRef.current;
const rotLat = rotLatRef.current;
const t = themeRef.current;
const active = activeCountriesRef.current;
// d3-geo's rotate uses [lambda, phi, gamma]. Negative lambda spins
// the globe west-to-east; phi tilts the camera off the equator.
projection.rotate([-rotLng, -VIEW_TILT_DEG]);
// The geographic point currently dead-centre on the visible
// hemisphere — used to fade things by depth (rings, selected dot).
const cameraLng = rotLng;
const cameraLat = VIEW_TILT_DEG;
// ── Country polygons ──────────────────────────────────────────────
const landEl = landGroupRef.current;
if (landEl) {
const polys = landEl.children;
for (let i = 0; i < polyRings.length; i++) {
const polygon = polys[i] as SVGPolygonElement | undefined;
if (!polygon) continue;
const { iso, pts } = polyRings[i];
// Project all vertices, dropping anything not on the front hemisphere.
// Where we cross the limb we emit a point on the sphere edge so the
// visible portion closes cleanly.
const n = pts.length;
let maxZ = -1;
const xs = new Array<number>(n);
const ys = new Array<number>(n);
const zs = new Array<number>(n);
for (let j = 0; j < n; j++) {
const p = project(pts[j][1], pts[j][0], rotLng, rotLat);
xs[j] = p.x;
ys[j] = p.y;
zs[j] = p.z;
if (p.z > maxZ) maxZ = p.z;
}
if (maxZ <= 0) {
polygon.setAttribute('opacity', '0');
const paths = landEl.children;
for (let i = 0; i < countries.length; i++) {
const path = paths[i] as SVGPathElement | undefined;
if (!path) continue;
const feature = countries[i];
const d = pathBuilder(feature);
if (!d) {
path.setAttribute('opacity', '0');
continue;
}
const parts: string[] = [];
for (let j = 0; j < n; j++) {
const k = (j + 1) % n;
const zj = zs[j];
const zk = zs[k];
if (zj > 0) parts.push(`${xs[j].toFixed(1)},${ys[j].toFixed(1)}`);
if ((zj > 0) !== (zk > 0)) {
const u = zj / (zj - zk);
const ex = xs[j] + (xs[k] - xs[j]) * u;
const ey = ys[j] + (ys[k] - ys[j]) * u;
const dx = ex - CENTER;
const dy = ey - CENTER;
const d = Math.hypot(dx, dy) || 1;
const lx = CENTER + (dx / d) * RADIUS;
const ly = CENTER + (dy / d) * RADIUS;
parts.push(`${lx.toFixed(1)},${ly.toFixed(1)}`);
}
}
if (parts.length < 3) {
polygon.setAttribute('opacity', '0');
continue;
}
const isActive = active.has(iso);
polygon.setAttribute('points', parts.join(' '));
polygon.setAttribute('fill', isActive ? t.activeCountryColor : t.restCountryColor);
// Slight depth-based dimming so the limb doesn't look flat.
const fade = Math.min(1, 0.55 + maxZ * 0.45);
const baseOpacity = isActive ? t.activeCountryOpacity : t.restCountryOpacity;
polygon.setAttribute('opacity', (baseOpacity * fade).toFixed(2));
const isActive = active.has(feature.properties.iso);
path.setAttribute('d', d);
path.setAttribute(
'fill',
isActive ? t.activeCountryColor : t.restCountryColor,
);
path.setAttribute(
'opacity',
(isActive ? t.activeCountryOpacity : t.restCountryOpacity).toFixed(2),
);
}
}
@@ -254,17 +191,19 @@ export function PlanetoraSvgGlobe({
el.setAttribute('opacity', '0');
continue;
}
const p = project(ring.lat, ring.lng, rotLng, rotLat);
if (p.z <= 0) {
const xy = projection([ring.lng, ring.lat]);
if (!xy) {
el.setAttribute('opacity', '0');
continue;
}
// 0 (centre) → π (antipode); we treat anything past π/2 as hidden.
const dist = geoDistance([ring.lng, ring.lat], [cameraLng, cameraLat]);
const z = Math.cos(dist); // 1 centre, 0 limb
const phase = age / PLANETORA_RING_TTL_MS; // 0..1
// Outward expansion + fade; same visual language as the WebGL rings.
const r = 4 + phase * 24;
const opacity = (1 - phase) * (0.25 + p.z * 0.65);
el.setAttribute('cx', p.x.toFixed(1));
el.setAttribute('cy', p.y.toFixed(1));
const opacity = (1 - phase) * (0.25 + Math.max(0, z) * 0.65);
el.setAttribute('cx', xy[0].toFixed(1));
el.setAttribute('cy', xy[1].toFixed(1));
el.setAttribute('r', r.toFixed(1));
el.setAttribute('opacity', opacity.toFixed(2));
el.setAttribute('stroke', t.ringColor);
@@ -273,17 +212,26 @@ export function PlanetoraSvgGlobe({
// ── Selected marker ───────────────────────────────────────────────
if (sel && selectedDotRef.current && selectedHaloRef.current) {
const p = project(sel.coords[1], sel.coords[0], rotLng, rotLat);
const visible = p.z > -0.05; // small grace band for the easing tail
const op = visible ? Math.max(0, Math.min(1, 0.4 + p.z * 0.7)) : 0;
selectedDotRef.current.setAttribute('cx', p.x.toFixed(1));
selectedDotRef.current.setAttribute('cy', p.y.toFixed(1));
selectedDotRef.current.setAttribute('opacity', op.toFixed(2));
selectedDotRef.current.setAttribute('fill', t.highlightColor);
selectedHaloRef.current.setAttribute('cx', p.x.toFixed(1));
selectedHaloRef.current.setAttribute('cy', p.y.toFixed(1));
selectedHaloRef.current.setAttribute('opacity', op.toFixed(2));
selectedHaloRef.current.setAttribute('stroke', t.highlightColor);
const xy = projection([sel.coords[0], sel.coords[1]]);
if (xy) {
const dist = geoDistance(
[sel.coords[0], sel.coords[1]],
[cameraLng, cameraLat],
);
const z = Math.cos(dist);
const op = Math.max(0, Math.min(1, 0.4 + z * 0.7));
selectedDotRef.current.setAttribute('cx', xy[0].toFixed(1));
selectedDotRef.current.setAttribute('cy', xy[1].toFixed(1));
selectedDotRef.current.setAttribute('opacity', op.toFixed(2));
selectedDotRef.current.setAttribute('fill', t.highlightColor);
selectedHaloRef.current.setAttribute('cx', xy[0].toFixed(1));
selectedHaloRef.current.setAttribute('cy', xy[1].toFixed(1));
selectedHaloRef.current.setAttribute('opacity', op.toFixed(2));
selectedHaloRef.current.setAttribute('stroke', t.highlightColor);
} else {
selectedDotRef.current.setAttribute('opacity', '0');
selectedHaloRef.current.setAttribute('opacity', '0');
}
} else if (selectedDotRef.current && selectedHaloRef.current) {
selectedDotRef.current.setAttribute('opacity', '0');
selectedHaloRef.current.setAttribute('opacity', '0');
@@ -293,7 +241,7 @@ export function PlanetoraSvgGlobe({
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [polyRings]);
}, [countries, projection, pathBuilder]);
// When the side panel is open we extend the SVG canvas off the LEFT
// (desktop) or BOTTOM (mobile) edge of the viewport. The globe stays
@@ -358,11 +306,13 @@ export function PlanetoraSvgGlobe({
<g
ref={landGroupRef}
stroke={theme.countryBorder}
strokeWidth="0.3"
strokeWidth="0.4"
strokeLinejoin="round"
strokeLinecap="round"
vectorEffect="non-scaling-stroke"
>
{polyRings.map((_, i) => (
<polygon key={i} opacity={0} />
{countries.map((f) => (
<path key={f.properties.iso} opacity={0} />
))}
</g>
</g>