SkateSpots/src/components/SkateGlobe.tsx

433 lines
16 KiB
TypeScript

/**
* SkateGlobe — 3D interactive globe using pure Three.js
*
* Behaviour:
* - Globe is STATIC by default (no auto-rotation)
* - Drag to rotate, scroll/pinch to zoom
* - Spots show as red pins, shops as lime pins
* - Pins update reactively as Nostr data loads
* - Click pin → zoom in + show info card
* - Click background → zoom out
*/
import { useRef, useEffect, useCallback, useState, useMemo } from 'react';
import * as THREE from 'three';
import type { SkateSpot, SkateShop } from '@/lib/types';
// ─── Public types ─────────────────────────────────────────────────────────────
export interface GlobeMarker {
id: string;
lat: number;
lng: number;
label: string;
type: 'spot' | 'shop';
spot?: SkateSpot;
shop?: SkateShop;
}
interface SkateGlobeProps {
spots: SkateSpot[];
shops: SkateShop[];
showSpots: boolean;
showShops: boolean;
selectedMarkerId?: string | null;
onMarkerClick: (marker: GlobeMarker) => void;
onBackgroundClick?: () => void;
}
// ─── Constants ────────────────────────────────────────────────────────────────
const R = 1.0; // globe radius
const PIN_R = 0.018; // normal pin radius
const PIN_R_SEL = 0.028; // selected pin radius
const CAM_DEFAULT = 2.6; // default camera Z distance
const CAM_ZOOMED = 1.5; // zoomed-in camera Z distance
// ─── Lat/Lng → 3D vector ─────────────────────────────────────────────────────
function ll2v(lat: number, lng: number, r: number): THREE.Vector3 {
const phi = (90 - lat) * (Math.PI / 180);
const theta = (lng + 180) * (Math.PI / 180);
return new THREE.Vector3(
-r * Math.sin(phi) * Math.cos(theta),
r * Math.cos(phi),
r * Math.sin(phi) * Math.sin(theta),
);
}
// ─── Geohash decode ───────────────────────────────────────────────────────────
const GH32 = '0123456789bcdefghjkmnpqrstuvwxyz';
function decodeGh(gh: string): { lat: number; lng: number } | null {
if (!gh || gh.length < 4) return null;
try {
let minLat = -90, maxLat = 90, minLng = -180, maxLng = 180, even = true;
for (const c of gh.toLowerCase()) {
const idx = GH32.indexOf(c);
if (idx < 0) break;
for (let b = 4; b >= 0; b--) {
const bit = (idx >> b) & 1;
if (even) { const m = (minLng + maxLng) / 2; if (bit) { minLng = m; } else { maxLng = m; } }
else { const m = (minLat + maxLat) / 2; if (bit) { minLat = m; } else { maxLat = m; } }
even = !even;
}
}
return { lat: (minLat + maxLat) / 2, lng: (minLng + maxLng) / 2 };
} catch { return null; }
}
function getCoords(tags: string[][]): { lat: number; lng: number } | null {
if (!tags || !Array.isArray(tags)) return null;
const gh = tags.find(t => t[0] === 'g')?.[1];
if (gh) { const c = decodeGh(gh); if (c) return c; }
const lat = parseFloat(tags.find(t => t[0] === 'lat')?.[1] ?? '');
const lng = parseFloat(tags.find(t => t[0] === 'lon')?.[1] ?? '');
if (!isNaN(lat) && !isNaN(lng)) return { lat, lng };
return null;
}
function getName(tags: string[][], fallback: string): string {
return tags.find(t => t[0] === 'title')?.[1]
?? tags.find(t => t[0] === 'name')?.[1]
?? fallback;
}
// ─── Component ────────────────────────────────────────────────────────────────
export function SkateGlobe({
spots, shops, showSpots, showShops,
selectedMarkerId, onMarkerClick, onBackgroundClick,
}: SkateGlobeProps) {
const containerRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const rendererRef = useRef<THREE.WebGLRenderer | null>(null);
const cameraRef = useRef<THREE.PerspectiveCamera | null>(null);
const globeGrpRef = useRef<THREE.Group | null>(null); // rotates with drag
const pinsGrpRef = useRef<THREE.Group | null>(null); // child of globeGrp
const raycaster = useRef(new THREE.Raycaster());
const mouse = useRef(new THREE.Vector2());
// drag state
const dragging = useRef(false);
const moved = useRef(false);
const lastXY = useRef({ x: 0, y: 0 });
const rotXY = useRef({ x: 0.3, y: 0 }); // current globe rotation
const velXY = useRef({ x: 0, y: 0 }); // inertia (no auto-spin)
const targetZ = useRef(CAM_DEFAULT);
const touchDist0 = useRef(0);
// map THREE.Mesh → marker for raycasting
const [meshMap, setMeshMap] = useState<Map<THREE.Mesh, GlobeMarker>>(new Map());
const [ready, setReady] = useState(false);
// ── Build marker list from Nostr data ────────────────────────────────────────
const markers = useMemo<GlobeMarker[]>(() => {
const out: GlobeMarker[] = [];
if (showSpots && spots) {
for (const s of spots) {
const evTags = s?.event?.tags;
if (!evTags || !Array.isArray(evTags)) continue;
const c = getCoords(evTags);
if (!c) continue;
out.push({
id: `spot-${s.id}`,
lat: c.lat, lng: c.lng,
label: s.title || getName(evTags, 'Unnamed Spot'),
type: 'spot', spot: s,
});
}
}
if (showShops && shops) {
for (const sh of shops) {
const evTags = sh?.event?.tags;
if (!evTags || !Array.isArray(evTags)) continue;
const c = getCoords(evTags);
if (!c) continue;
out.push({
id: `shop-${sh.id}`,
lat: c.lat, lng: c.lng,
label: sh.name || getName(evTags, 'Skate Shop'),
type: 'shop', shop: sh,
});
}
}
return out;
}, [spots, shops, showSpots, showShops]);
// ── Scene setup — runs once ──────────────────────────────────────────────────
useEffect(() => {
const canvas = canvasRef.current!;
const container = containerRef.current!;
const W = container.clientWidth || 800;
const H = container.clientHeight || 600;
// Renderer
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: false });
renderer.setSize(W, H);
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setClearColor(0x000000, 1);
rendererRef.current = renderer;
// Scene
const scene = new THREE.Scene();
// Starfield
const sfGeo = new THREE.BufferGeometry();
const sfPos = new Float32Array(3000 * 3);
for (let i = 0; i < sfPos.length; i++) sfPos[i] = (Math.random() - 0.5) * 100;
sfGeo.setAttribute('position', new THREE.BufferAttribute(sfPos, 3));
scene.add(new THREE.Points(sfGeo,
new THREE.PointsMaterial({ color: 0xffffff, size: 0.06, transparent: true, opacity: 0.55 })));
// Camera
const camera = new THREE.PerspectiveCamera(45, W / H, 0.01, 200);
camera.position.set(0, 0, CAM_DEFAULT);
cameraRef.current = camera;
// Globe group (drag rotates this)
const globeGrp = new THREE.Group();
globeGrpRef.current = globeGrp;
scene.add(globeGrp);
// Earth sphere
const loader = new THREE.TextureLoader();
const earthTex = loader.load(
'https://unpkg.com/three-globe/example/img/earth-night.jpg',
() => setReady(true),
undefined,
() => setReady(true), // still render on error
);
loader.load('https://unpkg.com/three-globe/example/img/earth-topology.png', bumpTex => {
(globeMesh.material as THREE.MeshPhongMaterial).bumpMap = bumpTex;
(globeMesh.material as THREE.MeshPhongMaterial).bumpScale = 0.006;
(globeMesh.material as THREE.MeshPhongMaterial).needsUpdate = true;
});
const globeMat = new THREE.MeshPhongMaterial({
map: earthTex,
specular: new THREE.Color(0x222222),
shininess: 8,
});
const globeMesh = new THREE.Mesh(new THREE.SphereGeometry(R, 64, 64), globeMat);
globeGrp.add(globeMesh);
// Atmosphere rim
globeGrp.add(new THREE.Mesh(
new THREE.SphereGeometry(R * 1.05, 32, 32),
new THREE.MeshPhongMaterial({
color: 0xff0040, transparent: true, opacity: 0.07, side: THREE.BackSide,
}),
));
// Lighting
scene.add(new THREE.AmbientLight(0x404040, 2));
const sun = new THREE.DirectionalLight(0xffffff, 2.5);
sun.position.set(5, 3, 5);
scene.add(sun);
// Pins group (child of globe so it rotates with it)
const pinsGrp = new THREE.Group();
pinsGrpRef.current = pinsGrp;
globeGrp.add(pinsGrp);
// Animation loop — NO auto-rotation, just inertia decay
let raf: number;
const tick = () => {
raf = requestAnimationFrame(tick);
// Inertia decay when not dragging
if (!dragging.current) {
velXY.current.x *= 0.88;
velXY.current.y *= 0.88;
rotXY.current.x += velXY.current.x;
rotXY.current.y += velXY.current.y;
rotXY.current.x = Math.max(-Math.PI / 2, Math.min(Math.PI / 2, rotXY.current.x));
}
globeGrp.rotation.x = rotXY.current.x;
globeGrp.rotation.y = rotXY.current.y;
// Smooth zoom
camera.position.z += (targetZ.current - camera.position.z) * 0.12;
renderer.render(scene, camera);
};
tick();
// Resize
const ro = new ResizeObserver(() => {
const nw = container.clientWidth;
const nh = container.clientHeight;
if (nw > 0 && nh > 0) {
renderer.setSize(nw, nh);
camera.aspect = nw / nh;
camera.updateProjectionMatrix();
}
});
ro.observe(container);
return () => { cancelAnimationFrame(raf); ro.disconnect(); renderer.dispose(); };
}, []);
// ── Rebuild pins whenever marker data changes ────────────────────────────────
useEffect(() => {
const grp = pinsGrpRef.current;
if (!grp) return;
// Dispose old geometries / materials
grp.children.slice().forEach(c => {
if (c instanceof THREE.Mesh) {
c.geometry.dispose();
(c.material as THREE.Material).dispose();
}
grp.remove(c);
});
const newMap = new Map<THREE.Mesh, GlobeMarker>();
for (const m of markers) {
const isSel = m.id === selectedMarkerId;
const hex = m.type === 'spot' ? 0xff0040 : 0xccff00;
const radius = isSel ? PIN_R_SEL : PIN_R;
const pos = ll2v(m.lat, m.lng, R + radius + 0.004);
const mesh = new THREE.Mesh(
new THREE.SphereGeometry(radius, 10, 10),
new THREE.MeshBasicMaterial({ color: isSel ? 0xffffff : hex }),
);
mesh.position.copy(pos);
grp.add(mesh);
newMap.set(mesh, m);
// Pulse ring for selected pin
if (isSel) {
const ring = new THREE.Mesh(
new THREE.RingGeometry(radius * 1.6, radius * 2.0, 20),
new THREE.MeshBasicMaterial({ color: 0xffffff, side: THREE.DoubleSide, transparent: true, opacity: 0.6 }),
);
ring.position.copy(pos);
ring.lookAt(new THREE.Vector3(0, 0, 0)); // face away from globe centre
grp.add(ring);
}
}
setMeshMap(newMap);
}, [markers, selectedMarkerId]);
// ── Pointer / touch handlers ─────────────────────────────────────────────────
const onPointerDown = useCallback((e: React.PointerEvent) => {
moved.current = false;
dragging.current = true;
lastXY.current = { x: e.clientX, y: e.clientY };
velXY.current = { x: 0, y: 0 };
canvasRef.current?.setPointerCapture(e.pointerId);
}, []);
const onPointerMove = useCallback((e: React.PointerEvent) => {
if (!dragging.current) return;
const dx = e.clientX - lastXY.current.x;
const dy = e.clientY - lastXY.current.y;
if (Math.abs(dx) > 2 || Math.abs(dy) > 2) moved.current = true;
// Accumulate velocity for inertia
velXY.current.y = dx * 0.005;
velXY.current.x = dy * 0.005;
rotXY.current.y += dx * 0.005;
rotXY.current.x += dy * 0.005;
rotXY.current.x = Math.max(-Math.PI / 2, Math.min(Math.PI / 2, rotXY.current.x));
lastXY.current = { x: e.clientX, y: e.clientY };
}, []);
const onPointerUp = useCallback((e: React.PointerEvent) => {
canvasRef.current?.releasePointerCapture(e.pointerId);
dragging.current = false;
if (!moved.current) {
// Tap / click — raycast for pins
const rect = canvasRef.current!.getBoundingClientRect();
mouse.current.set(
((e.clientX - rect.left) / rect.width) * 2 - 1,
-((e.clientY - rect.top) / rect.height) * 2 + 1,
);
raycaster.current.setFromCamera(mouse.current, cameraRef.current!);
const hits = raycaster.current.intersectObjects([...meshMap.keys()]);
if (hits.length > 0) {
const marker = meshMap.get(hits[0].object as THREE.Mesh);
if (marker) {
// Rotate globe so pin is facing camera
const dir = ll2v(marker.lat, marker.lng, 1).normalize();
rotXY.current.y = -Math.atan2(dir.x, dir.z);
rotXY.current.x = Math.asin(Math.max(-1, Math.min(1, dir.y)));
targetZ.current = CAM_ZOOMED;
onMarkerClick(marker);
}
} else {
targetZ.current = CAM_DEFAULT;
onBackgroundClick?.();
}
}
moved.current = false;
}, [meshMap, onMarkerClick, onBackgroundClick]);
const onWheel = useCallback((e: React.WheelEvent) => {
e.preventDefault();
targetZ.current = Math.max(1.2, Math.min(6, targetZ.current + e.deltaY * 0.004));
}, []);
const onTouchStart = useCallback((e: React.TouchEvent) => {
if (e.touches.length === 2) {
touchDist0.current = Math.hypot(
e.touches[0].clientX - e.touches[1].clientX,
e.touches[0].clientY - e.touches[1].clientY,
);
}
}, []);
const onTouchMove = useCallback((e: React.TouchEvent) => {
if (e.touches.length === 2) {
const d = Math.hypot(
e.touches[0].clientX - e.touches[1].clientX,
e.touches[0].clientY - e.touches[1].clientY,
);
const delta = touchDist0.current - d;
targetZ.current = Math.max(1.2, Math.min(6, targetZ.current + delta * 0.008));
touchDist0.current = d;
}
}, []);
// ── Render ───────────────────────────────────────────────────────────────────
return (
<div ref={containerRef} className="w-full h-full relative overflow-hidden bg-black">
{!ready && (
<div className="absolute inset-0 flex items-center justify-center z-10 pointer-events-none">
<div className="text-center">
<div className="w-12 h-12 border-2 border-[#ff0040] border-t-transparent rounded-full animate-spin mx-auto mb-3" />
<p className="text-[10px] font-bold uppercase tracking-widest text-gray-500">Loading Globe</p>
</div>
</div>
)}
<canvas
ref={canvasRef}
className="w-full h-full cursor-grab active:cursor-grabbing"
style={{ touchAction: 'none', display: 'block' }}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onWheel={onWheel}
onTouchStart={onTouchStart}
onTouchMove={onTouchMove}
/>
</div>
);
}