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:
parent
6331a937f2
commit
e8151bee17
2 changed files with 269 additions and 377 deletions
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Reference in a new issue