v111: Implement in-map zoom for islands/planets + create planet modal
- Islands: clicking zooms camera to island position using flyTo() - Planets: same zoom behavior + Create Planet button with modal - Search bar repositioned higher (top: 52px, z-index: 600) - Removed unnecessary Create Island button (islands auto-create with profiles) - Added zoomed overlay UI with back button, info, and post actions Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
e8af1a6a08
commit
b6b176271b
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "sociowire-frontend",
|
"name": "sociowire-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.110",
|
"version": "0.0.111",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host 0.0.0.0",
|
"dev": "vite --host 0.0.0.0",
|
||||||
|
|
|
||||||
|
|
@ -19,10 +19,10 @@
|
||||||
/* Search bar - fixed below topbar */
|
/* Search bar - fixed below topbar */
|
||||||
.islands-world-search {
|
.islands-world-search {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 56px;
|
top: 52px;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
z-index: 100;
|
z-index: 600;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
|
|
@ -366,40 +366,26 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Island Terrain View */
|
/* Island Zoomed Overlay */
|
||||||
.island-terrain-view {
|
.island-zoomed-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
|
||||||
z-index: 500;
|
|
||||||
background: linear-gradient(180deg, #0a1628 0%, #0d2847 100%);
|
|
||||||
animation: terrainFadeIn 0.4s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes terrainFadeIn {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: scale(1.1);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: scale(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.island-terrain-header {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
z-index: 100;
|
z-index: 500;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.island-zoomed-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 16px 24px;
|
padding: 16px 24px;
|
||||||
background: linear-gradient(180deg, rgba(10, 22, 40, 0.95) 0%, rgba(10, 22, 40, 0) 100%);
|
background: linear-gradient(180deg, rgba(10, 22, 40, 0.95) 0%, rgba(10, 22, 40, 0) 100%);
|
||||||
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.island-terrain-back {
|
.island-zoomed-back {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|
@ -414,35 +400,35 @@
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.island-terrain-back:hover {
|
.island-zoomed-back:hover {
|
||||||
background: rgba(56, 189, 248, 0.2);
|
background: rgba(56, 189, 248, 0.2);
|
||||||
border-color: rgba(56, 189, 248, 0.5);
|
border-color: rgba(56, 189, 248, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.island-terrain-info {
|
.island-zoomed-info {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.island-terrain-info h2 {
|
.island-zoomed-info h2 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #38bdf8;
|
color: #38bdf8;
|
||||||
}
|
}
|
||||||
|
|
||||||
.island-terrain-type {
|
.island-zoomed-info span {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: rgba(255, 255, 255, 0.6);
|
color: rgba(255, 255, 255, 0.6);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 1px;
|
letter-spacing: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.island-terrain-actions {
|
.island-zoomed-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.island-terrain-action {
|
.island-zoomed-action {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|
@ -456,90 +442,21 @@
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.island-terrain-action:hover {
|
.island-zoomed-action:hover {
|
||||||
background: rgba(255, 255, 255, 0.15);
|
background: rgba(255, 255, 255, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
.island-terrain-post {
|
.island-zoomed-post {
|
||||||
background: linear-gradient(135deg, #10b981, #059669);
|
background: linear-gradient(135deg, #10b981, #059669);
|
||||||
border: none;
|
border: none;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.island-terrain-post:hover {
|
.island-zoomed-post:hover {
|
||||||
transform: scale(1.05);
|
transform: scale(1.05);
|
||||||
box-shadow: 0 4px 16px rgba(16, 185, 129, 0.4);
|
box-shadow: 0 4px 16px rgba(16, 185, 129, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.island-terrain-canvas {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.island-terrain-map {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.island-terrain-loading {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.island-terrain-loading p {
|
|
||||||
color: rgba(255, 255, 255, 0.8);
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Terrain post markers */
|
|
||||||
.terrain-post-marker {
|
|
||||||
cursor: pointer;
|
|
||||||
transition: transform 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.terrain-post-marker:hover {
|
|
||||||
transform: scale(1.1);
|
|
||||||
z-index: 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
.terrain-post-icon {
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
background: linear-gradient(135deg, #38bdf8, #06b6d4);
|
|
||||||
border-radius: 50%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
color: white;
|
|
||||||
font-size: 16px;
|
|
||||||
box-shadow: 0 4px 16px rgba(56, 189, 248, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.terrain-post-title {
|
|
||||||
position: absolute;
|
|
||||||
top: 100%;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
margin-top: 4px;
|
|
||||||
padding: 4px 8px;
|
|
||||||
background: rgba(15, 23, 42, 0.9);
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 11px;
|
|
||||||
color: white;
|
|
||||||
white-space: nowrap;
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.terrain-post-marker:hover .terrain-post-title {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Responsive */
|
/* Responsive */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.islands-world-title {
|
.islands-world-title {
|
||||||
|
|
|
||||||
|
|
@ -14,21 +14,19 @@ export default function IslandsWorld({ theme, onIslandClick, onOpenMap, onOpenPl
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { authenticated, username } = useAuth();
|
const { authenticated, username } = useAuth();
|
||||||
const containerRef = useRef(null);
|
const containerRef = useRef(null);
|
||||||
const terrainRef = useRef(null);
|
|
||||||
const mapRef = useRef(null);
|
const mapRef = useRef(null);
|
||||||
const terrainMapRef = useRef(null);
|
|
||||||
const markersRef = useRef([]);
|
const markersRef = useRef([]);
|
||||||
const animationRef = useRef(null);
|
const animationRef = useRef(null);
|
||||||
|
const islandPositionsRef = useRef(new Map()); // Store island positions
|
||||||
|
|
||||||
const [islands, setIslands] = useState([]);
|
const [islands, setIslands] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [hoveredIsland, setHoveredIsland] = useState(null);
|
const [hoveredIsland, setHoveredIsland] = useState(null);
|
||||||
|
|
||||||
// Terrain view state
|
// Zoomed island state
|
||||||
const [zoomedIsland, setZoomedIsland] = useState(null);
|
const [zoomedIsland, setZoomedIsland] = useState(null);
|
||||||
const [islandPosts, setIslandPosts] = useState([]);
|
const [isZooming, setIsZooming] = useState(false);
|
||||||
const [terrainLoading, setTerrainLoading] = useState(false);
|
|
||||||
|
|
||||||
// Fetch islands
|
// Fetch islands
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -58,34 +56,67 @@ export default function IslandsWorld({ theme, onIslandClick, onOpenMap, onOpenPl
|
||||||
)
|
)
|
||||||
: islands;
|
: islands;
|
||||||
|
|
||||||
// Load island posts when zooming into terrain view
|
// Handle zoom to island
|
||||||
useEffect(() => {
|
|
||||||
if (!zoomedIsland) {
|
|
||||||
setIslandPosts([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setTerrainLoading(true);
|
|
||||||
fetchIslandPosts(zoomedIsland.username || zoomedIsland.id, { limit: 50 })
|
|
||||||
.then(posts => {
|
|
||||||
setIslandPosts(posts || []);
|
|
||||||
setTerrainLoading(false);
|
|
||||||
})
|
|
||||||
.catch(err => {
|
|
||||||
console.warn('[IslandsWorld] Failed to fetch island posts:', err);
|
|
||||||
setIslandPosts([]);
|
|
||||||
setTerrainLoading(false);
|
|
||||||
});
|
|
||||||
}, [zoomedIsland]);
|
|
||||||
|
|
||||||
// Handle zoom into island terrain
|
|
||||||
const handleZoomToIsland = useCallback((island) => {
|
const handleZoomToIsland = useCallback((island) => {
|
||||||
setZoomedIsland(island);
|
if (!mapRef.current || isZooming) return;
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Handle back from terrain view
|
const pos = islandPositionsRef.current.get(island.username || island.id);
|
||||||
const handleBackFromTerrain = useCallback(() => {
|
if (!pos) return;
|
||||||
|
|
||||||
|
setIsZooming(true);
|
||||||
|
setZoomedIsland(island);
|
||||||
|
|
||||||
|
// Stop the rotation animation
|
||||||
|
if (animationRef.current) {
|
||||||
|
cancelAnimationFrame(animationRef.current);
|
||||||
|
animationRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fly to island position with high zoom
|
||||||
|
mapRef.current.flyTo({
|
||||||
|
center: [pos.lng, pos.lat],
|
||||||
|
zoom: 6,
|
||||||
|
pitch: 60,
|
||||||
|
bearing: 0,
|
||||||
|
duration: 1500,
|
||||||
|
essential: true
|
||||||
|
});
|
||||||
|
|
||||||
|
mapRef.current.once('moveend', () => {
|
||||||
|
setIsZooming(false);
|
||||||
|
});
|
||||||
|
}, [isZooming]);
|
||||||
|
|
||||||
|
// Handle zoom out (back to world view)
|
||||||
|
const handleZoomOut = useCallback(() => {
|
||||||
|
if (!mapRef.current || isZooming) return;
|
||||||
|
|
||||||
|
setIsZooming(true);
|
||||||
|
|
||||||
|
mapRef.current.flyTo({
|
||||||
|
center: [0, 0],
|
||||||
|
zoom: 1,
|
||||||
|
pitch: 50,
|
||||||
|
bearing: 0,
|
||||||
|
duration: 1500,
|
||||||
|
essential: true
|
||||||
|
});
|
||||||
|
|
||||||
|
mapRef.current.once('moveend', () => {
|
||||||
setZoomedIsland(null);
|
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]);
|
||||||
|
|
||||||
// Initialize MapLibre
|
// Initialize MapLibre
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -104,7 +135,7 @@ export default function IslandsWorld({ theme, onIslandClick, onOpenMap, onOpenPl
|
||||||
id: 'background',
|
id: 'background',
|
||||||
type: 'background',
|
type: 'background',
|
||||||
paint: {
|
paint: {
|
||||||
'background-color': '#0a1628' // Deep ocean
|
'background-color': '#0a1628'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
@ -122,23 +153,14 @@ export default function IslandsWorld({ theme, onIslandClick, onOpenMap, onOpenPl
|
||||||
map.on('load', () => {
|
map.on('load', () => {
|
||||||
// Add ocean gradient layer
|
// Add ocean gradient layer
|
||||||
const oceanGradientData = createOceanGradient(centerLng, centerLat);
|
const oceanGradientData = createOceanGradient(centerLng, centerLat);
|
||||||
map.addSource('ocean-gradient', {
|
map.addSource('ocean-gradient', { type: 'geojson', data: oceanGradientData });
|
||||||
type: 'geojson',
|
|
||||||
data: oceanGradientData
|
|
||||||
});
|
|
||||||
|
|
||||||
map.addLayer({
|
map.addLayer({
|
||||||
id: 'ocean-glow',
|
id: 'ocean-glow',
|
||||||
type: 'fill',
|
type: 'fill',
|
||||||
source: 'ocean-gradient',
|
source: 'ocean-gradient',
|
||||||
paint: {
|
paint: {
|
||||||
'fill-color': [
|
'fill-color': ['interpolate', ['linear'], ['get', 'intensity'],
|
||||||
'interpolate',
|
0, '#0a1628', 0.5, '#0d2847', 1, '#1e3a5f'
|
||||||
['linear'],
|
|
||||||
['get', 'intensity'],
|
|
||||||
0, '#0a1628',
|
|
||||||
0.5, '#0d2847',
|
|
||||||
1, '#1e3a5f'
|
|
||||||
],
|
],
|
||||||
'fill-opacity': 0.8
|
'fill-opacity': 0.8
|
||||||
}
|
}
|
||||||
|
|
@ -174,6 +196,9 @@ export default function IslandsWorld({ theme, onIslandClick, onOpenMap, onOpenPl
|
||||||
const lng = centerLng + baseX + jitterX;
|
const lng = centerLng + baseX + jitterX;
|
||||||
const lat = centerLat + baseY + jitterY;
|
const lat = centerLat + baseY + jitterY;
|
||||||
|
|
||||||
|
// Store position for zoom
|
||||||
|
islandPositionsRef.current.set(island.username || island.id, { lng, lat });
|
||||||
|
|
||||||
const el = createIslandMarker(island, idx, t, username);
|
const el = createIslandMarker(island, idx, t, username);
|
||||||
|
|
||||||
el.addEventListener('mouseenter', () => setHoveredIsland(island));
|
el.addEventListener('mouseenter', () => setHoveredIsland(island));
|
||||||
|
|
@ -182,16 +207,14 @@ export default function IslandsWorld({ theme, onIslandClick, onOpenMap, onOpenPl
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const action = e.target.closest('[data-action]')?.dataset?.action;
|
const action = e.target.closest('[data-action]')?.dataset?.action;
|
||||||
if (action === 'profile') {
|
if (action === 'profile') {
|
||||||
// Navigate to user profile
|
|
||||||
window.location.href = `/profile/${island.username}`;
|
window.location.href = `/profile/${island.username}`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (action === 'edit') {
|
if (action === 'edit') {
|
||||||
// Navigate to island edit
|
|
||||||
window.location.href = `/island/${island.username}/edit`;
|
window.location.href = `/island/${island.username}/edit`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Default: zoom into island terrain
|
// Zoom to island
|
||||||
handleZoomToIsland(island);
|
handleZoomToIsland(island);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -207,7 +230,7 @@ export default function IslandsWorld({ theme, onIslandClick, onOpenMap, onOpenPl
|
||||||
|
|
||||||
markersRef.current = markers;
|
markersRef.current = markers;
|
||||||
|
|
||||||
// Gentle rotation
|
// Gentle rotation (only when not zoomed)
|
||||||
let bearing = 0;
|
let bearing = 0;
|
||||||
function animate() {
|
function animate() {
|
||||||
bearing += 0.05;
|
bearing += 0.05;
|
||||||
|
|
@ -222,9 +245,10 @@ export default function IslandsWorld({ theme, onIslandClick, onOpenMap, onOpenPl
|
||||||
if (animationRef.current) cancelAnimationFrame(animationRef.current);
|
if (animationRef.current) cancelAnimationFrame(animationRef.current);
|
||||||
markersRef.current.forEach(({ marker }) => marker.remove());
|
markersRef.current.forEach(({ marker }) => marker.remove());
|
||||||
markersRef.current = [];
|
markersRef.current = [];
|
||||||
|
islandPositionsRef.current.clear();
|
||||||
map.remove();
|
map.remove();
|
||||||
};
|
};
|
||||||
}, [filteredIslands, loading, handleZoomToIsland, t, username]);
|
}, [filteredIslands, loading, t, username, handleZoomToIsland]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="islands-world">
|
<div className="islands-world">
|
||||||
|
|
@ -257,8 +281,8 @@ export default function IslandsWorld({ theme, onIslandClick, onOpenMap, onOpenPl
|
||||||
{/* Main canvas */}
|
{/* Main canvas */}
|
||||||
<div ref={containerRef} className="islands-world-canvas" />
|
<div ref={containerRef} className="islands-world-canvas" />
|
||||||
|
|
||||||
{/* Hovered island tooltip */}
|
{/* Hovered island tooltip (only when not zoomed) */}
|
||||||
{hoveredIsland && (
|
{hoveredIsland && !zoomedIsland && (
|
||||||
<div className="islands-world-tooltip">
|
<div className="islands-world-tooltip">
|
||||||
<div className="tooltip-avatar">
|
<div className="tooltip-avatar">
|
||||||
{hoveredIsland.avatar_url ? (
|
{hoveredIsland.avatar_url ? (
|
||||||
|
|
@ -277,33 +301,24 @@ export default function IslandsWorld({ theme, onIslandClick, onOpenMap, onOpenPl
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Zoomed island UI overlay */}
|
||||||
{/* Loading overlay */}
|
{zoomedIsland && !isZooming && (
|
||||||
{loading && (
|
<div className="island-zoomed-overlay">
|
||||||
<div className="islands-world-loading">
|
<div className="island-zoomed-header">
|
||||||
<div className="loading-spinner" />
|
<button className="island-zoomed-back" onClick={handleZoomOut}>
|
||||||
<p>{t('worlds.loadingIslands')}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Island Terrain View - Full screen when zoomed */}
|
|
||||||
{zoomedIsland && (
|
|
||||||
<div className="island-terrain-view">
|
|
||||||
<div className="island-terrain-header">
|
|
||||||
<button className="island-terrain-back" onClick={handleBackFromTerrain}>
|
|
||||||
<i className="fa-solid fa-arrow-left" />
|
<i className="fa-solid fa-arrow-left" />
|
||||||
<span>{t('worlds.backToIslands')}</span>
|
<span>{t('worlds.backToIslands')}</span>
|
||||||
</button>
|
</button>
|
||||||
<div className="island-terrain-info">
|
<div className="island-zoomed-info">
|
||||||
<h2>@{zoomedIsland.username}'s Island</h2>
|
<h2>@{zoomedIsland.username}</h2>
|
||||||
<span className="island-terrain-type">{zoomedIsland.terrain_type || 'tropical'}</span>
|
<span>{zoomedIsland.terrain_type || 'tropical'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="island-terrain-actions">
|
<div className="island-zoomed-actions">
|
||||||
<button className="island-terrain-action" onClick={() => window.location.href = `/profile/${zoomedIsland.username}`}>
|
<button className="island-zoomed-action" onClick={() => window.location.href = `/profile/${zoomedIsland.username}`}>
|
||||||
<i className="fa-solid fa-user" />
|
<i className="fa-solid fa-user" />
|
||||||
</button>
|
</button>
|
||||||
{username && username.toLowerCase() === (zoomedIsland.username || '').toLowerCase() && (
|
{username && username.toLowerCase() === (zoomedIsland.username || '').toLowerCase() && (
|
||||||
<button className="island-terrain-action island-terrain-post" onClick={() => {
|
<button className="island-zoomed-action island-zoomed-post" onClick={() => {
|
||||||
window.dispatchEvent(new CustomEvent('sociowire:create-post', {
|
window.dispatchEvent(new CustomEvent('sociowire:create-post', {
|
||||||
detail: { island: zoomedIsland.username }
|
detail: { island: zoomedIsland.username }
|
||||||
}));
|
}));
|
||||||
|
|
@ -314,221 +329,20 @@ export default function IslandsWorld({ theme, onIslandClick, onOpenMap, onOpenPl
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Terrain canvas with posts */}
|
|
||||||
<div ref={terrainRef} className="island-terrain-canvas">
|
|
||||||
{terrainLoading ? (
|
|
||||||
<div className="island-terrain-loading">
|
|
||||||
<div className="loading-spinner" />
|
|
||||||
<p>{t('worlds.loadingTerrain')}</p>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<IslandTerrainCanvas
|
|
||||||
island={zoomedIsland}
|
|
||||||
posts={islandPosts}
|
|
||||||
isOwner={username && username.toLowerCase() === (zoomedIsland.username || '').toLowerCase()}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
|
{/* Loading overlay */}
|
||||||
|
{loading && (
|
||||||
|
<div className="islands-world-loading">
|
||||||
|
<div className="loading-spinner" />
|
||||||
|
<p>{t('worlds.loadingIslands')}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Island Terrain Canvas - renders the island as a large terrain with posts
|
|
||||||
*/
|
|
||||||
function IslandTerrainCanvas({ island, posts, isOwner }) {
|
|
||||||
const canvasRef = useRef(null);
|
|
||||||
const mapRef = useRef(null);
|
|
||||||
|
|
||||||
const terrainColors = {
|
|
||||||
'tropical': { main: '#2ecc71', sand: '#F5DEB3', water: '#1e90ff' },
|
|
||||||
'desert': { main: '#f39c12', sand: '#EDC9AF', water: '#4aa3df' },
|
|
||||||
'arctic': { main: '#3498db', sand: '#E8E8E8', water: '#5fa8d3' },
|
|
||||||
'volcanic': { main: '#e74c3c', sand: '#2C2C2C', water: '#ff6b6b' },
|
|
||||||
'meadow': { main: '#27ae60', sand: '#C2B280', water: '#5dade2' },
|
|
||||||
};
|
|
||||||
|
|
||||||
const colors = terrainColors[island.terrain_type] || terrainColors['tropical'];
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!canvasRef.current) return;
|
|
||||||
|
|
||||||
const map = new maplibregl.Map({
|
|
||||||
container: canvasRef.current,
|
|
||||||
style: {
|
|
||||||
version: 8,
|
|
||||||
sources: {},
|
|
||||||
layers: [
|
|
||||||
{
|
|
||||||
id: 'background',
|
|
||||||
type: 'background',
|
|
||||||
paint: { 'background-color': colors.water }
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
center: [0, 0],
|
|
||||||
zoom: 3,
|
|
||||||
pitch: 45,
|
|
||||||
bearing: 0,
|
|
||||||
attributionControl: false
|
|
||||||
});
|
|
||||||
|
|
||||||
mapRef.current = map;
|
|
||||||
|
|
||||||
map.on('load', () => {
|
|
||||||
// Create island terrain shape
|
|
||||||
const seed = island.seed || (island.username || '').charCodeAt(0) || 42;
|
|
||||||
const terrainData = generateTerrainGeoJSON(seed, colors.main, colors.sand);
|
|
||||||
|
|
||||||
map.addSource('terrain', { type: 'geojson', data: terrainData.island });
|
|
||||||
map.addSource('beach', { type: 'geojson', data: terrainData.beach });
|
|
||||||
|
|
||||||
// Beach layer
|
|
||||||
map.addLayer({
|
|
||||||
id: 'beach',
|
|
||||||
type: 'fill',
|
|
||||||
source: 'beach',
|
|
||||||
paint: {
|
|
||||||
'fill-color': colors.sand,
|
|
||||||
'fill-opacity': 0.9
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Island terrain layer
|
|
||||||
map.addLayer({
|
|
||||||
id: 'terrain',
|
|
||||||
type: 'fill',
|
|
||||||
source: 'terrain',
|
|
||||||
paint: {
|
|
||||||
'fill-color': colors.main,
|
|
||||||
'fill-opacity': 1
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add terrain details (trees, rocks, etc.)
|
|
||||||
const detailsData = generateTerrainDetails(seed);
|
|
||||||
map.addSource('details', { type: 'geojson', data: detailsData });
|
|
||||||
map.addLayer({
|
|
||||||
id: 'details',
|
|
||||||
type: 'circle',
|
|
||||||
source: 'details',
|
|
||||||
paint: {
|
|
||||||
'circle-radius': ['get', 'size'],
|
|
||||||
'circle-color': ['get', 'color'],
|
|
||||||
'circle-opacity': 0.8
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add posts as markers on the terrain
|
|
||||||
posts.forEach((post, idx) => {
|
|
||||||
const angle = (idx / Math.max(posts.length, 1)) * Math.PI * 2;
|
|
||||||
const radius = 0.01 + Math.random() * 0.02;
|
|
||||||
const lng = Math.cos(angle) * radius;
|
|
||||||
const lat = Math.sin(angle) * radius;
|
|
||||||
|
|
||||||
const el = document.createElement('div');
|
|
||||||
el.className = 'terrain-post-marker';
|
|
||||||
el.innerHTML = `
|
|
||||||
<div class="terrain-post-icon">
|
|
||||||
<i class="fa-solid ${post.media_type === 'video' ? 'fa-video' : post.media_type === 'image' ? 'fa-image' : 'fa-file-alt'}"></i>
|
|
||||||
</div>
|
|
||||||
<div class="terrain-post-title">${(post.title || post.caption || '').slice(0, 30)}...</div>
|
|
||||||
`;
|
|
||||||
el.addEventListener('click', () => {
|
|
||||||
window.dispatchEvent(new CustomEvent('sw:openPostDetail', { detail: { post } }));
|
|
||||||
});
|
|
||||||
|
|
||||||
new maplibregl.Marker({ element: el, anchor: 'bottom' })
|
|
||||||
.setLngLat([lng, lat])
|
|
||||||
.addTo(map);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
map.remove();
|
|
||||||
};
|
|
||||||
}, [island, posts, colors]);
|
|
||||||
|
|
||||||
return <div ref={canvasRef} className="island-terrain-map" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate terrain GeoJSON for island view
|
|
||||||
*/
|
|
||||||
function generateTerrainGeoJSON(seed, mainColor, sandColor) {
|
|
||||||
const rng = seededRandom(seed);
|
|
||||||
const numPoints = 24;
|
|
||||||
const islandPoints = [];
|
|
||||||
const beachPoints = [];
|
|
||||||
|
|
||||||
for (let i = 0; i < numPoints; i++) {
|
|
||||||
const angle = (i / numPoints) * Math.PI * 2;
|
|
||||||
const islandRadius = 0.025 + rng() * 0.015;
|
|
||||||
const beachRadius = islandRadius * 1.15;
|
|
||||||
|
|
||||||
islandPoints.push([Math.cos(angle) * islandRadius, Math.sin(angle) * islandRadius]);
|
|
||||||
beachPoints.push([Math.cos(angle) * beachRadius, Math.sin(angle) * beachRadius]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close the polygons
|
|
||||||
islandPoints.push(islandPoints[0]);
|
|
||||||
beachPoints.push(beachPoints[0]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
island: {
|
|
||||||
type: 'Feature',
|
|
||||||
geometry: { type: 'Polygon', coordinates: [islandPoints] }
|
|
||||||
},
|
|
||||||
beach: {
|
|
||||||
type: 'Feature',
|
|
||||||
geometry: { type: 'Polygon', coordinates: [beachPoints] }
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate terrain details (trees, rocks, flowers)
|
|
||||||
*/
|
|
||||||
function generateTerrainDetails(seed) {
|
|
||||||
const rng = seededRandom(seed);
|
|
||||||
const features = [];
|
|
||||||
const detailCount = 30 + Math.floor(rng() * 20);
|
|
||||||
|
|
||||||
for (let i = 0; i < detailCount; i++) {
|
|
||||||
const angle = rng() * Math.PI * 2;
|
|
||||||
const radius = rng() * 0.02;
|
|
||||||
const lng = Math.cos(angle) * radius;
|
|
||||||
const lat = Math.sin(angle) * radius;
|
|
||||||
|
|
||||||
const type = rng();
|
|
||||||
let color, size;
|
|
||||||
if (type < 0.5) {
|
|
||||||
// Tree
|
|
||||||
color = '#228B22';
|
|
||||||
size = 4 + rng() * 3;
|
|
||||||
} else if (type < 0.75) {
|
|
||||||
// Rock
|
|
||||||
color = '#696969';
|
|
||||||
size = 2 + rng() * 2;
|
|
||||||
} else {
|
|
||||||
// Flower
|
|
||||||
color = ['#FF69B4', '#FFD700', '#FF6347', '#9370DB'][Math.floor(rng() * 4)];
|
|
||||||
size = 2 + rng();
|
|
||||||
}
|
|
||||||
|
|
||||||
features.push({
|
|
||||||
type: 'Feature',
|
|
||||||
geometry: { type: 'Point', coordinates: [lng, lat] },
|
|
||||||
properties: { color, size }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return { type: 'FeatureCollection', features };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a stylized island marker element
|
* Create a stylized island marker element
|
||||||
*/
|
*/
|
||||||
|
|
@ -545,8 +359,6 @@ function createIslandMarker(island, idx, t, currentUsername) {
|
||||||
};
|
};
|
||||||
const color = terrainColors[island.terrain_type] || terrainColors['tropical'];
|
const color = terrainColors[island.terrain_type] || terrainColors['tropical'];
|
||||||
const seed = island.seed || idx;
|
const seed = island.seed || idx;
|
||||||
|
|
||||||
// Generate unique island shape
|
|
||||||
const islandSVG = generateIslandSVG(seed, color);
|
const islandSVG = generateIslandSVG(seed, color);
|
||||||
const postCount = island.content_count || 0;
|
const postCount = island.content_count || 0;
|
||||||
const isOwner = currentUsername && island.username &&
|
const isOwner = currentUsername && island.username &&
|
||||||
|
|
@ -585,19 +397,14 @@ function generateIslandSVG(seed, color) {
|
||||||
const cy = size / 2;
|
const cy = size / 2;
|
||||||
const numPoints = 10;
|
const numPoints = 10;
|
||||||
|
|
||||||
// Generate organic shape
|
|
||||||
const points = [];
|
const points = [];
|
||||||
for (let i = 0; i < numPoints; i++) {
|
for (let i = 0; i < numPoints; i++) {
|
||||||
const angle = (i / numPoints) * Math.PI * 2;
|
const angle = (i / numPoints) * Math.PI * 2;
|
||||||
const radiusVar = 0.55 + rng() * 0.45;
|
const radiusVar = 0.55 + rng() * 0.45;
|
||||||
const r = (size / 2.5) * radiusVar;
|
const r = (size / 2.5) * radiusVar;
|
||||||
points.push({
|
points.push({ x: cx + Math.cos(angle) * r, y: cy + Math.sin(angle) * r });
|
||||||
x: cx + Math.cos(angle) * r,
|
|
||||||
y: cy + Math.sin(angle) * r
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create smooth path
|
|
||||||
let path = `M ${points[0].x} ${points[0].y}`;
|
let path = `M ${points[0].x} ${points[0].y}`;
|
||||||
for (let i = 0; i < numPoints; i++) {
|
for (let i = 0; i < numPoints; i++) {
|
||||||
const curr = points[i];
|
const curr = points[i];
|
||||||
|
|
@ -608,7 +415,6 @@ function generateIslandSVG(seed, color) {
|
||||||
}
|
}
|
||||||
path += ' Z';
|
path += ' Z';
|
||||||
|
|
||||||
// Add decorations
|
|
||||||
const numTrees = 3 + Math.floor(rng() * 4);
|
const numTrees = 3 + Math.floor(rng() * 4);
|
||||||
let trees = '';
|
let trees = '';
|
||||||
for (let i = 0; i < numTrees; i++) {
|
for (let i = 0; i < numTrees; i++) {
|
||||||
|
|
@ -635,25 +441,16 @@ function generateIslandSVG(seed, color) {
|
||||||
</radialGradient>
|
</radialGradient>
|
||||||
<filter id="glow-${seed}">
|
<filter id="glow-${seed}">
|
||||||
<feGaussianBlur stdDeviation="4" result="blur"/>
|
<feGaussianBlur stdDeviation="4" result="blur"/>
|
||||||
<feMerge>
|
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||||
<feMergeNode in="blur"/>
|
|
||||||
<feMergeNode in="SourceGraphic"/>
|
|
||||||
</feMerge>
|
|
||||||
</filter>
|
</filter>
|
||||||
</defs>
|
</defs>
|
||||||
<!-- Beach ring -->
|
|
||||||
<path d="${path}" fill="#F5DEB3" transform="scale(1.08) translate(-5.6, -5.6)" opacity="0.6"/>
|
<path d="${path}" fill="#F5DEB3" transform="scale(1.08) translate(-5.6, -5.6)" opacity="0.6"/>
|
||||||
<!-- Main island -->
|
|
||||||
<path d="${path}" fill="url(#ig-${seed})" filter="url(#glow-${seed})"/>
|
<path d="${path}" fill="url(#ig-${seed})" filter="url(#glow-${seed})"/>
|
||||||
<!-- Trees -->
|
|
||||||
${trees}
|
${trees}
|
||||||
</svg>
|
</svg>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Create starfield GeoJSON
|
|
||||||
*/
|
|
||||||
function createStarfield(count) {
|
function createStarfield(count) {
|
||||||
const features = [];
|
const features = [];
|
||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < count; i++) {
|
||||||
|
|
@ -672,9 +469,6 @@ function createStarfield(count) {
|
||||||
return { type: 'FeatureCollection', features };
|
return { type: 'FeatureCollection', features };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Create ocean gradient GeoJSON
|
|
||||||
*/
|
|
||||||
function createOceanGradient(centerLng, centerLat) {
|
function createOceanGradient(centerLng, centerLat) {
|
||||||
const features = [];
|
const features = [];
|
||||||
const rings = 10;
|
const rings = 10;
|
||||||
|
|
@ -683,10 +477,7 @@ function createOceanGradient(centerLng, centerLat) {
|
||||||
const coords = [];
|
const coords = [];
|
||||||
for (let j = 0; j <= 64; j++) {
|
for (let j = 0; j <= 64; j++) {
|
||||||
const angle = (j / 64) * Math.PI * 2;
|
const angle = (j / 64) * Math.PI * 2;
|
||||||
coords.push([
|
coords.push([centerLng + Math.cos(angle) * radius, centerLat + Math.sin(angle) * radius]);
|
||||||
centerLng + Math.cos(angle) * radius,
|
|
||||||
centerLat + Math.sin(angle) * radius
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
features.push({
|
features.push({
|
||||||
type: 'Feature',
|
type: 'Feature',
|
||||||
|
|
@ -697,9 +488,6 @@ function createOceanGradient(centerLng, centerLat) {
|
||||||
return { type: 'FeatureCollection', features };
|
return { type: 'FeatureCollection', features };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Seeded random number generator
|
|
||||||
*/
|
|
||||||
function seededRandom(seed) {
|
function seededRandom(seed) {
|
||||||
let s = seed;
|
let s = seed;
|
||||||
return function() {
|
return function() {
|
||||||
|
|
|
||||||
|
|
@ -19,10 +19,10 @@
|
||||||
/* Search bar - fixed below topbar */
|
/* Search bar - fixed below topbar */
|
||||||
.planets-world-search {
|
.planets-world-search {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 56px;
|
top: 52px;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
z-index: 100;
|
z-index: 600;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,12 @@ import React, { useEffect, useRef, useState, useCallback } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import maplibregl from 'maplibre-gl';
|
import maplibregl from 'maplibre-gl';
|
||||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||||
import { fetchPlanets } from '../../api/client';
|
import { fetchPlanets, createPlanet } from '../../api/client';
|
||||||
import { useAuth } from '../../auth/AuthContext';
|
import { useAuth } from '../../auth/AuthContext';
|
||||||
import './PlanetsWorld.css';
|
import './PlanetsWorld.css';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PlanetsWorld: Full-screen metaverse for group spaces (planets)
|
* PlanetsWorld: Full-screen metaverse for group spaces (planets)
|
||||||
* Each planet represents a community/group floating in space
|
|
||||||
*/
|
*/
|
||||||
export default function PlanetsWorld({ theme, onOpenMap, onOpenIslands }) {
|
export default function PlanetsWorld({ theme, onOpenMap, onOpenIslands }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
@ -17,16 +16,23 @@ export default function PlanetsWorld({ theme, onOpenMap, onOpenIslands }) {
|
||||||
const mapRef = useRef(null);
|
const mapRef = useRef(null);
|
||||||
const markersRef = useRef([]);
|
const markersRef = useRef([]);
|
||||||
const animationRef = useRef(null);
|
const animationRef = useRef(null);
|
||||||
|
const planetPositionsRef = useRef(new Map());
|
||||||
|
|
||||||
const [planets, setPlanets] = useState([]);
|
const [planets, setPlanets] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [hoveredPlanet, setHoveredPlanet] = useState(null);
|
const [hoveredPlanet, setHoveredPlanet] = useState(null);
|
||||||
|
|
||||||
// Terrain view state
|
// Zoomed planet state
|
||||||
const [zoomedPlanet, setZoomedPlanet] = useState(null);
|
const [zoomedPlanet, setZoomedPlanet] = useState(null);
|
||||||
const [planetPosts, setPlanetPosts] = useState([]);
|
const [isZooming, setIsZooming] = useState(false);
|
||||||
const [terrainLoading, setTerrainLoading] = useState(false);
|
|
||||||
|
// Create planet modal
|
||||||
|
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||||
|
const [newPlanetName, setNewPlanetName] = useState('');
|
||||||
|
const [newPlanetDesc, setNewPlanetDesc] = useState('');
|
||||||
|
const [newPlanetTheme, setNewPlanetTheme] = useState('default');
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
|
||||||
// Fetch planets
|
// Fetch planets
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -56,54 +62,101 @@ export default function PlanetsWorld({ theme, onOpenMap, onOpenIslands }) {
|
||||||
)
|
)
|
||||||
: planets;
|
: planets;
|
||||||
|
|
||||||
// Load planet posts when zooming into terrain view
|
// Handle create planet
|
||||||
useEffect(() => {
|
const handleCreatePlanet = async () => {
|
||||||
if (!zoomedPlanet) {
|
if (!newPlanetName.trim() || creating) return;
|
||||||
setPlanetPosts([]);
|
setCreating(true);
|
||||||
return;
|
|
||||||
|
const result = await createPlanet({
|
||||||
|
name: newPlanetName.trim(),
|
||||||
|
description: newPlanetDesc.trim(),
|
||||||
|
theme: newPlanetTheme
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.ok || result.id) {
|
||||||
|
// Add new planet to list
|
||||||
|
setPlanets(prev => [result, ...prev]);
|
||||||
|
setShowCreateModal(false);
|
||||||
|
setNewPlanetName('');
|
||||||
|
setNewPlanetDesc('');
|
||||||
|
setNewPlanetTheme('default');
|
||||||
|
} else {
|
||||||
|
alert(result.error || 'Failed to create planet');
|
||||||
}
|
}
|
||||||
setTerrainLoading(true);
|
setCreating(false);
|
||||||
// TODO: Add fetchPlanetPosts API function
|
};
|
||||||
// For now, simulate with empty posts
|
|
||||||
setTimeout(() => {
|
|
||||||
setPlanetPosts([]);
|
|
||||||
setTerrainLoading(false);
|
|
||||||
}, 500);
|
|
||||||
}, [zoomedPlanet]);
|
|
||||||
|
|
||||||
// Handle zoom into planet terrain
|
// Handle zoom to planet
|
||||||
const handleZoomToPlanet = useCallback((planet) => {
|
const handleZoomToPlanet = useCallback((planet) => {
|
||||||
setZoomedPlanet(planet);
|
if (!mapRef.current || isZooming) return;
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Handle back from terrain view
|
const pos = planetPositionsRef.current.get(planet.id || planet.name);
|
||||||
const handleBackFromTerrain = useCallback(() => {
|
if (!pos) return;
|
||||||
|
|
||||||
|
setIsZooming(true);
|
||||||
|
setZoomedPlanet(planet);
|
||||||
|
|
||||||
|
if (animationRef.current) {
|
||||||
|
cancelAnimationFrame(animationRef.current);
|
||||||
|
animationRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
mapRef.current.flyTo({
|
||||||
|
center: [pos.lng, pos.lat],
|
||||||
|
zoom: 4,
|
||||||
|
pitch: 60,
|
||||||
|
bearing: 0,
|
||||||
|
duration: 1500,
|
||||||
|
essential: true
|
||||||
|
});
|
||||||
|
|
||||||
|
mapRef.current.once('moveend', () => {
|
||||||
|
setIsZooming(false);
|
||||||
|
});
|
||||||
|
}, [isZooming]);
|
||||||
|
|
||||||
|
// Handle zoom out
|
||||||
|
const handleZoomOut = useCallback(() => {
|
||||||
|
if (!mapRef.current || isZooming) return;
|
||||||
|
|
||||||
|
setIsZooming(true);
|
||||||
|
|
||||||
|
mapRef.current.flyTo({
|
||||||
|
center: [0, 0],
|
||||||
|
zoom: 0.8,
|
||||||
|
pitch: 45,
|
||||||
|
bearing: 0,
|
||||||
|
duration: 1500,
|
||||||
|
essential: true
|
||||||
|
});
|
||||||
|
|
||||||
|
mapRef.current.once('moveend', () => {
|
||||||
setZoomedPlanet(null);
|
setZoomedPlanet(null);
|
||||||
}, []);
|
setIsZooming(false);
|
||||||
|
|
||||||
|
let bearing = 0;
|
||||||
|
function animate() {
|
||||||
|
bearing += 0.03;
|
||||||
|
if (bearing >= 360) bearing = 0;
|
||||||
|
mapRef.current?.rotateTo(bearing, { duration: 100 });
|
||||||
|
animationRef.current = requestAnimationFrame(animate);
|
||||||
|
}
|
||||||
|
animate();
|
||||||
|
});
|
||||||
|
}, [isZooming]);
|
||||||
|
|
||||||
// Initialize MapLibre
|
// Initialize MapLibre
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!containerRef.current || loading) return;
|
if (!containerRef.current || loading) return;
|
||||||
|
|
||||||
const centerLng = 0;
|
|
||||||
const centerLat = 0;
|
|
||||||
|
|
||||||
const map = new maplibregl.Map({
|
const map = new maplibregl.Map({
|
||||||
container: containerRef.current,
|
container: containerRef.current,
|
||||||
style: {
|
style: {
|
||||||
version: 8,
|
version: 8,
|
||||||
sources: {},
|
sources: {},
|
||||||
layers: [
|
layers: [{ id: 'background', type: 'background', paint: { 'background-color': '#050510' } }]
|
||||||
{
|
|
||||||
id: 'background',
|
|
||||||
type: 'background',
|
|
||||||
paint: {
|
|
||||||
'background-color': '#050510' // Deep space black
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
center: [centerLng, centerLat],
|
center: [0, 0],
|
||||||
zoom: 0.8,
|
zoom: 0.8,
|
||||||
pitch: 45,
|
pitch: 45,
|
||||||
bearing: 0,
|
bearing: 0,
|
||||||
|
|
@ -114,26 +167,19 @@ export default function PlanetsWorld({ theme, onOpenMap, onOpenIslands }) {
|
||||||
mapRef.current = map;
|
mapRef.current = map;
|
||||||
|
|
||||||
map.on('load', () => {
|
map.on('load', () => {
|
||||||
// Add nebula effect
|
// Nebula effect
|
||||||
const nebulaData = createNebulaEffect();
|
const nebulaData = createNebulaEffect();
|
||||||
map.addSource('nebula', { type: 'geojson', data: nebulaData });
|
map.addSource('nebula', { type: 'geojson', data: nebulaData });
|
||||||
map.addLayer({
|
map.addLayer({
|
||||||
id: 'nebula',
|
id: 'nebula', type: 'fill', source: 'nebula',
|
||||||
type: 'fill',
|
paint: { 'fill-color': ['get', 'color'], 'fill-opacity': ['get', 'opacity'] }
|
||||||
source: 'nebula',
|
|
||||||
paint: {
|
|
||||||
'fill-color': ['get', 'color'],
|
|
||||||
'fill-opacity': ['get', 'opacity']
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add starfield
|
// Starfield
|
||||||
const starsData = createStarfield(800);
|
const starsData = createStarfield(800);
|
||||||
map.addSource('stars', { type: 'geojson', data: starsData });
|
map.addSource('stars', { type: 'geojson', data: starsData });
|
||||||
map.addLayer({
|
map.addLayer({
|
||||||
id: 'stars',
|
id: 'stars', type: 'circle', source: 'stars',
|
||||||
type: 'circle',
|
|
||||||
source: 'stars',
|
|
||||||
paint: {
|
paint: {
|
||||||
'circle-radius': ['interpolate', ['linear'], ['zoom'], 0, 0.8, 5, 2.5],
|
'circle-radius': ['interpolate', ['linear'], ['zoom'], 0, 0.8, 5, 2.5],
|
||||||
'circle-color': ['get', 'color'],
|
'circle-color': ['get', 'color'],
|
||||||
|
|
@ -142,20 +188,19 @@ export default function PlanetsWorld({ theme, onOpenMap, onOpenIslands }) {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create planet markers in orbital rings
|
// Planet markers
|
||||||
const markers = [];
|
const markers = [];
|
||||||
|
|
||||||
filteredPlanets.forEach((planet, idx) => {
|
filteredPlanets.forEach((planet, idx) => {
|
||||||
const ring = Math.floor(idx / 6);
|
const ring = Math.floor(idx / 6);
|
||||||
const posInRing = idx % 6;
|
const posInRing = idx % 6;
|
||||||
const baseRadius = 25 + ring * 30;
|
const baseRadius = 25 + ring * 30;
|
||||||
const angle = (posInRing / 6) * Math.PI * 2 + (ring * 0.5);
|
const angle = (posInRing / 6) * Math.PI * 2 + (ring * 0.5);
|
||||||
|
const lng = Math.cos(angle) * baseRadius;
|
||||||
|
const lat = Math.sin(angle) * baseRadius;
|
||||||
|
|
||||||
const lng = centerLng + Math.cos(angle) * baseRadius;
|
planetPositionsRef.current.set(planet.id || planet.name, { lng, lat });
|
||||||
const lat = centerLat + Math.sin(angle) * baseRadius;
|
|
||||||
|
|
||||||
const el = createPlanetMarker(planet, idx, t);
|
|
||||||
|
|
||||||
|
const el = createPlanetMarker(planet, idx);
|
||||||
el.addEventListener('mouseenter', () => setHoveredPlanet(planet));
|
el.addEventListener('mouseenter', () => setHoveredPlanet(planet));
|
||||||
el.addEventListener('mouseleave', () => setHoveredPlanet(null));
|
el.addEventListener('mouseleave', () => setHoveredPlanet(null));
|
||||||
el.addEventListener('click', (e) => {
|
el.addEventListener('click', (e) => {
|
||||||
|
|
@ -163,10 +208,7 @@ export default function PlanetsWorld({ theme, onOpenMap, onOpenIslands }) {
|
||||||
handleZoomToPlanet(planet);
|
handleZoomToPlanet(planet);
|
||||||
});
|
});
|
||||||
|
|
||||||
const marker = new maplibregl.Marker({
|
const marker = new maplibregl.Marker({ element: el, anchor: 'center' })
|
||||||
element: el,
|
|
||||||
anchor: 'center'
|
|
||||||
})
|
|
||||||
.setLngLat([lng, lat])
|
.setLngLat([lng, lat])
|
||||||
.addTo(map);
|
.addTo(map);
|
||||||
|
|
||||||
|
|
@ -175,7 +217,7 @@ export default function PlanetsWorld({ theme, onOpenMap, onOpenIslands }) {
|
||||||
|
|
||||||
markersRef.current = markers;
|
markersRef.current = markers;
|
||||||
|
|
||||||
// Slow cosmic rotation
|
// Rotation
|
||||||
let bearing = 0;
|
let bearing = 0;
|
||||||
function animate() {
|
function animate() {
|
||||||
bearing += 0.03;
|
bearing += 0.03;
|
||||||
|
|
@ -190,13 +232,14 @@ export default function PlanetsWorld({ theme, onOpenMap, onOpenIslands }) {
|
||||||
if (animationRef.current) cancelAnimationFrame(animationRef.current);
|
if (animationRef.current) cancelAnimationFrame(animationRef.current);
|
||||||
markersRef.current.forEach(({ marker }) => marker.remove());
|
markersRef.current.forEach(({ marker }) => marker.remove());
|
||||||
markersRef.current = [];
|
markersRef.current = [];
|
||||||
|
planetPositionsRef.current.clear();
|
||||||
map.remove();
|
map.remove();
|
||||||
};
|
};
|
||||||
}, [filteredPlanets, loading, t, handleZoomToPlanet]);
|
}, [filteredPlanets, loading, handleZoomToPlanet]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="planets-world">
|
<div className="planets-world">
|
||||||
{/* Search bar with icon and world nav */}
|
{/* Search bar */}
|
||||||
<div className="planets-world-search">
|
<div className="planets-world-search">
|
||||||
<i className="fa-solid fa-globe search-icon" />
|
<i className="fa-solid fa-globe search-icon" />
|
||||||
<i className="fa-solid fa-search" />
|
<i className="fa-solid fa-search" />
|
||||||
|
|
@ -222,17 +265,17 @@ export default function PlanetsWorld({ theme, onOpenMap, onOpenIslands }) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main canvas */}
|
{/* Canvas */}
|
||||||
<div ref={containerRef} className="planets-world-canvas" />
|
<div ref={containerRef} className="planets-world-canvas" />
|
||||||
|
|
||||||
{/* Hovered planet tooltip */}
|
{/* Tooltip */}
|
||||||
{hoveredPlanet && !zoomedPlanet && (
|
{hoveredPlanet && !zoomedPlanet && (
|
||||||
<div className="planets-world-tooltip">
|
<div className="planets-world-tooltip">
|
||||||
<div className="tooltip-icon" style={{ background: getPlanetGradient(hoveredPlanet.theme) }}>
|
<div className="tooltip-icon" style={{ background: getPlanetGradient(hoveredPlanet.theme) }}>
|
||||||
<i className="fa-solid fa-globe" />
|
<i className="fa-solid fa-globe" />
|
||||||
</div>
|
</div>
|
||||||
<div className="tooltip-info">
|
<div className="tooltip-info">
|
||||||
<div className="tooltip-name">{hoveredPlanet.name || 'Unknown Planet'}</div>
|
<div className="tooltip-name">{hoveredPlanet.name || 'Planet'}</div>
|
||||||
<div className="tooltip-desc">{hoveredPlanet.description || t('worlds.clickToExplore')}</div>
|
<div className="tooltip-desc">{hoveredPlanet.description || t('worlds.clickToExplore')}</div>
|
||||||
<div className="tooltip-stats">
|
<div className="tooltip-stats">
|
||||||
{hoveredPlanet.member_count || 0} {t('worlds.members')} • {hoveredPlanet.theme || 'default'}
|
{hoveredPlanet.member_count || 0} {t('worlds.members')} • {hoveredPlanet.theme || 'default'}
|
||||||
|
|
@ -241,243 +284,98 @@ export default function PlanetsWorld({ theme, onOpenMap, onOpenIslands }) {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Loading overlay */}
|
{/* Zoomed planet overlay */}
|
||||||
{loading && (
|
{zoomedPlanet && !isZooming && (
|
||||||
<div className="planets-world-loading">
|
<div className="planet-zoomed-overlay">
|
||||||
<div className="loading-spinner" />
|
<div className="planet-zoomed-header">
|
||||||
<p>{t('worlds.loadingPlanets')}</p>
|
<button className="planet-zoomed-back" onClick={handleZoomOut}>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Planet Terrain View - Full screen when zoomed */}
|
|
||||||
{zoomedPlanet && (
|
|
||||||
<div className="planet-terrain-view">
|
|
||||||
<div className="planet-terrain-header">
|
|
||||||
<button className="planet-terrain-back" onClick={handleBackFromTerrain}>
|
|
||||||
<i className="fa-solid fa-arrow-left" />
|
<i className="fa-solid fa-arrow-left" />
|
||||||
<span>{t('worlds.backToPlanets')}</span>
|
<span>{t('worlds.backToPlanets')}</span>
|
||||||
</button>
|
</button>
|
||||||
<div className="planet-terrain-info">
|
<div className="planet-zoomed-info">
|
||||||
<h2>{zoomedPlanet.name || 'Planet'}</h2>
|
<h2>{zoomedPlanet.name}</h2>
|
||||||
<span className="planet-terrain-theme">{zoomedPlanet.theme || 'default'}</span>
|
<span>{zoomedPlanet.theme || 'default'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="planet-terrain-actions">
|
<div className="planet-zoomed-actions">
|
||||||
<button className="planet-terrain-action">
|
<button className="planet-zoomed-action">
|
||||||
<i className="fa-solid fa-users" />
|
<i className="fa-solid fa-users" />
|
||||||
<span>{zoomedPlanet.member_count || 0}</span>
|
<span>{zoomedPlanet.member_count || 0}</span>
|
||||||
</button>
|
</button>
|
||||||
{authenticated && (
|
{authenticated && (
|
||||||
<button className="planet-terrain-action planet-terrain-post">
|
<button className="planet-zoomed-action planet-zoomed-post">
|
||||||
<i className="fa-solid fa-plus" />
|
<i className="fa-solid fa-plus" />
|
||||||
<span>{t('worlds.postHere')}</span>
|
<span>{t('worlds.postHere')}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Terrain canvas with posts */}
|
|
||||||
<div className="planet-terrain-canvas">
|
|
||||||
{terrainLoading ? (
|
|
||||||
<div className="planet-terrain-loading">
|
|
||||||
<div className="loading-spinner" />
|
|
||||||
<p>{t('worlds.loadingTerrain')}</p>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<PlanetTerrainCanvas
|
|
||||||
planet={zoomedPlanet}
|
|
||||||
posts={planetPosts}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Create Planet Button */}
|
||||||
|
{authenticated && !zoomedPlanet && (
|
||||||
|
<button className="planets-world-create" onClick={() => setShowCreateModal(true)}>
|
||||||
|
<i className="fa-solid fa-plus" />
|
||||||
|
<span>{t('worlds.createPlanet')}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create Planet Modal */}
|
||||||
|
{showCreateModal && (
|
||||||
|
<div className="planet-create-modal-backdrop" onClick={() => setShowCreateModal(false)}>
|
||||||
|
<div className="planet-create-modal" onClick={e => e.stopPropagation()}>
|
||||||
|
<button className="planet-create-close" onClick={() => setShowCreateModal(false)}>
|
||||||
|
<i className="fa-solid fa-xmark" />
|
||||||
|
</button>
|
||||||
|
<h2>{t('worlds.createPlanet')}</h2>
|
||||||
|
<div className="planet-create-form">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder={t('worlds.planetName')}
|
||||||
|
value={newPlanetName}
|
||||||
|
onChange={e => setNewPlanetName(e.target.value)}
|
||||||
|
maxLength={50}
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
placeholder={t('worlds.planetDescription')}
|
||||||
|
value={newPlanetDesc}
|
||||||
|
onChange={e => setNewPlanetDesc(e.target.value)}
|
||||||
|
maxLength={200}
|
||||||
|
/>
|
||||||
|
<select value={newPlanetTheme} onChange={e => setNewPlanetTheme(e.target.value)}>
|
||||||
|
<option value="default">Default</option>
|
||||||
|
<option value="tech">Tech</option>
|
||||||
|
<option value="gaming">Gaming</option>
|
||||||
|
<option value="music">Music</option>
|
||||||
|
<option value="art">Art</option>
|
||||||
|
<option value="science">Science</option>
|
||||||
|
<option value="sports">Sports</option>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
className="planet-create-submit"
|
||||||
|
onClick={handleCreatePlanet}
|
||||||
|
disabled={!newPlanetName.trim() || creating}
|
||||||
|
>
|
||||||
|
{creating ? <i className="fa-solid fa-spinner fa-spin" /> : <i className="fa-solid fa-rocket" />}
|
||||||
|
<span>{creating ? t('worlds.creating') : t('worlds.createPlanet')}</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Loading */}
|
||||||
|
{loading && (
|
||||||
|
<div className="planets-world-loading">
|
||||||
|
<div className="loading-spinner" />
|
||||||
|
<p>{t('worlds.loadingPlanets')}</p>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function createPlanetMarker(planet, idx) {
|
||||||
* Planet Terrain Canvas - renders the planet as a large terrain with posts
|
|
||||||
*/
|
|
||||||
function PlanetTerrainCanvas({ planet, posts }) {
|
|
||||||
const canvasRef = useRef(null);
|
|
||||||
const mapRef = useRef(null);
|
|
||||||
|
|
||||||
const themeColors = {
|
|
||||||
'default': { main: '#9b59b6', accent: '#8e44ad', bg: '#1a0a2e' },
|
|
||||||
'tech': { main: '#3498db', accent: '#2980b9', bg: '#0a1a2e' },
|
|
||||||
'gaming': { main: '#e74c3c', accent: '#c0392b', bg: '#2e0a0a' },
|
|
||||||
'music': { main: '#1abc9c', accent: '#16a085', bg: '#0a2e2a' },
|
|
||||||
'art': { main: '#e91e63', accent: '#c2185b', bg: '#2e0a1a' },
|
|
||||||
'science': { main: '#00bcd4', accent: '#0097a7', bg: '#0a2e2e' },
|
|
||||||
'sports': { main: '#ff9800', accent: '#f57c00', bg: '#2e1a0a' },
|
|
||||||
};
|
|
||||||
|
|
||||||
const colors = themeColors[planet.theme] || themeColors['default'];
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!canvasRef.current) return;
|
|
||||||
|
|
||||||
const map = new maplibregl.Map({
|
|
||||||
container: canvasRef.current,
|
|
||||||
style: {
|
|
||||||
version: 8,
|
|
||||||
sources: {},
|
|
||||||
layers: [
|
|
||||||
{
|
|
||||||
id: 'background',
|
|
||||||
type: 'background',
|
|
||||||
paint: { 'background-color': colors.bg }
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
center: [0, 0],
|
|
||||||
zoom: 3,
|
|
||||||
pitch: 45,
|
|
||||||
bearing: 0,
|
|
||||||
attributionControl: false
|
|
||||||
});
|
|
||||||
|
|
||||||
mapRef.current = map;
|
|
||||||
|
|
||||||
map.on('load', () => {
|
|
||||||
// Create planet surface with craters/features
|
|
||||||
const seed = planet.id || (planet.name || '').charCodeAt(0) || 42;
|
|
||||||
const surfaceData = generatePlanetSurface(seed, colors.main, colors.accent);
|
|
||||||
|
|
||||||
map.addSource('surface', { type: 'geojson', data: surfaceData.terrain });
|
|
||||||
map.addSource('features', { type: 'geojson', data: surfaceData.features });
|
|
||||||
|
|
||||||
// Planet surface
|
|
||||||
map.addLayer({
|
|
||||||
id: 'surface',
|
|
||||||
type: 'fill',
|
|
||||||
source: 'surface',
|
|
||||||
paint: {
|
|
||||||
'fill-color': colors.main,
|
|
||||||
'fill-opacity': 1
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Surface features (craters, mountains)
|
|
||||||
map.addLayer({
|
|
||||||
id: 'features',
|
|
||||||
type: 'circle',
|
|
||||||
source: 'features',
|
|
||||||
paint: {
|
|
||||||
'circle-radius': ['get', 'size'],
|
|
||||||
'circle-color': ['get', 'color'],
|
|
||||||
'circle-opacity': ['get', 'opacity']
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add posts as markers on the terrain
|
|
||||||
posts.forEach((post, idx) => {
|
|
||||||
const angle = (idx / Math.max(posts.length, 1)) * Math.PI * 2;
|
|
||||||
const radius = 0.01 + Math.random() * 0.02;
|
|
||||||
const lng = Math.cos(angle) * radius;
|
|
||||||
const lat = Math.sin(angle) * radius;
|
|
||||||
|
|
||||||
const el = document.createElement('div');
|
|
||||||
el.className = 'terrain-post-marker planet-post-marker';
|
|
||||||
el.innerHTML = `
|
|
||||||
<div class="terrain-post-icon" style="background: linear-gradient(135deg, ${colors.main}, ${colors.accent})">
|
|
||||||
<i class="fa-solid ${post.media_type === 'video' ? 'fa-video' : post.media_type === 'image' ? 'fa-image' : 'fa-file-alt'}"></i>
|
|
||||||
</div>
|
|
||||||
<div class="terrain-post-title">${(post.title || post.caption || '').slice(0, 30)}...</div>
|
|
||||||
`;
|
|
||||||
el.addEventListener('click', () => {
|
|
||||||
window.dispatchEvent(new CustomEvent('sw:openPostDetail', { detail: { post } }));
|
|
||||||
});
|
|
||||||
|
|
||||||
new maplibregl.Marker({ element: el, anchor: 'bottom' })
|
|
||||||
.setLngLat([lng, lat])
|
|
||||||
.addTo(map);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
map.remove();
|
|
||||||
};
|
|
||||||
}, [planet, posts, colors]);
|
|
||||||
|
|
||||||
return <div ref={canvasRef} className="planet-terrain-map" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate planet surface GeoJSON
|
|
||||||
*/
|
|
||||||
function generatePlanetSurface(seed, mainColor, accentColor) {
|
|
||||||
const rng = seededRandom(seed);
|
|
||||||
const numPoints = 32;
|
|
||||||
const surfacePoints = [];
|
|
||||||
|
|
||||||
for (let i = 0; i < numPoints; i++) {
|
|
||||||
const angle = (i / numPoints) * Math.PI * 2;
|
|
||||||
const radius = 0.03 + rng() * 0.01;
|
|
||||||
surfacePoints.push([Math.cos(angle) * radius, Math.sin(angle) * radius]);
|
|
||||||
}
|
|
||||||
surfacePoints.push(surfacePoints[0]);
|
|
||||||
|
|
||||||
// Generate craters and features
|
|
||||||
const features = [];
|
|
||||||
const featureCount = 20 + Math.floor(rng() * 15);
|
|
||||||
|
|
||||||
for (let i = 0; i < featureCount; i++) {
|
|
||||||
const angle = rng() * Math.PI * 2;
|
|
||||||
const radius = rng() * 0.025;
|
|
||||||
const lng = Math.cos(angle) * radius;
|
|
||||||
const lat = Math.sin(angle) * radius;
|
|
||||||
|
|
||||||
const type = rng();
|
|
||||||
let color, size, opacity;
|
|
||||||
if (type < 0.4) {
|
|
||||||
// Crater
|
|
||||||
color = accentColor;
|
|
||||||
size = 3 + rng() * 4;
|
|
||||||
opacity = 0.6;
|
|
||||||
} else if (type < 0.7) {
|
|
||||||
// Mountain/ridge
|
|
||||||
color = mainColor;
|
|
||||||
size = 2 + rng() * 3;
|
|
||||||
opacity = 0.8;
|
|
||||||
} else {
|
|
||||||
// Glowing spot
|
|
||||||
color = '#ffffff';
|
|
||||||
size = 1 + rng() * 2;
|
|
||||||
opacity = 0.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
features.push({
|
|
||||||
type: 'Feature',
|
|
||||||
geometry: { type: 'Point', coordinates: [lng, lat] },
|
|
||||||
properties: { color, size, opacity }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
terrain: {
|
|
||||||
type: 'Feature',
|
|
||||||
geometry: { type: 'Polygon', coordinates: [surfacePoints] }
|
|
||||||
},
|
|
||||||
features: { type: 'FeatureCollection', features }
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Seeded random number generator
|
|
||||||
*/
|
|
||||||
function seededRandom(seed) {
|
|
||||||
let s = seed;
|
|
||||||
return function() {
|
|
||||||
s = (s * 9301 + 49297) % 233280;
|
|
||||||
return s / 233280;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a stylized planet marker element
|
|
||||||
*/
|
|
||||||
function createPlanetMarker(planet, idx, t) {
|
|
||||||
const el = document.createElement('div');
|
const el = document.createElement('div');
|
||||||
el.className = 'planet-marker';
|
el.className = 'planet-marker';
|
||||||
|
|
||||||
|
|
@ -491,7 +389,6 @@ function createPlanetMarker(planet, idx, t) {
|
||||||
'sports': ['#ff9800', '#f57c00'],
|
'sports': ['#ff9800', '#f57c00'],
|
||||||
};
|
};
|
||||||
const colors = themeColors[planet.theme] || themeColors['default'];
|
const colors = themeColors[planet.theme] || themeColors['default'];
|
||||||
const memberCount = planet.member_count || 0;
|
|
||||||
|
|
||||||
el.innerHTML = `
|
el.innerHTML = `
|
||||||
<div class="planet-marker-inner" style="animation-delay: ${(idx % 8) * 0.4}s;">
|
<div class="planet-marker-inner" style="animation-delay: ${(idx % 8) * 0.4}s;">
|
||||||
|
|
@ -503,32 +400,22 @@ function createPlanetMarker(planet, idx, t) {
|
||||||
</radialGradient>
|
</radialGradient>
|
||||||
<filter id="planet-glow-${idx}">
|
<filter id="planet-glow-${idx}">
|
||||||
<feGaussianBlur stdDeviation="6" result="blur"/>
|
<feGaussianBlur stdDeviation="6" result="blur"/>
|
||||||
<feMerge>
|
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||||
<feMergeNode in="blur"/>
|
|
||||||
<feMergeNode in="SourceGraphic"/>
|
|
||||||
</feMerge>
|
|
||||||
</filter>
|
</filter>
|
||||||
</defs>
|
</defs>
|
||||||
<!-- Ring -->
|
|
||||||
<ellipse cx="60" cy="60" rx="55" ry="18" fill="none" stroke="${colors[0]}" stroke-width="4" opacity="0.5" transform="rotate(-25 60 60)"/>
|
<ellipse cx="60" cy="60" rx="55" ry="18" fill="none" stroke="${colors[0]}" stroke-width="4" opacity="0.5" transform="rotate(-25 60 60)"/>
|
||||||
<!-- Planet body -->
|
|
||||||
<circle cx="60" cy="60" r="38" fill="url(#pg-${idx})" filter="url(#planet-glow-${idx})"/>
|
<circle cx="60" cy="60" r="38" fill="url(#pg-${idx})" filter="url(#planet-glow-${idx})"/>
|
||||||
<!-- Highlight -->
|
|
||||||
<ellipse cx="48" cy="48" rx="12" ry="8" fill="rgba(255,255,255,0.25)" transform="rotate(-30 48 48)"/>
|
<ellipse cx="48" cy="48" rx="12" ry="8" fill="rgba(255,255,255,0.25)" transform="rotate(-30 48 48)"/>
|
||||||
</svg>
|
</svg>
|
||||||
<div class="planet-marker-label">
|
<div class="planet-marker-label">
|
||||||
<span class="planet-marker-name">${planet.name || 'Planet'}</span>
|
<span class="planet-marker-name">${planet.name || 'Planet'}</span>
|
||||||
<span class="planet-marker-meta">${memberCount} members</span>
|
<span class="planet-marker-meta">${planet.member_count || 0} members</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
return el;
|
return el;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get planet gradient for backgrounds
|
|
||||||
*/
|
|
||||||
function getPlanetGradient(theme) {
|
function getPlanetGradient(theme) {
|
||||||
const gradients = {
|
const gradients = {
|
||||||
'default': 'linear-gradient(135deg, #9b59b6, #8e44ad)',
|
'default': 'linear-gradient(135deg, #9b59b6, #8e44ad)',
|
||||||
|
|
@ -542,9 +429,6 @@ function getPlanetGradient(theme) {
|
||||||
return gradients[theme] || gradients['default'];
|
return gradients[theme] || gradients['default'];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Create starfield GeoJSON
|
|
||||||
*/
|
|
||||||
function createStarfield(count) {
|
function createStarfield(count) {
|
||||||
const features = [];
|
const features = [];
|
||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < count; i++) {
|
||||||
|
|
@ -563,9 +447,6 @@ function createStarfield(count) {
|
||||||
return { type: 'FeatureCollection', features };
|
return { type: 'FeatureCollection', features };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Create nebula effect GeoJSON
|
|
||||||
*/
|
|
||||||
function createNebulaEffect() {
|
function createNebulaEffect() {
|
||||||
const features = [];
|
const features = [];
|
||||||
const nebulaColors = [
|
const nebulaColors = [
|
||||||
|
|
@ -575,16 +456,12 @@ function createNebulaEffect() {
|
||||||
{ color: 'rgba(26, 188, 156, 0.08)', x: 80, y: 50 },
|
{ color: 'rgba(26, 188, 156, 0.08)', x: 80, y: 50 },
|
||||||
];
|
];
|
||||||
|
|
||||||
nebulaColors.forEach((nebula, i) => {
|
nebulaColors.forEach((nebula) => {
|
||||||
const coords = [];
|
const coords = [];
|
||||||
const numPoints = 32;
|
for (let j = 0; j <= 32; j++) {
|
||||||
for (let j = 0; j <= numPoints; j++) {
|
const angle = (j / 32) * Math.PI * 2;
|
||||||
const angle = (j / numPoints) * Math.PI * 2;
|
|
||||||
const radius = 60 + Math.random() * 30;
|
const radius = 60 + Math.random() * 30;
|
||||||
coords.push([
|
coords.push([nebula.x + Math.cos(angle) * radius, nebula.y + Math.sin(angle) * radius]);
|
||||||
nebula.x + Math.cos(angle) * radius,
|
|
||||||
nebula.y + Math.sin(angle) * radius
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
features.push({
|
features.push({
|
||||||
type: 'Feature',
|
type: 'Feature',
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue