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:
Your Name 2026-01-24 23:01:29 +00:00
parent 6422aeebfe
commit e1956b1383
3 changed files with 156 additions and 85 deletions

View File

@ -1,7 +1,7 @@
{
"name": "sociowire-frontend",
"private": true,
"version": "0.0.112",
"version": "0.0.113",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",

View File

@ -366,21 +366,27 @@
}
}
/* Island Terrain Overlay - Full screen when zoomed */
.island-terrain-overlay {
/* Island Full-screen Overlay */
.island-fullscreen-overlay {
position: fixed;
inset: 0;
z-index: 500;
pointer-events: none;
transition: opacity 0.3s ease;
z-index: 700;
background: #0a1628;
animation: islandFadeIn 0.4s ease;
}
.island-terrain-overlay.zooming {
opacity: 0.5;
@keyframes islandFadeIn {
from { opacity: 0; transform: scale(0.95); }
to { opacity: 1; transform: scale(1); }
}
.island-terrain-overlay.ready {
opacity: 1;
/* Full-screen island terrain SVG background */
.island-terrain-bg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.island-terrain-header {
@ -471,9 +477,10 @@
}
/* Posts container - overlay for posts on island */
.island-terrain-posts {
.island-terrain-content {
position: absolute;
inset: 0;
top: 80px;
pointer-events: none;
}

View File

@ -58,33 +58,14 @@ export default function IslandsWorld({ theme, onIslandClick, onOpenMap, onOpenPl
)
: 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) => {
if (!mapRef.current || isZooming) return;
const pos = islandPositionsRef.current.get(island.username || island.id);
if (!pos) return;
if (isZooming) return;
setIsZooming(true);
setZoomedIsland(island);
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
try {
const posts = await fetchPosts({ username: island.username, limit: 50 });
@ -94,42 +75,16 @@ export default function IslandsWorld({ theme, onIslandClick, onOpenMap, onOpenPl
setIslandPosts([]);
}
setLoadingPosts(false);
mapRef.current.once('moveend', () => {
setIsZooming(false);
});
setIsZooming(false);
}, [isZooming]);
// Handle zoom out (back to world view)
const handleZoomOut = useCallback(() => {
if (!mapRef.current || isZooming) return;
if (isZooming) return;
setIsZooming(true);
setZoomedIsland(null);
setIslandPosts([]);
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();
});
setIsZooming(false);
}, [isZooming]);
// Initialize MapLibre
@ -155,15 +110,21 @@ export default function IslandsWorld({ theme, onIslandClick, onOpenMap, onOpenPl
]
},
center: [centerLng, centerLat],
zoom: 1,
pitch: 50,
zoom: 2,
minZoom: 1,
maxZoom: 18,
pitch: 0,
bearing: 0,
antialias: true,
attributionControl: false
attributionControl: false,
dragRotate: false // Disable rotation, keep simple 2D navigation
});
mapRef.current = map;
// Add navigation controls
map.addControl(new maplibregl.NavigationControl({ showCompass: false }), 'bottom-right');
map.on('load', () => {
// Add ocean gradient layer
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 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) => {
// Use seeded random for consistent positions per island
const rng = seededRandom((island.username || island.id || '').charCodeAt(0) * 1000 + idx);
// Random position across the entire world
const lng = (rng() - 0.5) * worldSize;
const lat = (rng() - 0.5) * worldSize;
// Grid position with jitter
const gridX = idx % gridSize;
const gridY = Math.floor(idx / gridSize);
const rng = seededRandom((island.username || '').charCodeAt(0) || idx);
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
islandPositionsRef.current.set(island.username || island.id, { lng, lat });
@ -239,16 +206,6 @@ export default function IslandsWorld({ theme, onIslandClick, onOpenMap, onOpenPl
});
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 () => {
@ -313,7 +270,10 @@ export default function IslandsWorld({ theme, onIslandClick, onOpenMap, onOpenPl
{/* Zoomed island - full screen terrain view */}
{zoomedIsland && (
<div className={`island-terrain-overlay ${isZooming ? 'zooming' : 'ready'}`}>
<div className="island-fullscreen-overlay">
{/* Full-screen island terrain background */}
<IslandTerrainBackground island={zoomedIsland} />
{/* Header bar */}
<div className="island-terrain-header">
<button className="island-terrain-back" onClick={handleZoomOut}>
@ -347,7 +307,7 @@ export default function IslandsWorld({ theme, onIslandClick, onOpenMap, onOpenPl
</div>
{/* Posts on the island terrain */}
<div className="island-terrain-posts">
<div className="island-terrain-content">
{loadingPosts && (
<div className="island-terrain-loading">
<div className="loading-spinner" />
@ -355,7 +315,7 @@ export default function IslandsWorld({ theme, onIslandClick, onOpenMap, onOpenPl
)}
{!loadingPosts && islandPosts.length === 0 && (
<div className="island-terrain-empty">
<i className="fa-solid fa-island-tropical" />
<i className="fa-solid fa-umbrella-beach" />
<p>{t('worlds.emptyIsland')}</p>
{username && username.toLowerCase() === (zoomedIsland.username || '').toLowerCase() && (
<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
*/