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
* 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<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 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<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());
const [ready, setReady] = useState(false);
const [markerMap, setMarkerMap] = useState<Map<THREE.Object3D, GlobeMarker>>(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<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 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<THREE.Object3D, GlobeMarker>();
// 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<THREE.Mesh, GlobeMarker>();
// 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 (
<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 && (
<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="w-14 h-14 border-2 border-[#ff0040] border-t-transparent rounded-full animate-spin mx-auto mb-4" />
<p className="text-gray-400 text-xs font-bold uppercase tracking-widest">Rendering Globe</p>
<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"
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onWheel={handleWheel}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
style={{ touchAction: 'none' }}
style={{ touchAction: 'none', display: 'block' }}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onWheel={onWheel}
onTouchStart={onTouchStart}
onTouchMove={onTouchMove}
/>
</div>
);

View file

@ -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<ViewMode>('map');
const [selectedGlobeMarker, setSelectedGlobeMarker] = useState<GlobeMarker | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
const [selectedSpot, setSelectedSpot] = useState<SkateSpot | null>(null);
@ -647,7 +643,7 @@ export default function MapPage() {
/>
</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">
<Button
variant={viewMode === 'map' ? 'default' : 'ghost'}
@ -667,19 +663,6 @@ export default function MapPage() {
<List className="h-4 w-4" />
<span className="hidden sm:inline ml-2">{t('mapPage.listView')}</span>
</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>
{/* Mobile: Filters button that opens sheet */}
@ -1100,104 +1083,7 @@ export default function MapPage() {
{/* Main Content */}
<div className="flex-1 relative">
{viewMode === 'globe' ? (
/* ── 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' ? (
{viewMode === 'map' ? (
<>
<Suspense fallback={
<div className="h-full bg-black/50 flex items-center justify-center">