433 lines
16 KiB
TypeScript
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>
|
|
);
|
|
}
|