668 lines
22 KiB
JavaScript
668 lines
22 KiB
JavaScript
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import maplibregl from 'maplibre-gl';
|
|
import 'maplibre-gl/dist/maplibre-gl.css';
|
|
import { fetchIslands, fetchIslandPosts, fetchPosts } from '../../api/client';
|
|
import { useAuth } from '../../auth/AuthContext';
|
|
import './IslandsWorld.css';
|
|
|
|
/**
|
|
* IslandsWorld: Full-screen metaverse for personal islands
|
|
* Each user has their own floating island in this virtual ocean
|
|
*/
|
|
export default function IslandsWorld({ theme, onIslandClick, onOpenMap, onOpenPlanets }) {
|
|
const { t } = useTranslation();
|
|
const { authenticated, username } = useAuth();
|
|
const containerRef = useRef(null);
|
|
const mapRef = useRef(null);
|
|
const markersRef = useRef([]);
|
|
const animationRef = useRef(null);
|
|
const islandPositionsRef = useRef(new Map()); // Store island positions
|
|
|
|
const [islands, setIslands] = useState([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [hoveredIsland, setHoveredIsland] = useState(null);
|
|
|
|
// Zoomed island state
|
|
const [zoomedIsland, setZoomedIsland] = useState(null);
|
|
const [isZooming, setIsZooming] = useState(false);
|
|
const [islandPosts, setIslandPosts] = useState([]);
|
|
const [loadingPosts, setLoadingPosts] = useState(false);
|
|
|
|
// Fetch islands
|
|
useEffect(() => {
|
|
let mounted = true;
|
|
setLoading(true);
|
|
|
|
fetchIslands({ limit: 200 })
|
|
.then(data => {
|
|
if (mounted) {
|
|
setIslands(data || []);
|
|
setLoading(false);
|
|
}
|
|
})
|
|
.catch(err => {
|
|
console.warn('[IslandsWorld] Failed to fetch islands:', err);
|
|
if (mounted) setLoading(false);
|
|
});
|
|
|
|
return () => { mounted = false; };
|
|
}, []);
|
|
|
|
// Filter islands by search
|
|
const filteredIslands = searchQuery
|
|
? islands.filter(i =>
|
|
(i.username || '').toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
(i.display_name || '').toLowerCase().includes(searchQuery.toLowerCase())
|
|
)
|
|
: islands;
|
|
|
|
// Handle zoom to island - shows full-screen island terrain overlay
|
|
const handleZoomToIsland = useCallback(async (island) => {
|
|
if (isZooming) return;
|
|
|
|
setIsZooming(true);
|
|
setZoomedIsland(island);
|
|
setLoadingPosts(true);
|
|
|
|
// Fetch posts for this island
|
|
try {
|
|
const posts = await fetchPosts({ username: island.username, limit: 50 });
|
|
setIslandPosts(posts || []);
|
|
} catch (err) {
|
|
console.warn('[IslandsWorld] Failed to fetch island posts:', err);
|
|
setIslandPosts([]);
|
|
}
|
|
setLoadingPosts(false);
|
|
setIsZooming(false);
|
|
}, [isZooming]);
|
|
|
|
// Handle zoom out (back to world view)
|
|
const handleZoomOut = useCallback(() => {
|
|
if (isZooming) return;
|
|
|
|
setZoomedIsland(null);
|
|
setIslandPosts([]);
|
|
setIsZooming(false);
|
|
}, [isZooming]);
|
|
|
|
// Initialize MapLibre
|
|
useEffect(() => {
|
|
if (!containerRef.current || loading) return;
|
|
|
|
const centerLng = 0;
|
|
const centerLat = 0;
|
|
|
|
const map = new maplibregl.Map({
|
|
container: containerRef.current,
|
|
style: {
|
|
version: 8,
|
|
sources: {},
|
|
layers: [
|
|
{
|
|
id: 'background',
|
|
type: 'background',
|
|
paint: {
|
|
'background-color': '#0a1628'
|
|
}
|
|
}
|
|
]
|
|
},
|
|
center: [centerLng, centerLat],
|
|
zoom: 2,
|
|
minZoom: 1,
|
|
maxZoom: 18,
|
|
pitch: 0,
|
|
bearing: 0,
|
|
antialias: true,
|
|
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);
|
|
map.addSource('ocean-gradient', { type: 'geojson', data: oceanGradientData });
|
|
map.addLayer({
|
|
id: 'ocean-glow',
|
|
type: 'fill',
|
|
source: 'ocean-gradient',
|
|
paint: {
|
|
'fill-color': ['interpolate', ['linear'], ['get', 'intensity'],
|
|
0, '#0a1628', 0.5, '#0d2847', 1, '#1e3a5f'
|
|
],
|
|
'fill-opacity': 0.8
|
|
}
|
|
});
|
|
|
|
// Add starfield
|
|
const starsData = createStarfield(500);
|
|
map.addSource('stars', { type: 'geojson', data: starsData });
|
|
map.addLayer({
|
|
id: 'stars',
|
|
type: 'circle',
|
|
source: 'stars',
|
|
paint: {
|
|
'circle-radius': ['interpolate', ['linear'], ['zoom'], 0, 1, 5, 3],
|
|
'circle-color': ['get', 'color'],
|
|
'circle-opacity': ['get', 'opacity'],
|
|
'circle-blur': 0.3
|
|
}
|
|
});
|
|
|
|
// Create island markers - spread in a grid with some randomization
|
|
const markers = [];
|
|
const numIslands = filteredIslands.length;
|
|
const gridSize = Math.ceil(Math.sqrt(numIslands));
|
|
const spacing = 25; // Degrees between islands
|
|
|
|
filteredIslands.forEach((island, idx) => {
|
|
// 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 });
|
|
|
|
const el = createIslandMarker(island, idx, t, username);
|
|
|
|
el.addEventListener('mouseenter', () => setHoveredIsland(island));
|
|
el.addEventListener('mouseleave', () => setHoveredIsland(null));
|
|
el.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
const action = e.target.closest('[data-action]')?.dataset?.action;
|
|
if (action === 'profile') {
|
|
window.location.href = `/profile/${island.username}`;
|
|
return;
|
|
}
|
|
if (action === 'edit') {
|
|
window.location.href = `/island/${island.username}/edit`;
|
|
return;
|
|
}
|
|
// Zoom to island
|
|
handleZoomToIsland(island);
|
|
});
|
|
|
|
const marker = new maplibregl.Marker({
|
|
element: el,
|
|
anchor: 'center'
|
|
})
|
|
.setLngLat([lng, lat])
|
|
.addTo(map);
|
|
|
|
markers.push({ marker, island, el });
|
|
});
|
|
|
|
markersRef.current = markers;
|
|
});
|
|
|
|
return () => {
|
|
if (animationRef.current) cancelAnimationFrame(animationRef.current);
|
|
markersRef.current.forEach(({ marker }) => marker.remove());
|
|
markersRef.current = [];
|
|
islandPositionsRef.current.clear();
|
|
map.remove();
|
|
};
|
|
}, [filteredIslands, loading, t, username, handleZoomToIsland]);
|
|
|
|
return (
|
|
<div className="islands-world">
|
|
{/* Search bar with icon and world nav */}
|
|
<div className="islands-world-search">
|
|
<i className="fa-solid fa-umbrella-beach search-icon" />
|
|
<i className="fa-solid fa-search" />
|
|
<input
|
|
type="text"
|
|
placeholder={t('worlds.searchIslands')}
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
/>
|
|
{searchQuery && (
|
|
<button onClick={() => setSearchQuery('')} className="search-clear">
|
|
<i className="fa-solid fa-xmark" />
|
|
</button>
|
|
)}
|
|
<span className="search-count">{islands.length}</span>
|
|
<div className="world-nav-inline">
|
|
<button className="world-nav-btn-sm" onClick={onOpenMap} title={t('worlds.map')}>
|
|
<i className="fa-solid fa-earth-americas" />
|
|
</button>
|
|
<button className="world-nav-btn-sm" onClick={onOpenPlanets} title={t('worlds.planets')}>
|
|
<i className="fa-solid fa-globe" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Main canvas */}
|
|
<div ref={containerRef} className="islands-world-canvas" />
|
|
|
|
{/* Hovered island tooltip (only when not zoomed) */}
|
|
{hoveredIsland && !zoomedIsland && (
|
|
<div className="islands-world-tooltip">
|
|
<div className="tooltip-avatar">
|
|
{hoveredIsland.avatar_url ? (
|
|
<img src={hoveredIsland.avatar_url} alt={hoveredIsland.username} />
|
|
) : (
|
|
<span>{(hoveredIsland.username || 'U')[0].toUpperCase()}</span>
|
|
)}
|
|
</div>
|
|
<div className="tooltip-info">
|
|
<div className="tooltip-username">@{hoveredIsland.username}</div>
|
|
<div className="tooltip-bio">{hoveredIsland.display_name || hoveredIsland.bio || t('worlds.clickToVisit')}</div>
|
|
<div className="tooltip-stats">
|
|
{hoveredIsland.content_count || 0} {t('worlds.posts')} • {hoveredIsland.terrain_type || 'tropical'}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Zoomed island - full screen terrain view */}
|
|
{zoomedIsland && (
|
|
<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}>
|
|
<i className="fa-solid fa-arrow-left" />
|
|
<span>{t('worlds.backToIslands')}</span>
|
|
</button>
|
|
<div className="island-terrain-title">
|
|
<h2>@{zoomedIsland.username}</h2>
|
|
<span>{zoomedIsland.display_name || zoomedIsland.terrain_type || 'Island'}</span>
|
|
</div>
|
|
<div className="island-terrain-actions">
|
|
<button className="island-terrain-btn" onClick={() => window.location.href = `/profile/${zoomedIsland.username}`}>
|
|
<i className="fa-solid fa-user" />
|
|
</button>
|
|
{username && username.toLowerCase() === (zoomedIsland.username || '').toLowerCase() && (
|
|
<>
|
|
<button className="island-terrain-btn" onClick={() => window.location.href = `/island/${zoomedIsland.username}/edit`}>
|
|
<i className="fa-solid fa-cog" />
|
|
</button>
|
|
<button className="island-terrain-btn island-terrain-post-btn" onClick={() => {
|
|
window.dispatchEvent(new CustomEvent('sociowire:create-post', {
|
|
detail: { island: zoomedIsland.username }
|
|
}));
|
|
}}>
|
|
<i className="fa-solid fa-plus" />
|
|
<span>{t('worlds.postHere')}</span>
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Posts on the island terrain */}
|
|
<div className="island-terrain-content">
|
|
{loadingPosts && (
|
|
<div className="island-terrain-loading">
|
|
<div className="loading-spinner" />
|
|
</div>
|
|
)}
|
|
{!loadingPosts && islandPosts.length === 0 && (
|
|
<div className="island-terrain-empty">
|
|
<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={() => {
|
|
window.dispatchEvent(new CustomEvent('sociowire:create-post', {
|
|
detail: { island: zoomedIsland.username }
|
|
}));
|
|
}}>
|
|
<i className="fa-solid fa-plus" />
|
|
{t('worlds.createFirstPost')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
{!loadingPosts && islandPosts.map((post, idx) => (
|
|
<IslandPost key={post.id || idx} post={post} index={idx} islandSeed={zoomedIsland.seed || 0} />
|
|
))}
|
|
</div>
|
|
|
|
{/* Floating create post button for island owner */}
|
|
{username && username.toLowerCase() === (zoomedIsland.username || '').toLowerCase() && (
|
|
<button
|
|
className="island-create-post-fab"
|
|
onClick={() => {
|
|
window.dispatchEvent(new CustomEvent('sociowire:create-post', {
|
|
detail: { island: zoomedIsland.username }
|
|
}));
|
|
}}
|
|
>
|
|
<i className="fa-solid fa-plus" />
|
|
<span>{t('worlds.newPost')}</span>
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Loading overlay */}
|
|
{loading && (
|
|
<div className="islands-world-loading">
|
|
<div className="loading-spinner" />
|
|
<p>{t('worlds.loadingIslands')}</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Create a stylized island marker element
|
|
*/
|
|
function createIslandMarker(island, idx, t, currentUsername) {
|
|
const el = document.createElement('div');
|
|
el.className = 'island-marker';
|
|
|
|
const terrainColors = {
|
|
'tropical': '#2ecc71',
|
|
'desert': '#f39c12',
|
|
'arctic': '#3498db',
|
|
'volcanic': '#e74c3c',
|
|
'meadow': '#27ae60',
|
|
};
|
|
const color = terrainColors[island.terrain_type] || terrainColors['tropical'];
|
|
const seed = island.seed || idx;
|
|
const islandSVG = generateIslandSVG(seed, color);
|
|
const postCount = island.content_count || 0;
|
|
const isOwner = currentUsername && island.username &&
|
|
currentUsername.toLowerCase() === island.username.toLowerCase();
|
|
|
|
el.innerHTML = `
|
|
<div class="island-marker-inner" style="animation-delay: ${(idx % 12) * 0.25}s;">
|
|
${isOwner ? `
|
|
<div class="island-owner-badge">
|
|
<i class="fa-solid fa-home"></i>
|
|
<span>Mon île</span>
|
|
</div>
|
|
` : ''}
|
|
${islandSVG}
|
|
<div class="island-marker-label">
|
|
<span class="island-marker-name" style="color: ${color}">@${island.username}</span>
|
|
<span class="island-marker-meta">${postCount} posts</span>
|
|
</div>
|
|
<div class="island-marker-actions">
|
|
<button class="island-action-btn island-profile-btn" data-action="profile" title="View Profile">
|
|
<i class="fa-solid fa-user"></i>
|
|
</button>
|
|
${isOwner ? `
|
|
<button class="island-action-btn island-edit-btn" data-action="edit" title="Edit Island">
|
|
<i class="fa-solid fa-pen"></i>
|
|
</button>
|
|
` : ''}
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
return el;
|
|
}
|
|
|
|
/**
|
|
* Generate island SVG with organic shape
|
|
*/
|
|
function generateIslandSVG(seed, color) {
|
|
const rng = seededRandom(seed);
|
|
const size = 140;
|
|
const cx = size / 2;
|
|
const cy = size / 2;
|
|
const numPoints = 10;
|
|
|
|
const points = [];
|
|
for (let i = 0; i < numPoints; i++) {
|
|
const angle = (i / numPoints) * Math.PI * 2;
|
|
const radiusVar = 0.55 + rng() * 0.45;
|
|
const r = (size / 2.5) * radiusVar;
|
|
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) * 10;
|
|
const cpy = (curr.y + next.y) / 2 + (rng() - 0.5) * 10;
|
|
path += ` Q ${cpx} ${cpy} ${next.x} ${next.y}`;
|
|
}
|
|
path += ' Z';
|
|
|
|
const numTrees = 3 + Math.floor(rng() * 4);
|
|
let trees = '';
|
|
for (let i = 0; i < numTrees; i++) {
|
|
const angle = rng() * Math.PI * 2;
|
|
const dist = 10 + rng() * 20;
|
|
const tx = cx + Math.cos(angle) * dist;
|
|
const ty = cy + Math.sin(angle) * dist;
|
|
const treeSize = 6 + rng() * 6;
|
|
trees += `
|
|
<g transform="translate(${tx}, ${ty})">
|
|
<rect x="-1" y="${-treeSize}" width="2" height="${treeSize}" fill="#5D4037" rx="0.5"/>
|
|
<ellipse cx="0" cy="${-treeSize}" rx="${treeSize * 0.8}" ry="${treeSize * 0.5}" fill="#228B22" opacity="0.9"/>
|
|
</g>
|
|
`;
|
|
}
|
|
|
|
return `
|
|
<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" class="island-svg">
|
|
<defs>
|
|
<radialGradient id="ig-${seed}" cx="30%" cy="30%">
|
|
<stop offset="0%" stop-color="${color}" stop-opacity="1" />
|
|
<stop offset="70%" stop-color="${color}" stop-opacity="0.85" />
|
|
<stop offset="100%" stop-color="${color}" stop-opacity="0.6" />
|
|
</radialGradient>
|
|
<filter id="glow-${seed}">
|
|
<feGaussianBlur stdDeviation="4" result="blur"/>
|
|
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
|
</filter>
|
|
</defs>
|
|
<path d="${path}" fill="#F5DEB3" transform="scale(1.08) translate(-5.6, -5.6)" opacity="0.6"/>
|
|
<path d="${path}" fill="url(#ig-${seed})" filter="url(#glow-${seed})"/>
|
|
${trees}
|
|
</svg>
|
|
`;
|
|
}
|
|
|
|
function createStarfield(count) {
|
|
const features = [];
|
|
for (let i = 0; i < count; i++) {
|
|
const lng = (Math.random() - 0.5) * 400;
|
|
const lat = (Math.random() - 0.5) * 400;
|
|
const brightness = Math.random();
|
|
features.push({
|
|
type: 'Feature',
|
|
geometry: { type: 'Point', coordinates: [lng, lat] },
|
|
properties: {
|
|
color: brightness > 0.8 ? '#ffeedd' : brightness > 0.5 ? '#aaccff' : '#ffffff',
|
|
opacity: 0.3 + brightness * 0.5
|
|
}
|
|
});
|
|
}
|
|
return { type: 'FeatureCollection', features };
|
|
}
|
|
|
|
function createOceanGradient(centerLng, centerLat) {
|
|
const features = [];
|
|
const rings = 10;
|
|
for (let i = 0; i < rings; i++) {
|
|
const radius = (i + 1) * 30;
|
|
const coords = [];
|
|
for (let j = 0; j <= 64; j++) {
|
|
const angle = (j / 64) * Math.PI * 2;
|
|
coords.push([centerLng + Math.cos(angle) * radius, centerLat + Math.sin(angle) * radius]);
|
|
}
|
|
features.push({
|
|
type: 'Feature',
|
|
geometry: { type: 'Polygon', coordinates: [coords] },
|
|
properties: { intensity: 1 - (i / rings) }
|
|
});
|
|
}
|
|
return { type: 'FeatureCollection', features };
|
|
}
|
|
|
|
function seededRandom(seed) {
|
|
let s = seed;
|
|
return function() {
|
|
s = (s * 9301 + 49297) % 233280;
|
|
return s / 233280;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
*/
|
|
function IslandPost({ post, index, islandSeed }) {
|
|
// Position posts pseudo-randomly on the island
|
|
const rng = seededRandom(islandSeed * 100 + index + (post.id || 0));
|
|
const left = 10 + rng() * 75; // 10-85% from left
|
|
const top = 15 + rng() * 65; // 15-80% from top
|
|
const rotation = (rng() - 0.5) * 12; // -6 to +6 degrees
|
|
|
|
const hasImage = post.media_urls?.length > 0 || post.image_url;
|
|
const imageUrl = post.media_urls?.[0] || post.image_url;
|
|
|
|
return (
|
|
<div
|
|
className="island-post-marker"
|
|
style={{
|
|
left: `${left}%`,
|
|
top: `${top}%`,
|
|
transform: `rotate(${rotation}deg)`
|
|
}}
|
|
onClick={() => window.location.href = `/post/${post.id}`}
|
|
>
|
|
{hasImage ? (
|
|
<div className="island-post-image">
|
|
<img src={imageUrl} alt="" />
|
|
</div>
|
|
) : (
|
|
<div className="island-post-text">
|
|
<p>{(post.content || post.text || '').slice(0, 80)}</p>
|
|
</div>
|
|
)}
|
|
<div className="island-post-meta">
|
|
<span className="island-post-likes">
|
|
<i className="fa-solid fa-heart" /> {post.like_count || 0}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|