/** * 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(null); const canvasRef = useRef(null); const rendererRef = useRef(null); const cameraRef = useRef(null); const globeGrpRef = useRef(null); // rotates with drag const pinsGrpRef = useRef(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>(new Map()); const [ready, setReady] = useState(false); // ── Build marker list from Nostr data ──────────────────────────────────────── const markers = useMemo(() => { 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(); 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 (
{!ready && (

Loading Globe

)}
); }