feat(loader): real Natural Earth globe via d3-geo + topojson + world-atlas

Per user — GlobeLoader должен показывать реальные континенты (Asia/Africa/
Australia outline) с rotating orthographic projection, как в прототипе
Downloads/Globe loader/Globe Loader.html. Прошлая версия использовала
faked ellipses graticule — выглядело как абстрактные кольца, не как глобус.

Implementation:
- d3-geo.geoOrthographic + d3.geoPath для projection math
- topojson-client.feature для распаковки world-atlas land-110m
- dynamic import обоих deps + JSON — не блокирует main bundle, lazy chunk
- requestAnimationFrame loop пересчитывает d-attr на каждом кадре
  (8s per revolution per прототип, tilt -15° lean)
- LAND_CACHE module-level — повторные mount'ы не re-fetch
- CSS .globe-sphere rotation удалена — d3 projection делает работу,
  CSS transform distort'ила бы land mass (нет projection math)

Bundle hit: ~55kb (land-110m.json) + ~15kb gzipped (d3-geo + topojson) —
lazy chunk, fetched один раз и cached.
This commit is contained in:
Zimin A.N.
2026-05-11 22:39:58 +03:00
parent e165a552aa
commit acde4e134d
4 changed files with 223 additions and 49 deletions
@@ -1,39 +1,123 @@
import { useId as useReactId } from 'react'
import { useEffect, useId as useReactId, useRef, useState } from 'react'
import { geoOrthographic, geoPath, geoGraticule10 } from 'd3-geo'
import type { Feature, FeatureCollection, Geometry } from 'geojson'
import { cn } from '@/lib/utils'
/**
* GlobeLoader — orbital globe spinner per Downloads/Globe loader prototype.
* GlobeLoader — orbital globe spinner per /Users/zimin/Downloads/Globe loader
* prototype. Real Natural Earth land outline через d3-geo orthographic
* projection, rotating longitude continuously. Outer whirl arcs + pulsing
* ring CSS-only.
*
* Чистый CSS/SVG (без d3 / topojson) — упрощённая sphere с graticule
* meridians + pulsing outer ring + два контр-вращающихся whirl arcs с tail
* gradient. Stylized в дизайн-системе: stroke=currentColor (берёт цвет ink
* из родителя — works на любом фоне, в т.ч. dark mode).
* <p>Lazy-loads world-atlas (~55kb) — пока геометрия не доехала, видны
* только sphere + graticule + orbital arcs (still looks like a loader).
*
* Использование:
* <p>Использование:
* <GlobeLoader /> // 64px default
* <GlobeLoader size={120} /> // larger
* <GlobeLoader className="text-mute" /> // tint
*
* Performance: всё в SVG + CSS animations (transform / opacity) — paint-only,
* без JS frame loop. Безопасно держать одновременно на странице.
* <GlobeLoader className="text-mute" /> // tint via currentColor
*/
export type GlobeLoaderProps = {
/** Pixel size of the spinner square (default 64). */
size?: number
/** Optional label rendered below (text-body, мute). */
/** Optional label rendered below. */
label?: React.ReactNode
/** Extra wrapper class — обычно text-ink/text-mute для tint. */
/** Extra wrapper class. */
className?: string
}
// Cache загруженной land geometry между mount'ами loader'а (типично загрузка
// списка → переход в editor → ещё loader). Без cache каждый GlobeLoader
// триггерил бы import заново — Vite кеширует chunk, но parse cost есть.
let LAND_CACHE: Feature<Geometry> | null = null
let LAND_PROMISE: Promise<Feature<Geometry>> | null = null
async function loadLand(): Promise<Feature<Geometry>> {
if (LAND_CACHE) return LAND_CACHE
if (LAND_PROMISE) return LAND_PROMISE
LAND_PROMISE = (async () => {
const [{ feature }, world] = await Promise.all([
import('topojson-client'),
import('world-atlas/land-110m.json'),
])
const data = (world as { default: unknown }).default ?? world
// topojson-client expects Topology с objects.land mesh.
const f = feature(
data as Parameters<typeof feature>[0],
(data as { objects: { land: Parameters<typeof feature>[1] } }).objects.land,
) as Feature<Geometry> | FeatureCollection<Geometry>
const result = 'features' in f ? (f.features[0] as Feature<Geometry>) : f
LAND_CACHE = result
return result
})()
return LAND_PROMISE
}
export function GlobeLoader({ size = 64, label, className }: GlobeLoaderProps) {
// ID gradient unique per instance чтобы несколько loader'ов не конфликтовали
// через shared <defs id>. React 19 useId — SSR-safe + stable.
const id = useReactId().replace(/:/g, '')
const tailA = `globe-tail-a-${id}`
const tailB = `globe-tail-b-${id}`
const sphereRef = useRef<SVGPathElement>(null)
const gratRef = useRef<SVGPathElement>(null)
const landRef = useRef<SVGPathElement>(null)
const [landReady, setLandReady] = useState(false)
useEffect(() => {
// Setup projection: orthographic, R=70 (как в прототипе), tilt -15° (легкий
// northern lean). viewBox 200x200 — fixed; scale projection относительно него.
const projection = geoOrthographic()
.scale(70)
.translate([100, 100])
.clipAngle(90)
.rotate([0, -15, 0])
const path = geoPath(projection)
const graticule = geoGraticule10()
// Initial render (без land — оно lazy).
if (sphereRef.current) sphereRef.current.setAttribute('d', path({ type: 'Sphere' }) ?? '')
if (gratRef.current) gratRef.current.setAttribute('d', path(graticule) ?? '')
let landFeature: Feature<Geometry> | null = LAND_CACHE
let cancelled = false
if (!landFeature) {
void loadLand().then((f) => {
if (cancelled) return
landFeature = f
if (landRef.current) landRef.current.setAttribute('d', path(f) ?? '')
setLandReady(true)
})
} else {
if (landRef.current) landRef.current.setAttribute('d', path(landFeature) ?? '')
setLandReady(true)
}
// Animate rotation — 8s per revolution per prototype.
const start = performance.now()
const period = 8000
let raf = 0
const frame = (now: number) => {
if (cancelled) return
const t = ((now - start) % period) / period
const lambda = t * 360
projection.rotate([lambda, -15, 0])
if (gratRef.current) gratRef.current.setAttribute('d', path(graticule) ?? '')
if (landFeature && landRef.current) {
landRef.current.setAttribute('d', path(landFeature) ?? '')
}
raf = requestAnimationFrame(frame)
}
raf = requestAnimationFrame(frame)
return () => {
cancelled = true
cancelAnimationFrame(raf)
}
}, [])
return (
<div
role="status"
@@ -41,10 +125,8 @@ export function GlobeLoader({ size = 64, label, className }: GlobeLoaderProps) {
aria-label="loading"
className={cn('inline-flex flex-col items-center justify-center gap-2 text-ink', className)}
>
<div
className="globe-loader relative"
style={{ width: size, height: size }}
>
<div className="globe-loader relative" style={{ width: size, height: size }}>
{/* Outer overlay svg: whirl arcs + pulse ring (CSS-animated). */}
<svg viewBox="0 0 200 200" className="absolute inset-0 size-full overflow-visible">
<defs>
<linearGradient id={tailA} gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="200" y2="0">
@@ -92,7 +174,7 @@ export function GlobeLoader({ size = 64, label, className }: GlobeLoaderProps) {
/>
</g>
{/* Inner whirl, counter-rotating, tighter */}
{/* Inner whirl, counter-rotating */}
<g className="globe-whirl-rev">
<path
d="M 100 18 A 82 82 0 1 1 18 100"
@@ -104,31 +186,40 @@ export function GlobeLoader({ size = 64, label, className }: GlobeLoaderProps) {
/>
<circle cx="100" cy="18" r="1.6" fill="currentColor" opacity="0.85" />
</g>
</svg>
{/* Sphere + graticule. Без d3 — фиксированные meridians/parallels.
* Meridians = vertical ellipses with varying rx (orthographic-like
* projection приближение). Parallels = horizontal lines (clipped
* sphere implicit через circle clip). */}
<g className="globe-sphere">
<circle cx="100" cy="100" r="60" fill="none" stroke="currentColor" strokeWidth="1" />
{/* meridians at different rx values — looks like rotating projection */}
<g stroke="currentColor" strokeWidth="0.5" strokeOpacity="0.35" fill="none">
<ellipse cx="100" cy="100" rx="60" ry="60" />
<ellipse cx="100" cy="100" rx="45" ry="60" />
<ellipse cx="100" cy="100" rx="25" ry="60" />
<ellipse cx="100" cy="100" rx="5" ry="60" />
{/* parallels — horizontal lines clipped within the sphere */}
<line x1="44" y1="80" x2="156" y2="80" />
<line x1="40" y1="100" x2="160" y2="100" />
<line x1="44" y1="120" x2="156" y2="120" />
<line x1="55" y1="60" x2="145" y2="60" />
<line x1="55" y1="140" x2="145" y2="140" />
</g>
</g>
{/* Globe svg: real orthographic projection с rotating land. */}
<svg viewBox="0 0 200 200" className="absolute inset-0 size-full overflow-visible">
{/* Sphere — disk silhouette (fill = bg чтобы land не светилось через
* прозрачное место). */}
<path
ref={sphereRef}
fill="var(--color-bg, #f4f1ec)"
stroke="currentColor"
strokeWidth="1.25"
/>
{/* Graticule — subtle lat/lon grid. */}
<path
ref={gratRef}
fill="none"
stroke="currentColor"
strokeWidth="0.4"
strokeOpacity="0.18"
/>
{/* Land — Natural Earth outline, rotated continuously. fill-opacity
* 0.88 чтобы slightly soften. */}
<path
ref={landRef}
fill="currentColor"
fillOpacity={landReady ? 0.88 : 0}
stroke="var(--color-bg, #f4f1ec)"
strokeWidth="0.35"
strokeLinejoin="round"
style={{ transition: 'fill-opacity 0.3s ease' }}
/>
</svg>
</div>
{label && <span className="text-body text-mute">{label}</span>}
</div>
)
}