v113: Enable map navigation + full-screen island terrain view
- Removed auto-rotation, enabled drag/pan/zoom navigation - Added navigation controls (zoom buttons) - Islands placed in a grid with jitter (25° spacing) - Clicking island opens full-screen overlay with island terrain - Island shape rendered as SVG background (ocean, sand, grass, trees) - Posts displayed scattered on the island terrain Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
6422aeebfe
commit
e1956b1383
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "sociowire-frontend",
|
"name": "sociowire-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.112",
|
"version": "0.0.113",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host 0.0.0.0",
|
"dev": "vite --host 0.0.0.0",
|
||||||
|
|
|
||||||
|
|
@ -366,21 +366,27 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Island Terrain Overlay - Full screen when zoomed */
|
/* Island Full-screen Overlay */
|
||||||
.island-terrain-overlay {
|
.island-fullscreen-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: 500;
|
z-index: 700;
|
||||||
pointer-events: none;
|
background: #0a1628;
|
||||||
transition: opacity 0.3s ease;
|
animation: islandFadeIn 0.4s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.island-terrain-overlay.zooming {
|
@keyframes islandFadeIn {
|
||||||
opacity: 0.5;
|
from { opacity: 0; transform: scale(0.95); }
|
||||||
|
to { opacity: 1; transform: scale(1); }
|
||||||
}
|
}
|
||||||
|
|
||||||
.island-terrain-overlay.ready {
|
/* Full-screen island terrain SVG background */
|
||||||
opacity: 1;
|
.island-terrain-bg {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
|
|
||||||
.island-terrain-header {
|
.island-terrain-header {
|
||||||
|
|
@ -471,9 +477,10 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Posts container - overlay for posts on island */
|
/* Posts container - overlay for posts on island */
|
||||||
.island-terrain-posts {
|
.island-terrain-content {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
|
top: 80px;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -58,33 +58,14 @@ export default function IslandsWorld({ theme, onIslandClick, onOpenMap, onOpenPl
|
||||||
)
|
)
|
||||||
: islands;
|
: islands;
|
||||||
|
|
||||||
// Handle zoom to island - fills the screen with the island terrain
|
// Handle zoom to island - shows full-screen island terrain overlay
|
||||||
const handleZoomToIsland = useCallback(async (island) => {
|
const handleZoomToIsland = useCallback(async (island) => {
|
||||||
if (!mapRef.current || isZooming) return;
|
if (isZooming) return;
|
||||||
|
|
||||||
const pos = islandPositionsRef.current.get(island.username || island.id);
|
|
||||||
if (!pos) return;
|
|
||||||
|
|
||||||
setIsZooming(true);
|
setIsZooming(true);
|
||||||
setZoomedIsland(island);
|
setZoomedIsland(island);
|
||||||
setLoadingPosts(true);
|
setLoadingPosts(true);
|
||||||
|
|
||||||
// Stop the rotation animation
|
|
||||||
if (animationRef.current) {
|
|
||||||
cancelAnimationFrame(animationRef.current);
|
|
||||||
animationRef.current = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fly to island - zoom high enough so island fills screen
|
|
||||||
mapRef.current.flyTo({
|
|
||||||
center: [pos.lng, pos.lat],
|
|
||||||
zoom: 16,
|
|
||||||
pitch: 0,
|
|
||||||
bearing: 0,
|
|
||||||
duration: 2000,
|
|
||||||
essential: true
|
|
||||||
});
|
|
||||||
|
|
||||||
// Fetch posts for this island
|
// Fetch posts for this island
|
||||||
try {
|
try {
|
||||||
const posts = await fetchPosts({ username: island.username, limit: 50 });
|
const posts = await fetchPosts({ username: island.username, limit: 50 });
|
||||||
|
|
@ -94,42 +75,16 @@ export default function IslandsWorld({ theme, onIslandClick, onOpenMap, onOpenPl
|
||||||
setIslandPosts([]);
|
setIslandPosts([]);
|
||||||
}
|
}
|
||||||
setLoadingPosts(false);
|
setLoadingPosts(false);
|
||||||
|
setIsZooming(false);
|
||||||
mapRef.current.once('moveend', () => {
|
|
||||||
setIsZooming(false);
|
|
||||||
});
|
|
||||||
}, [isZooming]);
|
}, [isZooming]);
|
||||||
|
|
||||||
// Handle zoom out (back to world view)
|
// Handle zoom out (back to world view)
|
||||||
const handleZoomOut = useCallback(() => {
|
const handleZoomOut = useCallback(() => {
|
||||||
if (!mapRef.current || isZooming) return;
|
if (isZooming) return;
|
||||||
|
|
||||||
setIsZooming(true);
|
setZoomedIsland(null);
|
||||||
setIslandPosts([]);
|
setIslandPosts([]);
|
||||||
|
setIsZooming(false);
|
||||||
mapRef.current.flyTo({
|
|
||||||
center: [0, 0],
|
|
||||||
zoom: 1,
|
|
||||||
pitch: 50,
|
|
||||||
bearing: 0,
|
|
||||||
duration: 1500,
|
|
||||||
essential: true
|
|
||||||
});
|
|
||||||
|
|
||||||
mapRef.current.once('moveend', () => {
|
|
||||||
setZoomedIsland(null);
|
|
||||||
setIsZooming(false);
|
|
||||||
|
|
||||||
// Restart rotation animation
|
|
||||||
let bearing = 0;
|
|
||||||
function animate() {
|
|
||||||
bearing += 0.05;
|
|
||||||
if (bearing >= 360) bearing = 0;
|
|
||||||
mapRef.current?.rotateTo(bearing, { duration: 100 });
|
|
||||||
animationRef.current = requestAnimationFrame(animate);
|
|
||||||
}
|
|
||||||
animate();
|
|
||||||
});
|
|
||||||
}, [isZooming]);
|
}, [isZooming]);
|
||||||
|
|
||||||
// Initialize MapLibre
|
// Initialize MapLibre
|
||||||
|
|
@ -155,15 +110,21 @@ export default function IslandsWorld({ theme, onIslandClick, onOpenMap, onOpenPl
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
center: [centerLng, centerLat],
|
center: [centerLng, centerLat],
|
||||||
zoom: 1,
|
zoom: 2,
|
||||||
pitch: 50,
|
minZoom: 1,
|
||||||
|
maxZoom: 18,
|
||||||
|
pitch: 0,
|
||||||
bearing: 0,
|
bearing: 0,
|
||||||
antialias: true,
|
antialias: true,
|
||||||
attributionControl: false
|
attributionControl: false,
|
||||||
|
dragRotate: false // Disable rotation, keep simple 2D navigation
|
||||||
});
|
});
|
||||||
|
|
||||||
mapRef.current = map;
|
mapRef.current = map;
|
||||||
|
|
||||||
|
// Add navigation controls
|
||||||
|
map.addControl(new maplibregl.NavigationControl({ showCompass: false }), 'bottom-right');
|
||||||
|
|
||||||
map.on('load', () => {
|
map.on('load', () => {
|
||||||
// Add ocean gradient layer
|
// Add ocean gradient layer
|
||||||
const oceanGradientData = createOceanGradient(centerLng, centerLat);
|
const oceanGradientData = createOceanGradient(centerLng, centerLat);
|
||||||
|
|
@ -195,16 +156,22 @@ export default function IslandsWorld({ theme, onIslandClick, onOpenMap, onOpenPl
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create island markers - scattered randomly across a large area
|
// Create island markers - spread in a grid with some randomization
|
||||||
const markers = [];
|
const markers = [];
|
||||||
const worldSize = 300; // Large world area
|
const numIslands = filteredIslands.length;
|
||||||
|
const gridSize = Math.ceil(Math.sqrt(numIslands));
|
||||||
|
const spacing = 25; // Degrees between islands
|
||||||
|
|
||||||
filteredIslands.forEach((island, idx) => {
|
filteredIslands.forEach((island, idx) => {
|
||||||
// Use seeded random for consistent positions per island
|
// Grid position with jitter
|
||||||
const rng = seededRandom((island.username || island.id || '').charCodeAt(0) * 1000 + idx);
|
const gridX = idx % gridSize;
|
||||||
// Random position across the entire world
|
const gridY = Math.floor(idx / gridSize);
|
||||||
const lng = (rng() - 0.5) * worldSize;
|
const rng = seededRandom((island.username || '').charCodeAt(0) || idx);
|
||||||
const lat = (rng() - 0.5) * worldSize;
|
const jitterX = (rng() - 0.5) * spacing * 0.6;
|
||||||
|
const jitterY = (rng() - 0.5) * spacing * 0.6;
|
||||||
|
|
||||||
|
const lng = (gridX - gridSize / 2) * spacing + jitterX;
|
||||||
|
const lat = (gridY - gridSize / 2) * spacing + jitterY;
|
||||||
|
|
||||||
// Store position for zoom
|
// Store position for zoom
|
||||||
islandPositionsRef.current.set(island.username || island.id, { lng, lat });
|
islandPositionsRef.current.set(island.username || island.id, { lng, lat });
|
||||||
|
|
@ -239,16 +206,6 @@ export default function IslandsWorld({ theme, onIslandClick, onOpenMap, onOpenPl
|
||||||
});
|
});
|
||||||
|
|
||||||
markersRef.current = markers;
|
markersRef.current = markers;
|
||||||
|
|
||||||
// Gentle rotation (only when not zoomed)
|
|
||||||
let bearing = 0;
|
|
||||||
function animate() {
|
|
||||||
bearing += 0.05;
|
|
||||||
if (bearing >= 360) bearing = 0;
|
|
||||||
map.rotateTo(bearing, { duration: 100 });
|
|
||||||
animationRef.current = requestAnimationFrame(animate);
|
|
||||||
}
|
|
||||||
animate();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
|
@ -313,7 +270,10 @@ export default function IslandsWorld({ theme, onIslandClick, onOpenMap, onOpenPl
|
||||||
|
|
||||||
{/* Zoomed island - full screen terrain view */}
|
{/* Zoomed island - full screen terrain view */}
|
||||||
{zoomedIsland && (
|
{zoomedIsland && (
|
||||||
<div className={`island-terrain-overlay ${isZooming ? 'zooming' : 'ready'}`}>
|
<div className="island-fullscreen-overlay">
|
||||||
|
{/* Full-screen island terrain background */}
|
||||||
|
<IslandTerrainBackground island={zoomedIsland} />
|
||||||
|
|
||||||
{/* Header bar */}
|
{/* Header bar */}
|
||||||
<div className="island-terrain-header">
|
<div className="island-terrain-header">
|
||||||
<button className="island-terrain-back" onClick={handleZoomOut}>
|
<button className="island-terrain-back" onClick={handleZoomOut}>
|
||||||
|
|
@ -347,7 +307,7 @@ export default function IslandsWorld({ theme, onIslandClick, onOpenMap, onOpenPl
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Posts on the island terrain */}
|
{/* Posts on the island terrain */}
|
||||||
<div className="island-terrain-posts">
|
<div className="island-terrain-content">
|
||||||
{loadingPosts && (
|
{loadingPosts && (
|
||||||
<div className="island-terrain-loading">
|
<div className="island-terrain-loading">
|
||||||
<div className="loading-spinner" />
|
<div className="loading-spinner" />
|
||||||
|
|
@ -355,7 +315,7 @@ export default function IslandsWorld({ theme, onIslandClick, onOpenMap, onOpenPl
|
||||||
)}
|
)}
|
||||||
{!loadingPosts && islandPosts.length === 0 && (
|
{!loadingPosts && islandPosts.length === 0 && (
|
||||||
<div className="island-terrain-empty">
|
<div className="island-terrain-empty">
|
||||||
<i className="fa-solid fa-island-tropical" />
|
<i className="fa-solid fa-umbrella-beach" />
|
||||||
<p>{t('worlds.emptyIsland')}</p>
|
<p>{t('worlds.emptyIsland')}</p>
|
||||||
{username && username.toLowerCase() === (zoomedIsland.username || '').toLowerCase() && (
|
{username && username.toLowerCase() === (zoomedIsland.username || '').toLowerCase() && (
|
||||||
<button className="island-terrain-first-post" onClick={() => {
|
<button className="island-terrain-first-post" onClick={() => {
|
||||||
|
|
@ -540,6 +500,110 @@ function seededRandom(seed) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IslandTerrainBackground - Full-screen island shape background
|
||||||
|
*/
|
||||||
|
function IslandTerrainBackground({ island }) {
|
||||||
|
const terrainColors = {
|
||||||
|
'tropical': { main: '#2ecc71', sand: '#F5DEB3', water: '#1e3a5f' },
|
||||||
|
'desert': { main: '#f39c12', sand: '#DEB887', water: '#1e3a5f' },
|
||||||
|
'arctic': { main: '#3498db', sand: '#E0FFFF', water: '#0d2847' },
|
||||||
|
'volcanic': { main: '#e74c3c', sand: '#2C2C2C', water: '#1a1a2e' },
|
||||||
|
'meadow': { main: '#27ae60', sand: '#90EE90', water: '#0d2847' },
|
||||||
|
};
|
||||||
|
const colors = terrainColors[island.terrain_type] || terrainColors['tropical'];
|
||||||
|
const seed = island.seed || (island.username || '').charCodeAt(0) || 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg className="island-terrain-bg" viewBox="0 0 100 100" preserveAspectRatio="xMidYMid slice">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="ocean-grad" cx="50%" cy="50%" r="70%">
|
||||||
|
<stop offset="0%" stopColor={colors.water} />
|
||||||
|
<stop offset="100%" stopColor="#0a1628" />
|
||||||
|
</radialGradient>
|
||||||
|
<radialGradient id="island-grad" cx="30%" cy="30%">
|
||||||
|
<stop offset="0%" stopColor={colors.main} stopOpacity="1" />
|
||||||
|
<stop offset="60%" stopColor={colors.main} stopOpacity="0.9" />
|
||||||
|
<stop offset="100%" stopColor={colors.main} stopOpacity="0.7" />
|
||||||
|
</radialGradient>
|
||||||
|
<filter id="island-shadow">
|
||||||
|
<feDropShadow dx="0" dy="2" stdDeviation="3" floodColor="rgba(0,0,0,0.5)" />
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
{/* Ocean background */}
|
||||||
|
<rect width="100" height="100" fill="url(#ocean-grad)" />
|
||||||
|
|
||||||
|
{/* Beach/sand ring */}
|
||||||
|
<IslandShape seed={seed} cx={50} cy={50} scale={1.15} fill={colors.sand} opacity={0.6} />
|
||||||
|
|
||||||
|
{/* Main island */}
|
||||||
|
<IslandShape seed={seed} cx={50} cy={50} scale={1} fill="url(#island-grad)" filter="url(#island-shadow)" />
|
||||||
|
|
||||||
|
{/* Trees and details */}
|
||||||
|
<IslandDetails seed={seed} cx={50} cy={50} />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate organic island shape path
|
||||||
|
*/
|
||||||
|
function IslandShape({ seed, cx, cy, scale, fill, opacity = 1, filter }) {
|
||||||
|
const rng = seededRandom(seed);
|
||||||
|
const baseRadius = 30;
|
||||||
|
const numPoints = 12;
|
||||||
|
|
||||||
|
const points = [];
|
||||||
|
for (let i = 0; i < numPoints; i++) {
|
||||||
|
const angle = (i / numPoints) * Math.PI * 2;
|
||||||
|
const radiusVar = 0.7 + rng() * 0.3;
|
||||||
|
const r = baseRadius * radiusVar * scale;
|
||||||
|
points.push({
|
||||||
|
x: cx + Math.cos(angle) * r,
|
||||||
|
y: cy + Math.sin(angle) * r
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = `M ${points[0].x} ${points[0].y}`;
|
||||||
|
for (let i = 0; i < numPoints; i++) {
|
||||||
|
const curr = points[i];
|
||||||
|
const next = points[(i + 1) % numPoints];
|
||||||
|
const cpx = (curr.x + next.x) / 2 + (rng() - 0.5) * 6;
|
||||||
|
const cpy = (curr.y + next.y) / 2 + (rng() - 0.5) * 6;
|
||||||
|
path += ` Q ${cpx} ${cpy} ${next.x} ${next.y}`;
|
||||||
|
}
|
||||||
|
path += ' Z';
|
||||||
|
|
||||||
|
return <path d={path} fill={fill} opacity={opacity} filter={filter} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Island trees and details
|
||||||
|
*/
|
||||||
|
function IslandDetails({ seed, cx, cy }) {
|
||||||
|
const rng = seededRandom(seed + 100);
|
||||||
|
const numTrees = 8 + Math.floor(rng() * 6);
|
||||||
|
const trees = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < numTrees; i++) {
|
||||||
|
const angle = rng() * Math.PI * 2;
|
||||||
|
const dist = 5 + rng() * 18;
|
||||||
|
const tx = cx + Math.cos(angle) * dist;
|
||||||
|
const ty = cy + Math.sin(angle) * dist;
|
||||||
|
const treeSize = 2 + rng() * 3;
|
||||||
|
|
||||||
|
trees.push(
|
||||||
|
<g key={i} transform={`translate(${tx}, ${ty})`}>
|
||||||
|
<rect x="-0.3" y={-treeSize} width="0.6" height={treeSize} fill="#5D4037" rx="0.2" />
|
||||||
|
<ellipse cx="0" cy={-treeSize} rx={treeSize * 0.7} ry={treeSize * 0.5} fill="#228B22" opacity="0.85" />
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <g>{trees}</g>;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* IslandPost - A post displayed on the island terrain
|
* IslandPost - A post displayed on the island terrain
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue