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:
@@ -399,16 +399,13 @@ body {
|
||||
transform-box: fill-box;
|
||||
animation: ord-globe-pulse 2.4s ease-in-out infinite;
|
||||
}
|
||||
.globe-loader .globe-sphere {
|
||||
transform-origin: 100px 100px;
|
||||
transform-box: fill-box;
|
||||
animation: ord-globe-spin 14s linear infinite;
|
||||
}
|
||||
/* Sphere/land внутри globe-loader rotates через d3 projection (rAF
|
||||
* пересчитывает path d-attr), не через CSS transform — иначе land
|
||||
* distort'илась бы при rotation (no projection math). */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.globe-loader .globe-whirl,
|
||||
.globe-loader .globe-whirl-rev,
|
||||
.globe-loader .globe-pulse,
|
||||
.globe-loader .globe-sphere {
|
||||
.globe-loader .globe-pulse {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user