diff --git a/src/components/SkateGlobe.tsx b/src/components/SkateGlobe.tsx index 45658ef..88e5006 100644 --- a/src/components/SkateGlobe.tsx +++ b/src/components/SkateGlobe.tsx @@ -1,22 +1,20 @@ /** * SkateGlobe — 3D interactive globe using pure Three.js - * No h3-js dependency. Uses WebGL for hardware-accelerated rendering. * - * Features: - * - Spherical Earth with night-side texture - * - Atmospheric glow (neon red ring) - * - Spot/shop pins on the globe surface - * - Click pin → zoom camera to it + * Behaviour: + * - Globe is STATIC by default (no auto-rotation) * - Drag to rotate, scroll/pinch to zoom - * - Auto-rotation when idle - * - ResizeObserver for responsive canvas + * - 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'; -// ─── Types ──────────────────────────────────────────────────────────────────── +// ─── Public types ───────────────────────────────────────────────────────────── export interface GlobeMarker { id: string; @@ -24,7 +22,6 @@ export interface GlobeMarker { lng: number; label: string; type: 'spot' | 'shop'; - color: string; spot?: SkateSpot; shop?: SkateShop; } @@ -39,233 +36,231 @@ interface SkateGlobeProps { onBackgroundClick?: () => void; } -// ─── Colour palette ─────────────────────────────────────────────────────────── -const SPOT_COLOR = new THREE.Color('#ff0040'); -const SHOP_COLOR = new THREE.Color('#ccff00'); -const GLOBE_RADIUS = 1.0; +// ─── Constants ──────────────────────────────────────────────────────────────── -// ─── Lat/Lng → 3D sphere position ───────────────────────────────────────────── -function latLngToVec3(lat: number, lng: number, radius: number): THREE.Vector3 { - const phi = (90 - lat) * (Math.PI / 180); +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( - -radius * Math.sin(phi) * Math.cos(theta), - radius * Math.cos(phi), - radius * Math.sin(phi) * Math.sin(theta) + -r * Math.sin(phi) * Math.cos(theta), + r * Math.cos(phi), + r * Math.sin(phi) * Math.sin(theta), ); } // ─── 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 { - const BASE32 = '0123456789bcdefghjkmnpqrstuvwxyz'; - let minLat = -90, maxLat = 90, minLng = -180, maxLng = 180; - let isEven = true; - for (const c of g.toLowerCase()) { - const idx = BASE32.indexOf(c); + 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 bits = 4; bits >= 0; bits--) { - const bit = (idx >> bits) & 1; - if (isEven) { - const mid = (minLng + maxLng) / 2; - if (bit) minLng = mid; else maxLng = mid; - } else { - const mid = (minLat + maxLat) / 2; - if (bit) minLat = mid; else maxLat = mid; - } - isEven = !isEven; + for (let b = 4; b >= 0; b--) { + const bit = (idx >> b) & 1; + if (even) { const m = (minLng + maxLng) / 2; bit ? (minLng = m) : (maxLng = m); } + else { const m = (minLat + maxLat) / 2; bit ? (minLat = m) : (maxLat = m); } + even = !even; } } return { lat: (minLat + maxLat) / 2, lng: (minLng + maxLng) / 2 }; - } catch { - return null; - } + } catch { return null; } } -function extractCoords(tags: string[][]): { lat: number; lng: number } | null { - const g = tags.find(([k]) => k === 'g')?.[1]; - if (g) { const c = decodeGeohash(g); if (c) return c; } - const lat = parseFloat(tags.find(([k]) => k === 'lat')?.[1] ?? ''); - const lng = parseFloat(tags.find(([k]) => k === 'lon')?.[1] ?? ''); +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, + spots, shops, showSpots, showShops, + selectedMarkerId, onMarkerClick, onBackgroundClick, }: SkateGlobeProps) { - const containerRef = useRef(null); - const canvasRef = useRef(null); - const rendererRef = useRef(null); - const sceneRef = useRef(null); - const cameraRef = useRef(null); - const frameRef = useRef(0); - const globeGroupRef = useRef(null); - const markersGroupRef = useRef(null); - const raycasterRef = useRef(new THREE.Raycaster()); - const mouseRef = useRef(new THREE.Vector2()); - // Interaction state - const isDraggingRef = useRef(false); - const lastMouseRef = useRef({ x: 0, y: 0 }); - const rotationRef = useRef({ x: 0.3, y: 0 }); - const rotationVelRef = useRef({ x: 0, y: 0.002 }); // auto-rotate - const zoomRef = useRef(2.5); - const touchStartDistRef = useRef(0); + 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()); - const [ready, setReady] = useState(false); - const [markerMap, setMarkerMap] = useState>(new Map()); + // 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 ──────────────────────────────────────── - // Build marker data const markers = useMemo(() => { - const result: GlobeMarker[] = []; - if (showSpots) { - for (const spot of spots) { - const coords = extractCoords(spot.tags); - if (!coords) continue; - const name = spot.tags.find(([k]) => k === 'title')?.[1] - ?? spot.tags.find(([k]) => k === 'name')?.[1] - ?? 'Unnamed Spot'; - result.push({ id: `spot-${spot.id}`, lat: coords.lat, lng: coords.lng, label: name, type: 'spot', color: '#ff0040', spot }); + const out: GlobeMarker[] = []; + + if (showSpots && spots) { + for (const s of spots) { + if (!s?.tags || !Array.isArray(s.tags)) continue; + const c = getCoords(s.tags); + if (!c) continue; + out.push({ + 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) { - const coords = extractCoords(shop.tags); - if (!coords) continue; - const name = shop.tags.find(([k]) => k === 'title')?.[1] - ?? shop.tags.find(([k]) => k === 'name')?.[1] - ?? 'Skate Shop'; - result.push({ id: `shop-${shop.id}`, lat: coords.lat, lng: coords.lng, label: name, type: 'shop', color: '#ccff00', shop }); + + if (showShops && shops) { + for (const sh of shops) { + if (!sh?.tags || !Array.isArray(sh.tags)) continue; + const c = getCoords(sh.tags); + if (!c) continue; + out.push({ + 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]); - // ── Scene setup ───────────────────────────────────────────────────────────── - useEffect(() => { - if (!canvasRef.current || !containerRef.current) return; + // ── Scene setup — runs once ────────────────────────────────────────────────── - const canvas = canvasRef.current; - const container = containerRef.current; - const w = container.clientWidth || 800; - const h = container.clientHeight || 600; + 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: true }); - renderer.setSize(w, h); - renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); - renderer.setClearColor(0x000000, 0); + 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(); - sceneRef.current = scene; - - // Camera - const camera = new THREE.PerspectiveCamera(45, w / h, 0.1, 100); - camera.position.z = zoomRef.current; - cameraRef.current = camera; // Starfield - const starGeom = new THREE.BufferGeometry(); - const starCount = 2000; - const starPositions = new Float32Array(starCount * 3); - for (let i = 0; i < starCount * 3; i++) { - starPositions[i] = (Math.random() - 0.5) * 80; - } - 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)); + 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 }))); - // Globe group - const globeGroup = new THREE.Group(); - globeGroupRef.current = globeGroup; - scene.add(globeGroup); + // Camera + const camera = new THREE.PerspectiveCamera(45, W / H, 0.01, 200); + camera.position.set(0, 0, CAM_DEFAULT); + cameraRef.current = camera; - // Globe sphere — use a procedural texture for the night-side look - const globeGeom = new THREE.SphereGeometry(GLOBE_RADIUS, 64, 64); + // Globe group (drag rotates this) + const globeGrp = new THREE.Group(); + globeGrpRef.current = globeGrp; + scene.add(globeGrp); - // Load Earth texture from CDN - const textureLoader = new THREE.TextureLoader(); - const earthTex = textureLoader.load( + // 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) // 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({ map: earthTex, - bumpMap: bumpTex, - bumpScale: 0.005, - specular: new THREE.Color(0x333333), - shininess: 10, + specular: new THREE.Color(0x222222), + shininess: 8, }); - const globe = new THREE.Mesh(globeGeom, globeMat); - globeGroup.add(globe); + const globeMesh = new THREE.Mesh(new THREE.SphereGeometry(R, 64, 64), globeMat); + globeGrp.add(globeMesh); - // Atmosphere glow - const atmosGeom = new THREE.SphereGeometry(GLOBE_RADIUS * 1.06, 32, 32); - const atmosMat = new THREE.MeshPhongMaterial({ - color: new THREE.Color('#ff0040'), - transparent: true, - opacity: 0.08, - side: THREE.FrontSide, - }); - globeGroup.add(new THREE.Mesh(atmosGeom, atmosMat)); + // 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 - const ambientLight = new THREE.AmbientLight(0x404040, 1.5); - scene.add(ambientLight); - const sunLight = new THREE.DirectionalLight(0xffffff, 2.0); - sunLight.position.set(5, 3, 5); - scene.add(sunLight); + scene.add(new THREE.AmbientLight(0x404040, 2)); + const sun = new THREE.DirectionalLight(0xffffff, 2.5); + sun.position.set(5, 3, 5); + scene.add(sun); - // Markers group - const markersGroup = new THREE.Group(); - markersGroupRef.current = markersGroup; - globeGroup.add(markersGroup); + // Pins group (child of globe so it rotates with it) + const pinsGrp = new THREE.Group(); + pinsGrpRef.current = pinsGrp; + globeGrp.add(pinsGrp); - // Animation loop - let animFrame: number; - const animate = () => { - animFrame = requestAnimationFrame(animate); + // Animation loop — NO auto-rotation, just inertia decay + let raf: number; + const tick = () => { + raf = requestAnimationFrame(tick); - // Apply rotation velocity - rotationRef.current.x += rotationVelRef.current.x * 0.05; - rotationRef.current.y += rotationVelRef.current.y; - - // Decay auto-rotation velocity when dragging stops - if (!isDraggingRef.current) { - rotationVelRef.current.x *= 0.92; - // Restore gentle auto-rotate - rotationVelRef.current.y = rotationVelRef.current.y * 0.98 + 0.002 * 0.02; + // 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)); } - globeGroup.rotation.x = rotationRef.current.x; - globeGroup.rotation.y = rotationRef.current.y; + globeGrp.rotation.x = rotXY.current.x; + globeGrp.rotation.y = rotXY.current.y; // Smooth zoom - const targetZ = zoomRef.current; - camera.position.z += (targetZ - camera.position.z) * 0.1; + camera.position.z += (targetZ.current - camera.position.z) * 0.12; renderer.render(scene, camera); }; - animate(); - frameRef.current = animFrame!; + tick(); - // ResizeObserver + // Resize const ro = new ResizeObserver(() => { const nw = container.clientWidth; const nh = container.clientHeight; @@ -277,148 +272,159 @@ export function SkateGlobe({ }); ro.observe(container); - return () => { - cancelAnimationFrame(animFrame); - ro.disconnect(); - renderer.dispose(); - }; - }, []); + return () => { cancelAnimationFrame(raf); ro.disconnect(); renderer.dispose(); }; + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + // ── Rebuild pins whenever marker data changes ──────────────────────────────── - // ── Update markers when data changes ──────────────────────────────────────── useEffect(() => { - const mg = markersGroupRef.current; - if (!mg) return; + const grp = pinsGrpRef.current; + if (!grp) return; - // Clear existing pins - while (mg.children.length) mg.remove(mg.children[0]); - const newMap = new Map(); + // 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); + }); - for (const marker of markers) { - 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; + const newMap = new Map(); - // Pin dot - const geom = new THREE.SphereGeometry(isSelected ? 0.022 : 0.014, 8, 8); - const mat = new THREE.MeshBasicMaterial({ color: isSelected ? new THREE.Color('#ffffff') : color }); - const mesh = new THREE.Mesh(geom, mat); + 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); - mg.add(mesh); - newMap.set(mesh, marker); + grp.add(mesh); + newMap.set(mesh, m); - // Outer ring for selected - if (isSelected) { - const ringGeom = new THREE.RingGeometry(0.028, 0.034, 16); - const ringMat = new THREE.MeshBasicMaterial({ color: new THREE.Color('#ffffff'), side: THREE.DoubleSide, transparent: true, opacity: 0.7 }); - const ring = new THREE.Mesh(ringGeom, ringMat); + // 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).add(pos)); - mg.add(ring); + ring.lookAt(new THREE.Vector3(0, 0, 0)); // face away from globe centre + grp.add(ring); } } - setMarkerMap(newMap); + setMeshMap(newMap); }, [markers, selectedMarkerId]); - // ── Mouse / touch interaction ──────────────────────────────────────────────── - const handlePointerDown = useCallback((e: React.PointerEvent) => { - isDraggingRef.current = false; - lastMouseRef.current = { x: e.clientX, y: e.clientY }; + // ── 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); - // Stop auto-rotate velocity - rotationVelRef.current = { x: 0, y: 0 }; }, []); - const handlePointerMove = useCallback((e: React.PointerEvent) => { - if (!(e.buttons & 1)) return; - const dx = e.clientX - lastMouseRef.current.x; - const dy = e.clientY - lastMouseRef.current.y; - if (Math.abs(dx) > 2 || Math.abs(dy) > 2) isDraggingRef.current = true; - rotationVelRef.current.y = dx * 0.004; - rotationVelRef.current.x = dy * 0.004; - rotationRef.current.y += dx * 0.004; - rotationRef.current.x += dy * 0.004; - rotationRef.current.x = Math.max(-Math.PI / 2.2, Math.min(Math.PI / 2.2, rotationRef.current.x)); - lastMouseRef.current = { x: e.clientX, y: e.clientY }; + 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 handlePointerUp = useCallback((e: React.PointerEvent) => { + const onPointerUp = useCallback((e: React.PointerEvent) => { canvasRef.current?.releasePointerCapture(e.pointerId); - if (!isDraggingRef.current) { - // Click — raycast + dragging.current = false; + + if (!moved.current) { + // Tap / click — raycast for pins const rect = canvasRef.current!.getBoundingClientRect(); - mouseRef.current.set( - ((e.clientX - rect.left) / rect.width) * 2 - 1, - -((e.clientY - rect.top) / rect.height) * 2 + 1 + mouse.current.set( + ((e.clientX - rect.left) / rect.width) * 2 - 1, + -((e.clientY - rect.top) / rect.height) * 2 + 1, ); - raycasterRef.current.setFromCamera(mouseRef.current, cameraRef.current!); - const meshes = Array.from(markerMap.keys()).filter(o => o instanceof THREE.Mesh) as THREE.Mesh[]; - const hits = raycasterRef.current.intersectObjects(meshes); + raycaster.current.setFromCamera(mouse.current, cameraRef.current!); + const hits = raycaster.current.intersectObjects([...meshMap.keys()]); + if (hits.length > 0) { - const marker = markerMap.get(hits[0].object); + const marker = meshMap.get(hits[0].object as THREE.Mesh); if (marker) { - // Zoom to marker - zoomRef.current = 1.4; - // Rotate globe so pin faces camera - const target = latLngToVec3(marker.lat, marker.lng, 1); - rotationRef.current.y = -Math.atan2(target.x, target.z); - rotationRef.current.x = Math.asin(target.y); + // 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 { - zoomRef.current = 2.5; + targetZ.current = CAM_DEFAULT; onBackgroundClick?.(); } } - isDraggingRef.current = false; - }, [markerMap, onMarkerClick, onBackgroundClick]); + moved.current = false; + }, [meshMap, onMarkerClick, onBackgroundClick]); - const handleWheel = useCallback((e: React.WheelEvent) => { + const onWheel = useCallback((e: React.WheelEvent) => { 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) { - touchStartDistRef.current = Math.hypot( + touchDist0.current = Math.hypot( 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) { - const dist = Math.hypot( + const d = Math.hypot( 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; - zoomRef.current = Math.max(1.3, Math.min(5, zoomRef.current + delta * 0.01)); - touchStartDistRef.current = dist; + 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 && ( -
+
-
-

Rendering Globe

+
+

Loading Globe

)}
); diff --git a/src/pages/MapPage.tsx b/src/pages/MapPage.tsx index d6a7463..3afa86c 100644 --- a/src/pages/MapPage.tsx +++ b/src/pages/MapPage.tsx @@ -43,7 +43,6 @@ import { Plus, List, Map, - Globe, X, ShoppingBag, Route, @@ -54,7 +53,6 @@ import { Zap, Lock, Users, - Globe, ArrowRightLeft, Crown, Shield, @@ -69,10 +67,9 @@ import { CameraCapture } from '@/components/CameraCapture'; import { useUploadFile } from '@/hooks/useUploadFile'; import { useToast } from '@/hooks/useToast'; 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 function MiniStashCard({ item, onDelete }: { item: StashItem; onDelete: () => void }) { @@ -349,7 +346,6 @@ export default function MapPage() { const focusGear = viewParam === 'gear'; const [viewMode, setViewMode] = useState('map'); - const [selectedGlobeMarker, setSelectedGlobeMarker] = useState(null); const [searchQuery, setSearchQuery] = useState(''); const [selectedCategories, setSelectedCategories] = useState([]); const [selectedSpot, setSelectedSpot] = useState(null); @@ -647,7 +643,7 @@ export default function MapPage() { />
- {/* View toggle - Map / List / Globe */} + {/* View toggle - Map / List */}
-
{/* Mobile: Filters button that opens sheet */} @@ -1100,104 +1083,7 @@ export default function MapPage() { {/* Main Content */}
- {viewMode === 'globe' ? ( - /* ── 3D Globe View ─────────────────────────────────────────── */ -
- -
-
-

Loading Globe...

-
-
- }> - { - setSelectedGlobeMarker(marker); - if (marker.spot) setSelectedSpot(marker.spot); - }} - onBackgroundClick={() => { - setSelectedGlobeMarker(null); - setSelectedSpot(null); - }} - /> -
- - {/* Globe legend overlay */} -
-
- - Spots ({(spots || []).length}) -
-
- - Shops ({(shops || []).length}) -
-
- - {/* Selected spot info card */} - {selectedGlobeMarker && ( -
-
-
-
- - {selectedGlobeMarker.type === 'spot' ? '🛹 Spot' : '🏪 Shop'} - -
-

{selectedGlobeMarker.label}

-

- {selectedGlobeMarker.lat.toFixed(4)}°, {selectedGlobeMarker.lng.toFixed(4)}° -

-
-
- {selectedGlobeMarker.spot && ( - 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 → - - )} - {selectedGlobeMarker.shop && ( - 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 → - - )} - -
-
-
- )} - - {/* Hint overlay */} - {!selectedGlobeMarker && ( -
-

- Drag to rotate • Scroll to zoom • Click a pin to explore -

-
- )} -
- ) : viewMode === 'map' ? ( + {viewMode === 'map' ? ( <>