revert: remove 3D globe, restore flat map as default

- Removed SkateGlobe component from view toggle
- ViewMode back to 'map' | 'list', defaulting to 'map'
- Removed Globe import, selectedGlobeMarker state, and all globe render blocks
- Flat Leaflet map loads as default again

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-07-23 16:18:24 +07:00
parent 6331a937f2
commit e8151bee17
2 changed files with 269 additions and 377 deletions

View file

@ -1,22 +1,20 @@
/** /**
* SkateGlobe — 3D interactive globe using pure Three.js * SkateGlobe — 3D interactive globe using pure Three.js
* No h3-js dependency. Uses WebGL for hardware-accelerated rendering.
* *
* Features: * Behaviour:
* - Spherical Earth with night-side texture * - Globe is STATIC by default (no auto-rotation)
* - Atmospheric glow (neon red ring)
* - Spot/shop pins on the globe surface
* - Click pin → zoom camera to it
* - Drag to rotate, scroll/pinch to zoom * - Drag to rotate, scroll/pinch to zoom
* - Auto-rotation when idle * - Spots show as red pins, shops as lime pins
* - ResizeObserver for responsive canvas * - 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 { useRef, useEffect, useCallback, useState, useMemo } from 'react';
import * as THREE from 'three'; import * as THREE from 'three';
import type { SkateSpot, SkateShop } from '@/lib/types'; import type { SkateSpot, SkateShop } from '@/lib/types';
// ─── Types ──────────────────────────────────────────────────────────────────── // ─── Public types ─────────────────────────────────────────────────────────────
export interface GlobeMarker { export interface GlobeMarker {
id: string; id: string;
@ -24,7 +22,6 @@ export interface GlobeMarker {
lng: number; lng: number;
label: string; label: string;
type: 'spot' | 'shop'; type: 'spot' | 'shop';
color: string;
spot?: SkateSpot; spot?: SkateSpot;
shop?: SkateShop; shop?: SkateShop;
} }
@ -39,233 +36,231 @@ interface SkateGlobeProps {
onBackgroundClick?: () => void; onBackgroundClick?: () => void;
} }
// ─── Colour palette ─────────────────────────────────────────────────────────── // ─── Constants ────────────────────────────────────────────────────────────────
const SPOT_COLOR = new THREE.Color('#ff0040');
const SHOP_COLOR = new THREE.Color('#ccff00');
const GLOBE_RADIUS = 1.0;
// ─── Lat/Lng → 3D sphere position ───────────────────────────────────────────── const R = 1.0; // globe radius
function latLngToVec3(lat: number, lng: number, radius: number): THREE.Vector3 { const PIN_R = 0.018; // normal pin radius
const phi = (90 - lat) * (Math.PI / 180); 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); const theta = (lng + 180) * (Math.PI / 180);
return new THREE.Vector3( return new THREE.Vector3(
-radius * Math.sin(phi) * Math.cos(theta), -r * Math.sin(phi) * Math.cos(theta),
radius * Math.cos(phi), r * Math.cos(phi),
radius * Math.sin(phi) * Math.sin(theta) r * Math.sin(phi) * Math.sin(theta),
); );
} }
// ─── Geohash decode ─────────────────────────────────────────────────────────── // ─── Geohash decode ───────────────────────────────────────────────────────────
function decodeGeohash(g: string): { lat: number; lng: number } | null {
if (!g || g.length < 4) return null; const GH32 = '0123456789bcdefghjkmnpqrstuvwxyz';
function decodeGh(gh: string): { lat: number; lng: number } | null {
if (!gh || gh.length < 4) return null;
try { try {
const BASE32 = '0123456789bcdefghjkmnpqrstuvwxyz'; let minLat = -90, maxLat = 90, minLng = -180, maxLng = 180, even = true;
let minLat = -90, maxLat = 90, minLng = -180, maxLng = 180; for (const c of gh.toLowerCase()) {
let isEven = true; const idx = GH32.indexOf(c);
for (const c of g.toLowerCase()) {
const idx = BASE32.indexOf(c);
if (idx < 0) break; if (idx < 0) break;
for (let bits = 4; bits >= 0; bits--) { for (let b = 4; b >= 0; b--) {
const bit = (idx >> bits) & 1; const bit = (idx >> b) & 1;
if (isEven) { if (even) { const m = (minLng + maxLng) / 2; bit ? (minLng = m) : (maxLng = m); }
const mid = (minLng + maxLng) / 2; else { const m = (minLat + maxLat) / 2; bit ? (minLat = m) : (maxLat = m); }
if (bit) minLng = mid; else maxLng = mid; even = !even;
} else {
const mid = (minLat + maxLat) / 2;
if (bit) minLat = mid; else maxLat = mid;
}
isEven = !isEven;
} }
} }
return { lat: (minLat + maxLat) / 2, lng: (minLng + maxLng) / 2 }; return { lat: (minLat + maxLat) / 2, lng: (minLng + maxLng) / 2 };
} catch { } catch { return null; }
return null;
}
} }
function extractCoords(tags: string[][]): { lat: number; lng: number } | null { function getCoords(tags: string[][]): { lat: number; lng: number } | null {
const g = tags.find(([k]) => k === 'g')?.[1]; if (!tags || !Array.isArray(tags)) return null;
if (g) { const c = decodeGeohash(g); if (c) return c; } const gh = tags.find(t => t[0] === 'g')?.[1];
const lat = parseFloat(tags.find(([k]) => k === 'lat')?.[1] ?? ''); if (gh) { const c = decodeGh(gh); if (c) return c; }
const lng = parseFloat(tags.find(([k]) => k === 'lon')?.[1] ?? ''); 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 }; if (!isNaN(lat) && !isNaN(lng)) return { lat, lng };
return null; 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 ──────────────────────────────────────────────────────────────── // ─── Component ────────────────────────────────────────────────────────────────
export function SkateGlobe({ export function SkateGlobe({
spots, spots, shops, showSpots, showShops,
shops, selectedMarkerId, onMarkerClick, onBackgroundClick,
showSpots,
showShops,
selectedMarkerId,
onMarkerClick,
onBackgroundClick,
}: SkateGlobeProps) { }: SkateGlobeProps) {
const containerRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const rendererRef = useRef<THREE.WebGLRenderer | null>(null);
const sceneRef = useRef<THREE.Scene | null>(null);
const cameraRef = useRef<THREE.PerspectiveCamera | null>(null);
const frameRef = useRef<number>(0);
const globeGroupRef = useRef<THREE.Group | null>(null);
const markersGroupRef = useRef<THREE.Group | null>(null);
const raycasterRef = useRef(new THREE.Raycaster());
const mouseRef = useRef(new THREE.Vector2());
// Interaction state const containerRef = useRef<HTMLDivElement>(null);
const isDraggingRef = useRef(false); const canvasRef = useRef<HTMLCanvasElement>(null);
const lastMouseRef = useRef({ x: 0, y: 0 }); const rendererRef = useRef<THREE.WebGLRenderer | null>(null);
const rotationRef = useRef({ x: 0.3, y: 0 }); const cameraRef = useRef<THREE.PerspectiveCamera | null>(null);
const rotationVelRef = useRef({ x: 0, y: 0.002 }); // auto-rotate const globeGrpRef = useRef<THREE.Group | null>(null); // rotates with drag
const zoomRef = useRef(2.5); const pinsGrpRef = useRef<THREE.Group | null>(null); // child of globeGrp
const touchStartDistRef = useRef(0); const raycaster = useRef(new THREE.Raycaster());
const mouse = useRef(new THREE.Vector2());
const [ready, setReady] = useState(false); // drag state
const [markerMap, setMarkerMap] = useState<Map<THREE.Object3D, GlobeMarker>>(new Map()); 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 ────────────────────────────────────────
// Build marker data
const markers = useMemo<GlobeMarker[]>(() => { const markers = useMemo<GlobeMarker[]>(() => {
const result: GlobeMarker[] = []; const out: GlobeMarker[] = [];
if (showSpots) {
for (const spot of spots) { if (showSpots && spots) {
const coords = extractCoords(spot.tags); for (const s of spots) {
if (!coords) continue; if (!s?.tags || !Array.isArray(s.tags)) continue;
const name = spot.tags.find(([k]) => k === 'title')?.[1] const c = getCoords(s.tags);
?? spot.tags.find(([k]) => k === 'name')?.[1] if (!c) continue;
?? 'Unnamed Spot'; out.push({
result.push({ id: `spot-${spot.id}`, lat: coords.lat, lng: coords.lng, label: name, type: 'spot', color: '#ff0040', spot }); id: `spot-${s.id}`,
lat: c.lat, lng: c.lng,
label: getName(s.tags, 'Unnamed Spot'),
type: 'spot', spot: s,
});
} }
} }
if (showShops) {
for (const shop of shops) { if (showShops && shops) {
const coords = extractCoords(shop.tags); for (const sh of shops) {
if (!coords) continue; if (!sh?.tags || !Array.isArray(sh.tags)) continue;
const name = shop.tags.find(([k]) => k === 'title')?.[1] const c = getCoords(sh.tags);
?? shop.tags.find(([k]) => k === 'name')?.[1] if (!c) continue;
?? 'Skate Shop'; out.push({
result.push({ id: `shop-${shop.id}`, lat: coords.lat, lng: coords.lng, label: name, type: 'shop', color: '#ccff00', shop }); id: `shop-${sh.id}`,
lat: c.lat, lng: c.lng,
label: getName(sh.tags, 'Skate Shop'),
type: 'shop', shop: sh,
});
} }
} }
return result;
return out;
}, [spots, shops, showSpots, showShops]); }, [spots, shops, showSpots, showShops]);
// ── Scene setup ───────────────────────────────────────────────────────────── // ── Scene setup — runs once ──────────────────────────────────────────────────
useEffect(() => {
if (!canvasRef.current || !containerRef.current) return;
const canvas = canvasRef.current; useEffect(() => {
const container = containerRef.current; const canvas = canvasRef.current!;
const w = container.clientWidth || 800; const container = containerRef.current!;
const h = container.clientHeight || 600; const W = container.clientWidth || 800;
const H = container.clientHeight || 600;
// Renderer // Renderer
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true }); const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: false });
renderer.setSize(w, h); renderer.setSize(W, H);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setClearColor(0x000000, 0); renderer.setClearColor(0x000000, 1);
rendererRef.current = renderer; rendererRef.current = renderer;
// Scene // Scene
const scene = new THREE.Scene(); const scene = new THREE.Scene();
sceneRef.current = scene;
// Camera
const camera = new THREE.PerspectiveCamera(45, w / h, 0.1, 100);
camera.position.z = zoomRef.current;
cameraRef.current = camera;
// Starfield // Starfield
const starGeom = new THREE.BufferGeometry(); const sfGeo = new THREE.BufferGeometry();
const starCount = 2000; const sfPos = new Float32Array(3000 * 3);
const starPositions = new Float32Array(starCount * 3); for (let i = 0; i < sfPos.length; i++) sfPos[i] = (Math.random() - 0.5) * 100;
for (let i = 0; i < starCount * 3; i++) { sfGeo.setAttribute('position', new THREE.BufferAttribute(sfPos, 3));
starPositions[i] = (Math.random() - 0.5) * 80; scene.add(new THREE.Points(sfGeo,
} new THREE.PointsMaterial({ color: 0xffffff, size: 0.06, transparent: true, opacity: 0.55 })));
starGeom.setAttribute('position', new THREE.BufferAttribute(starPositions, 3));
const starMat = new THREE.PointsMaterial({ color: 0xffffff, size: 0.08, transparent: true, opacity: 0.6 });
scene.add(new THREE.Points(starGeom, starMat));
// Globe group // Camera
const globeGroup = new THREE.Group(); const camera = new THREE.PerspectiveCamera(45, W / H, 0.01, 200);
globeGroupRef.current = globeGroup; camera.position.set(0, 0, CAM_DEFAULT);
scene.add(globeGroup); cameraRef.current = camera;
// Globe sphere — use a procedural texture for the night-side look // Globe group (drag rotates this)
const globeGeom = new THREE.SphereGeometry(GLOBE_RADIUS, 64, 64); const globeGrp = new THREE.Group();
globeGrpRef.current = globeGrp;
scene.add(globeGrp);
// Load Earth texture from CDN // Earth sphere
const textureLoader = new THREE.TextureLoader(); const loader = new THREE.TextureLoader();
const earthTex = textureLoader.load( const earthTex = loader.load(
'https://unpkg.com/three-globe/example/img/earth-night.jpg', 'https://unpkg.com/three-globe/example/img/earth-night.jpg',
() => setReady(true), () => setReady(true),
undefined, undefined,
() => setReady(true) // fallback if texture fails () => setReady(true), // still render on error
); );
const bumpTex = textureLoader.load('https://unpkg.com/three-globe/example/img/earth-topology.png'); 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({ const globeMat = new THREE.MeshPhongMaterial({
map: earthTex, map: earthTex,
bumpMap: bumpTex, specular: new THREE.Color(0x222222),
bumpScale: 0.005, shininess: 8,
specular: new THREE.Color(0x333333),
shininess: 10,
}); });
const globe = new THREE.Mesh(globeGeom, globeMat); const globeMesh = new THREE.Mesh(new THREE.SphereGeometry(R, 64, 64), globeMat);
globeGroup.add(globe); globeGrp.add(globeMesh);
// Atmosphere glow // Atmosphere rim
const atmosGeom = new THREE.SphereGeometry(GLOBE_RADIUS * 1.06, 32, 32); globeGrp.add(new THREE.Mesh(
const atmosMat = new THREE.MeshPhongMaterial({ new THREE.SphereGeometry(R * 1.05, 32, 32),
color: new THREE.Color('#ff0040'), new THREE.MeshPhongMaterial({
transparent: true, color: 0xff0040, transparent: true, opacity: 0.07, side: THREE.BackSide,
opacity: 0.08, }),
side: THREE.FrontSide, ));
});
globeGroup.add(new THREE.Mesh(atmosGeom, atmosMat));
// Lighting // Lighting
const ambientLight = new THREE.AmbientLight(0x404040, 1.5); scene.add(new THREE.AmbientLight(0x404040, 2));
scene.add(ambientLight); const sun = new THREE.DirectionalLight(0xffffff, 2.5);
const sunLight = new THREE.DirectionalLight(0xffffff, 2.0); sun.position.set(5, 3, 5);
sunLight.position.set(5, 3, 5); scene.add(sun);
scene.add(sunLight);
// Markers group // Pins group (child of globe so it rotates with it)
const markersGroup = new THREE.Group(); const pinsGrp = new THREE.Group();
markersGroupRef.current = markersGroup; pinsGrpRef.current = pinsGrp;
globeGroup.add(markersGroup); globeGrp.add(pinsGrp);
// Animation loop // Animation loop — NO auto-rotation, just inertia decay
let animFrame: number; let raf: number;
const animate = () => { const tick = () => {
animFrame = requestAnimationFrame(animate); raf = requestAnimationFrame(tick);
// Apply rotation velocity // Inertia decay when not dragging
rotationRef.current.x += rotationVelRef.current.x * 0.05; if (!dragging.current) {
rotationRef.current.y += rotationVelRef.current.y; velXY.current.x *= 0.88;
velXY.current.y *= 0.88;
// Decay auto-rotation velocity when dragging stops rotXY.current.x += velXY.current.x;
if (!isDraggingRef.current) { rotXY.current.y += velXY.current.y;
rotationVelRef.current.x *= 0.92; rotXY.current.x = Math.max(-Math.PI / 2, Math.min(Math.PI / 2, rotXY.current.x));
// Restore gentle auto-rotate
rotationVelRef.current.y = rotationVelRef.current.y * 0.98 + 0.002 * 0.02;
} }
globeGroup.rotation.x = rotationRef.current.x; globeGrp.rotation.x = rotXY.current.x;
globeGroup.rotation.y = rotationRef.current.y; globeGrp.rotation.y = rotXY.current.y;
// Smooth zoom // Smooth zoom
const targetZ = zoomRef.current; camera.position.z += (targetZ.current - camera.position.z) * 0.12;
camera.position.z += (targetZ - camera.position.z) * 0.1;
renderer.render(scene, camera); renderer.render(scene, camera);
}; };
animate(); tick();
frameRef.current = animFrame!;
// ResizeObserver // Resize
const ro = new ResizeObserver(() => { const ro = new ResizeObserver(() => {
const nw = container.clientWidth; const nw = container.clientWidth;
const nh = container.clientHeight; const nh = container.clientHeight;
@ -277,148 +272,159 @@ export function SkateGlobe({
}); });
ro.observe(container); ro.observe(container);
return () => { return () => { cancelAnimationFrame(raf); ro.disconnect(); renderer.dispose(); };
cancelAnimationFrame(animFrame); }, []); // eslint-disable-line react-hooks/exhaustive-deps
ro.disconnect();
renderer.dispose(); // ── Rebuild pins whenever marker data changes ────────────────────────────────
};
}, []);
// ── Update markers when data changes ────────────────────────────────────────
useEffect(() => { useEffect(() => {
const mg = markersGroupRef.current; const grp = pinsGrpRef.current;
if (!mg) return; if (!grp) return;
// Clear existing pins // Dispose old geometries / materials
while (mg.children.length) mg.remove(mg.children[0]); grp.children.slice().forEach(c => {
const newMap = new Map<THREE.Object3D, GlobeMarker>(); if (c instanceof THREE.Mesh) {
c.geometry.dispose();
(c.material as THREE.Material).dispose();
}
grp.remove(c);
});
for (const marker of markers) { const newMap = new Map<THREE.Mesh, GlobeMarker>();
const pos = latLngToVec3(marker.lat, marker.lng, GLOBE_RADIUS + 0.012);
const isSelected = marker.id === selectedMarkerId;
const color = marker.type === 'spot' ? SPOT_COLOR : SHOP_COLOR;
// Pin dot for (const m of markers) {
const geom = new THREE.SphereGeometry(isSelected ? 0.022 : 0.014, 8, 8); const isSel = m.id === selectedMarkerId;
const mat = new THREE.MeshBasicMaterial({ color: isSelected ? new THREE.Color('#ffffff') : color }); const hex = m.type === 'spot' ? 0xff0040 : 0xccff00;
const mesh = new THREE.Mesh(geom, mat); 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); mesh.position.copy(pos);
mg.add(mesh); grp.add(mesh);
newMap.set(mesh, marker); newMap.set(mesh, m);
// Outer ring for selected // Pulse ring for selected pin
if (isSelected) { if (isSel) {
const ringGeom = new THREE.RingGeometry(0.028, 0.034, 16); const ring = new THREE.Mesh(
const ringMat = new THREE.MeshBasicMaterial({ color: new THREE.Color('#ffffff'), side: THREE.DoubleSide, transparent: true, opacity: 0.7 }); new THREE.RingGeometry(radius * 1.6, radius * 2.0, 20),
const ring = new THREE.Mesh(ringGeom, ringMat); new THREE.MeshBasicMaterial({ color: 0xffffff, side: THREE.DoubleSide, transparent: true, opacity: 0.6 }),
);
ring.position.copy(pos); ring.position.copy(pos);
ring.lookAt(new THREE.Vector3(0, 0, 0).add(pos)); ring.lookAt(new THREE.Vector3(0, 0, 0)); // face away from globe centre
mg.add(ring); grp.add(ring);
} }
} }
setMarkerMap(newMap); setMeshMap(newMap);
}, [markers, selectedMarkerId]); }, [markers, selectedMarkerId]);
// ── Mouse / touch interaction ──────────────────────────────────────────────── // ── Pointer / touch handlers ─────────────────────────────────────────────────
const handlePointerDown = useCallback((e: React.PointerEvent) => {
isDraggingRef.current = false; const onPointerDown = useCallback((e: React.PointerEvent) => {
lastMouseRef.current = { x: e.clientX, y: e.clientY }; 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); canvasRef.current?.setPointerCapture(e.pointerId);
// Stop auto-rotate velocity
rotationVelRef.current = { x: 0, y: 0 };
}, []); }, []);
const handlePointerMove = useCallback((e: React.PointerEvent) => { const onPointerMove = useCallback((e: React.PointerEvent) => {
if (!(e.buttons & 1)) return; if (!dragging.current) return;
const dx = e.clientX - lastMouseRef.current.x; const dx = e.clientX - lastXY.current.x;
const dy = e.clientY - lastMouseRef.current.y; const dy = e.clientY - lastXY.current.y;
if (Math.abs(dx) > 2 || Math.abs(dy) > 2) isDraggingRef.current = true; if (Math.abs(dx) > 2 || Math.abs(dy) > 2) moved.current = true;
rotationVelRef.current.y = dx * 0.004; // Accumulate velocity for inertia
rotationVelRef.current.x = dy * 0.004; velXY.current.y = dx * 0.005;
rotationRef.current.y += dx * 0.004; velXY.current.x = dy * 0.005;
rotationRef.current.x += dy * 0.004; rotXY.current.y += dx * 0.005;
rotationRef.current.x = Math.max(-Math.PI / 2.2, Math.min(Math.PI / 2.2, rotationRef.current.x)); rotXY.current.x += dy * 0.005;
lastMouseRef.current = { x: e.clientX, y: e.clientY }; 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 handlePointerUp = useCallback((e: React.PointerEvent) => { const onPointerUp = useCallback((e: React.PointerEvent) => {
canvasRef.current?.releasePointerCapture(e.pointerId); canvasRef.current?.releasePointerCapture(e.pointerId);
if (!isDraggingRef.current) { dragging.current = false;
// Click — raycast
if (!moved.current) {
// Tap / click — raycast for pins
const rect = canvasRef.current!.getBoundingClientRect(); const rect = canvasRef.current!.getBoundingClientRect();
mouseRef.current.set( mouse.current.set(
((e.clientX - rect.left) / rect.width) * 2 - 1, ((e.clientX - rect.left) / rect.width) * 2 - 1,
-((e.clientY - rect.top) / rect.height) * 2 + 1 -((e.clientY - rect.top) / rect.height) * 2 + 1,
); );
raycasterRef.current.setFromCamera(mouseRef.current, cameraRef.current!); raycaster.current.setFromCamera(mouse.current, cameraRef.current!);
const meshes = Array.from(markerMap.keys()).filter(o => o instanceof THREE.Mesh) as THREE.Mesh[]; const hits = raycaster.current.intersectObjects([...meshMap.keys()]);
const hits = raycasterRef.current.intersectObjects(meshes);
if (hits.length > 0) { if (hits.length > 0) {
const marker = markerMap.get(hits[0].object); const marker = meshMap.get(hits[0].object as THREE.Mesh);
if (marker) { if (marker) {
// Zoom to marker // Rotate globe so pin is facing camera
zoomRef.current = 1.4; const dir = ll2v(marker.lat, marker.lng, 1).normalize();
// Rotate globe so pin faces camera rotXY.current.y = -Math.atan2(dir.x, dir.z);
const target = latLngToVec3(marker.lat, marker.lng, 1); rotXY.current.x = Math.asin(Math.max(-1, Math.min(1, dir.y)));
rotationRef.current.y = -Math.atan2(target.x, target.z); targetZ.current = CAM_ZOOMED;
rotationRef.current.x = Math.asin(target.y);
onMarkerClick(marker); onMarkerClick(marker);
} }
} else { } else {
zoomRef.current = 2.5; targetZ.current = CAM_DEFAULT;
onBackgroundClick?.(); onBackgroundClick?.();
} }
} }
isDraggingRef.current = false; moved.current = false;
}, [markerMap, onMarkerClick, onBackgroundClick]); }, [meshMap, onMarkerClick, onBackgroundClick]);
const handleWheel = useCallback((e: React.WheelEvent) => { const onWheel = useCallback((e: React.WheelEvent) => {
e.preventDefault(); e.preventDefault();
zoomRef.current = Math.max(1.3, Math.min(5, zoomRef.current + e.deltaY * 0.005)); targetZ.current = Math.max(1.2, Math.min(6, targetZ.current + e.deltaY * 0.004));
}, []); }, []);
const handleTouchStart = useCallback((e: React.TouchEvent) => { const onTouchStart = useCallback((e: React.TouchEvent) => {
if (e.touches.length === 2) { if (e.touches.length === 2) {
touchStartDistRef.current = Math.hypot( touchDist0.current = Math.hypot(
e.touches[0].clientX - e.touches[1].clientX, e.touches[0].clientX - e.touches[1].clientX,
e.touches[0].clientY - e.touches[1].clientY e.touches[0].clientY - e.touches[1].clientY,
); );
} }
}, []); }, []);
const handleTouchMove = useCallback((e: React.TouchEvent) => { const onTouchMove = useCallback((e: React.TouchEvent) => {
if (e.touches.length === 2) { if (e.touches.length === 2) {
const dist = Math.hypot( const d = Math.hypot(
e.touches[0].clientX - e.touches[1].clientX, e.touches[0].clientX - e.touches[1].clientX,
e.touches[0].clientY - e.touches[1].clientY e.touches[0].clientY - e.touches[1].clientY,
); );
const delta = touchStartDistRef.current - dist; const delta = touchDist0.current - d;
zoomRef.current = Math.max(1.3, Math.min(5, zoomRef.current + delta * 0.01)); targetZ.current = Math.max(1.2, Math.min(6, targetZ.current + delta * 0.008));
touchStartDistRef.current = dist; touchDist0.current = d;
} }
}, []); }, []);
// ── Render ───────────────────────────────────────────────────────────────────
return ( return (
<div ref={containerRef} className="w-full h-full relative overflow-hidden bg-black globe-container"> <div ref={containerRef} className="w-full h-full relative overflow-hidden bg-black">
{!ready && ( {!ready && (
<div className="absolute inset-0 flex items-center justify-center bg-black z-10 pointer-events-none"> <div className="absolute inset-0 flex items-center justify-center z-10 pointer-events-none">
<div className="text-center"> <div className="text-center">
<div className="w-14 h-14 border-2 border-[#ff0040] border-t-transparent rounded-full animate-spin mx-auto mb-4" /> <div className="w-12 h-12 border-2 border-[#ff0040] border-t-transparent rounded-full animate-spin mx-auto mb-3" />
<p className="text-gray-400 text-xs font-bold uppercase tracking-widest">Rendering Globe</p> <p className="text-[10px] font-bold uppercase tracking-widest text-gray-500">Loading Globe</p>
</div> </div>
</div> </div>
)} )}
<canvas <canvas
ref={canvasRef} ref={canvasRef}
className="w-full h-full cursor-grab active:cursor-grabbing" className="w-full h-full cursor-grab active:cursor-grabbing"
onPointerDown={handlePointerDown} style={{ touchAction: 'none', display: 'block' }}
onPointerMove={handlePointerMove} onPointerDown={onPointerDown}
onPointerUp={handlePointerUp} onPointerMove={onPointerMove}
onWheel={handleWheel} onPointerUp={onPointerUp}
onTouchStart={handleTouchStart} onWheel={onWheel}
onTouchMove={handleTouchMove} onTouchStart={onTouchStart}
style={{ touchAction: 'none' }} onTouchMove={onTouchMove}
/> />
</div> </div>
); );

View file

@ -43,7 +43,6 @@ import {
Plus, Plus,
List, List,
Map, Map,
Globe,
X, X,
ShoppingBag, ShoppingBag,
Route, Route,
@ -54,7 +53,6 @@ import {
Zap, Zap,
Lock, Lock,
Users, Users,
Globe,
ArrowRightLeft, ArrowRightLeft,
Crown, Crown,
Shield, Shield,
@ -69,10 +67,9 @@ import { CameraCapture } from '@/components/CameraCapture';
import { useUploadFile } from '@/hooks/useUploadFile'; import { useUploadFile } from '@/hooks/useUploadFile';
import { useToast } from '@/hooks/useToast'; import { useToast } from '@/hooks/useToast';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import type { GlobeMarker } from '@/components/SkateGlobe';
const SkateGlobe = lazy(() => import('@/components/SkateGlobe').then(m => ({ default: m.SkateGlobe })));
type ViewMode = 'map' | 'list' | 'globe';
type ViewMode = 'map' | 'list';
// Compact stash item card for the drawer // Compact stash item card for the drawer
function MiniStashCard({ item, onDelete }: { item: StashItem; onDelete: () => void }) { function MiniStashCard({ item, onDelete }: { item: StashItem; onDelete: () => void }) {
@ -349,7 +346,6 @@ export default function MapPage() {
const focusGear = viewParam === 'gear'; const focusGear = viewParam === 'gear';
const [viewMode, setViewMode] = useState<ViewMode>('map'); const [viewMode, setViewMode] = useState<ViewMode>('map');
const [selectedGlobeMarker, setSelectedGlobeMarker] = useState<GlobeMarker | null>(null);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [selectedCategories, setSelectedCategories] = useState<string[]>([]); const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
const [selectedSpot, setSelectedSpot] = useState<SkateSpot | null>(null); const [selectedSpot, setSelectedSpot] = useState<SkateSpot | null>(null);
@ -647,7 +643,7 @@ export default function MapPage() {
/> />
</div> </div>
{/* View toggle - Map / List / Globe */} {/* View toggle - Map / List */}
<div className="flex items-center rounded-lg border bg-muted p-0.5 flex-shrink-0"> <div className="flex items-center rounded-lg border bg-muted p-0.5 flex-shrink-0">
<Button <Button
variant={viewMode === 'map' ? 'default' : 'ghost'} variant={viewMode === 'map' ? 'default' : 'ghost'}
@ -667,19 +663,6 @@ export default function MapPage() {
<List className="h-4 w-4" /> <List className="h-4 w-4" />
<span className="hidden sm:inline ml-2">{t('mapPage.listView')}</span> <span className="hidden sm:inline ml-2">{t('mapPage.listView')}</span>
</Button> </Button>
<Button
variant={viewMode === 'globe' ? 'default' : 'ghost'}
size="sm"
className={cn(
'h-9 w-9 sm:w-auto sm:px-3 p-0',
viewMode === 'globe' && 'bg-[#ff0040] text-white hover:bg-[#ff0040]/90'
)}
onClick={() => setViewMode('globe')}
title="3D Globe View"
>
<Globe className="h-4 w-4" />
<span className="hidden sm:inline ml-2">Globe</span>
</Button>
</div> </div>
{/* Mobile: Filters button that opens sheet */} {/* Mobile: Filters button that opens sheet */}
@ -1100,104 +1083,7 @@ export default function MapPage() {
{/* Main Content */} {/* Main Content */}
<div className="flex-1 relative"> <div className="flex-1 relative">
{viewMode === 'globe' ? ( {viewMode === 'map' ? (
/* ── 3D Globe View ─────────────────────────────────────────── */
<div className="h-full relative bg-black">
<Suspense fallback={
<div className="h-full flex items-center justify-center bg-black">
<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-gray-400 text-sm uppercase tracking-widest font-bold">Loading Globe...</p>
</div>
</div>
}>
<SkateGlobe
spots={spots || []}
shops={shops || []}
showSpots={showSpots}
showShops={showShops}
selectedMarkerId={selectedGlobeMarker?.id ?? null}
onMarkerClick={(marker) => {
setSelectedGlobeMarker(marker);
if (marker.spot) setSelectedSpot(marker.spot);
}}
onBackgroundClick={() => {
setSelectedGlobeMarker(null);
setSelectedSpot(null);
}}
/>
</Suspense>
{/* Globe legend overlay */}
<div className="absolute top-4 left-4 flex flex-col gap-1.5 pointer-events-none">
<div className="flex items-center gap-2 bg-black/70 backdrop-blur px-3 py-1.5 rounded-full border border-white/10 text-xs font-bold text-white">
<span className="w-2.5 h-2.5 rounded-full bg-[#ff0040]" />
Spots ({(spots || []).length})
</div>
<div className="flex items-center gap-2 bg-black/70 backdrop-blur px-3 py-1.5 rounded-full border border-white/10 text-xs font-bold text-white">
<span className="w-2.5 h-2.5 rounded-full bg-[#ccff00]" />
Shops ({(shops || []).length})
</div>
</div>
{/* Selected spot info card */}
{selectedGlobeMarker && (
<div className="absolute bottom-6 left-1/2 -translate-x-1/2 w-[min(380px,90vw)] bg-black/90 backdrop-blur border border-[#ff0040]/40 rounded-2xl p-4 shadow-2xl shadow-[#ff0040]/20 z-10">
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className={cn(
'text-xs font-bold uppercase px-2 py-0.5 rounded-full',
selectedGlobeMarker.type === 'spot'
? 'bg-[#ff0040]/20 text-[#ff0040]'
: 'bg-[#ccff00]/20 text-[#ccff00]'
)}>
{selectedGlobeMarker.type === 'spot' ? '🛹 Spot' : '🏪 Shop'}
</span>
</div>
<h3 className="font-bold text-white text-base truncate">{selectedGlobeMarker.label}</h3>
<p className="text-xs text-gray-400 mt-0.5">
{selectedGlobeMarker.lat.toFixed(4)}°, {selectedGlobeMarker.lng.toFixed(4)}°
</p>
</div>
<div className="flex flex-col gap-2 flex-shrink-0">
{selectedGlobeMarker.spot && (
<Link
to={`/spot/${selectedGlobeMarker.spot.pubkey}/${selectedGlobeMarker.spot.tags.find(([k]) => k === 'd')?.[1] ?? ''}`}
className="text-xs bg-[#ff0040] hover:bg-[#ff0040]/90 text-white font-bold px-3 py-1.5 rounded-full transition-colors"
>
View Spot →
</Link>
)}
{selectedGlobeMarker.shop && (
<Link
to={`/shop/${selectedGlobeMarker.shop.pubkey}/${selectedGlobeMarker.shop.tags.find(([k]) => k === 'd')?.[1] ?? ''}`}
className="text-xs bg-[#ccff00] hover:bg-[#ccff00]/90 text-black font-bold px-3 py-1.5 rounded-full transition-colors"
>
View Shop →
</Link>
)}
<button
onClick={() => { setSelectedGlobeMarker(null); setSelectedSpot(null); }}
className="text-xs text-gray-500 hover:text-white transition-colors text-center"
>
✕ Close
</button>
</div>
</div>
</div>
)}
{/* Hint overlay */}
{!selectedGlobeMarker && (
<div className="absolute bottom-6 left-1/2 -translate-x-1/2 pointer-events-none">
<p className="text-xs text-gray-500 bg-black/60 px-3 py-1.5 rounded-full border border-white/10">
Drag to rotate • Scroll to zoom • Click a pin to explore
</p>
</div>
)}
</div>
) : viewMode === 'map' ? (
<> <>
<Suspense fallback={ <Suspense fallback={
<div className="h-full bg-black/50 flex items-center justify-center"> <div className="h-full bg-black/50 flex items-center justify-center">