From e8af1a6a08e4300f3b1f29bd71c5501dd04ca397 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 24 Jan 2026 20:57:41 +0000 Subject: [PATCH] feat: Add terrain zoom view for Islands and Planets When clicking an island or planet: - Zooms into a full-screen terrain view - Shows the island/planet surface as an interactive map - Posts are displayed as markers on the terrain - Back button to return to world view - Post button for authenticated users (on own island) Changes: - Fixed search bar position to fixed top: 56px (below topbar) - Removed Create Island button (islands auto-create with profile) - Removed planet detail modal (replaced with terrain view) - Added Island/Planet TerrainCanvas components - Added fetchIslandPosts API function - Added terrain CSS styles Co-Authored-By: Claude Opus 4.5 --- package-lock.json | 4 +- package.json | 2 +- src/api/client.js | 13 ++ src/components/World/IslandsWorld.css | 180 ++++++++++++++- src/components/World/IslandsWorld.jsx | 291 ++++++++++++++++++++++- src/components/World/PlanetsWorld.css | 149 ++++++++++-- src/components/World/PlanetsWorld.jsx | 317 +++++++++++++++++++++----- 7 files changed, 860 insertions(+), 96 deletions(-) diff --git a/package-lock.json b/package-lock.json index d3ef83a..0b865e0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sociowire-frontend", - "version": "0.0.109", + "version": "0.0.110", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sociowire-frontend", - "version": "0.0.109", + "version": "0.0.110", "dependencies": { "@fortawesome/fontawesome-free": "^7.1.0", "axios": "^1.13.2", diff --git a/package.json b/package.json index af3f260..fc98810 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "sociowire-frontend", "private": true, - "version": "0.0.109", + "version": "0.0.110", "type": "module", "scripts": { "dev": "vite --host 0.0.0.0", diff --git a/src/api/client.js b/src/api/client.js index 2005ef5..e6634e4 100644 --- a/src/api/client.js +++ b/src/api/client.js @@ -677,6 +677,19 @@ export async function fetchIslandByUsername(username) { } } +/** + * Fetch posts for an island (by username) + */ +export async function fetchIslandPosts(usernameOrId, params = {}) { + const username = typeof usernameOrId === 'string' ? usernameOrId : String(usernameOrId); + if (!username) return []; + return fetchPosts({ + username, + limit: params.limit || 50, + ...params + }); +} + export async function uploadProfileAvatar(file, opts = {}) { if (!file) return { ok: false }; const url = `${apiBase()}/profile/avatar`; diff --git a/src/components/World/IslandsWorld.css b/src/components/World/IslandsWorld.css index 10b476c..760e297 100644 --- a/src/components/World/IslandsWorld.css +++ b/src/components/World/IslandsWorld.css @@ -16,10 +16,10 @@ height: 100%; } -/* Search bar - matches map overlay position */ +/* Search bar - fixed below topbar */ .islands-world-search { - position: absolute; - top: 0.7rem; + position: fixed; + top: 56px; left: 50%; transform: translateX(-50%); z-index: 100; @@ -366,6 +366,180 @@ } } +/* Island Terrain View */ +.island-terrain-view { + 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; + left: 0; + right: 0; + z-index: 100; + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 24px; + background: linear-gradient(180deg, rgba(10, 22, 40, 0.95) 0%, rgba(10, 22, 40, 0) 100%); +} + +.island-terrain-back { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 16px; + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(56, 189, 248, 0.3); + border-radius: 12px; + color: white; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; +} + +.island-terrain-back:hover { + background: rgba(56, 189, 248, 0.2); + border-color: rgba(56, 189, 248, 0.5); +} + +.island-terrain-info { + text-align: center; +} + +.island-terrain-info h2 { + margin: 0; + font-size: 20px; + font-weight: 700; + color: #38bdf8; +} + +.island-terrain-type { + font-size: 12px; + color: rgba(255, 255, 255, 0.6); + text-transform: uppercase; + letter-spacing: 1px; +} + +.island-terrain-actions { + display: flex; + gap: 10px; +} + +.island-terrain-action { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 16px; + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 12px; + color: white; + font-size: 14px; + cursor: pointer; + transition: all 0.2s ease; +} + +.island-terrain-action:hover { + background: rgba(255, 255, 255, 0.15); +} + +.island-terrain-post { + background: linear-gradient(135deg, #10b981, #059669); + border: none; + font-weight: 600; +} + +.island-terrain-post:hover { + transform: scale(1.05); + 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 */ @media (max-width: 768px) { .islands-world-title { diff --git a/src/components/World/IslandsWorld.jsx b/src/components/World/IslandsWorld.jsx index 58753fe..91e9475 100644 --- a/src/components/World/IslandsWorld.jsx +++ b/src/components/World/IslandsWorld.jsx @@ -2,7 +2,7 @@ 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 } from '../../api/client'; +import { fetchIslands, fetchIslandPosts } from '../../api/client'; import { useAuth } from '../../auth/AuthContext'; import './IslandsWorld.css'; @@ -14,7 +14,9 @@ export default function IslandsWorld({ theme, onIslandClick, onOpenMap, onOpenPl const { t } = useTranslation(); const { authenticated, username } = useAuth(); const containerRef = useRef(null); + const terrainRef = useRef(null); const mapRef = useRef(null); + const terrainMapRef = useRef(null); const markersRef = useRef([]); const animationRef = useRef(null); @@ -23,6 +25,11 @@ export default function IslandsWorld({ theme, onIslandClick, onOpenMap, onOpenPl const [searchQuery, setSearchQuery] = useState(''); const [hoveredIsland, setHoveredIsland] = useState(null); + // Terrain view state + const [zoomedIsland, setZoomedIsland] = useState(null); + const [islandPosts, setIslandPosts] = useState([]); + const [terrainLoading, setTerrainLoading] = useState(false); + // Fetch islands useEffect(() => { let mounted = true; @@ -51,6 +58,35 @@ export default function IslandsWorld({ theme, onIslandClick, onOpenMap, onOpenPl ) : islands; + // Load island posts when zooming into terrain view + 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) => { + setZoomedIsland(island); + }, []); + + // Handle back from terrain view + const handleBackFromTerrain = useCallback(() => { + setZoomedIsland(null); + }, []); + // Initialize MapLibre useEffect(() => { if (!containerRef.current || loading) return; @@ -155,8 +191,8 @@ export default function IslandsWorld({ theme, onIslandClick, onOpenMap, onOpenPl window.location.href = `/island/${island.username}/edit`; return; } - // Default: open island viewer - onIslandClick?.(island); + // Default: zoom into island terrain + handleZoomToIsland(island); }); const marker = new maplibregl.Marker({ @@ -188,7 +224,7 @@ export default function IslandsWorld({ theme, onIslandClick, onOpenMap, onOpenPl markersRef.current = []; map.remove(); }; - }, [filteredIslands, loading, onIslandClick, t]); + }, [filteredIslands, loading, handleZoomToIsland, t, username]); return (
@@ -241,13 +277,6 @@ export default function IslandsWorld({ theme, onIslandClick, onOpenMap, onOpenPl
)} - {/* Create Island CTA (for authenticated users without island) */} - {authenticated && ( - - )} {/* Loading overlay */} {loading && ( @@ -256,10 +285,250 @@ export default function IslandsWorld({ theme, onIslandClick, onOpenMap, onOpenPl

{t('worlds.loadingIslands')}

)} + + {/* Island Terrain View - Full screen when zoomed */} + {zoomedIsland && ( +
+
+ +
+

@{zoomedIsland.username}'s Island

+ {zoomedIsland.terrain_type || 'tropical'} +
+
+ + {username && username.toLowerCase() === (zoomedIsland.username || '').toLowerCase() && ( + + )} +
+
+ + {/* Terrain canvas with posts */} +
+ {terrainLoading ? ( +
+
+

{t('worlds.loadingTerrain')}

+
+ ) : ( + + )} +
+
+ )}
); } +/** + * 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 = ` +
+ +
+
${(post.title || post.caption || '').slice(0, 30)}...
+ `; + 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
; +} + +/** + * 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 */ diff --git a/src/components/World/PlanetsWorld.css b/src/components/World/PlanetsWorld.css index 5524a39..29ebec0 100644 --- a/src/components/World/PlanetsWorld.css +++ b/src/components/World/PlanetsWorld.css @@ -16,10 +16,10 @@ height: 100%; } -/* Search bar - matches map overlay position */ +/* Search bar - fixed below topbar */ .planets-world-search { - position: absolute; - top: 0.7rem; + position: fixed; + top: 56px; left: 50%; transform: translateX(-50%); z-index: 100; @@ -399,36 +399,147 @@ box-shadow: 0 6px 20px rgba(155, 89, 182, 0.5); } -/* Create Planet button */ -.planets-world-create { +/* Planet Terrain View */ +.planet-terrain-view { position: fixed; - bottom: 24px; - right: 24px; - z-index: 200; + inset: 0; + z-index: 500; + background: linear-gradient(180deg, #0f0f1a 0%, #050510 100%); + animation: planetTerrainFadeIn 0.4s ease; +} + +@keyframes planetTerrainFadeIn { + from { + opacity: 0; + transform: scale(1.1); + } + to { + opacity: 1; + transform: scale(1); + } +} + +.planet-terrain-header { + position: absolute; + top: 0; + left: 0; + right: 0; + z-index: 100; display: flex; align-items: center; - gap: 10px; - background: linear-gradient(135deg, #9b59b6, #8e44ad); - border: none; - border-radius: 50px; - padding: 14px 24px; + justify-content: space-between; + padding: 16px 24px; + background: linear-gradient(180deg, rgba(15, 15, 26, 0.95) 0%, rgba(15, 15, 26, 0) 100%); +} + +.planet-terrain-back { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 16px; + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(155, 89, 182, 0.3); + border-radius: 12px; color: white; font-size: 14px; - font-weight: 700; + font-weight: 600; cursor: pointer; - box-shadow: 0 8px 32px rgba(155, 89, 182, 0.4); - transition: all 0.3s ease; + transition: all 0.2s ease; } -.planets-world-create:hover { +.planet-terrain-back:hover { + background: rgba(155, 89, 182, 0.2); + border-color: rgba(155, 89, 182, 0.5); +} + +.planet-terrain-info { + text-align: center; +} + +.planet-terrain-info h2 { + margin: 0; + font-size: 20px; + font-weight: 700; + color: #d4a5ff; +} + +.planet-terrain-theme { + font-size: 12px; + color: rgba(255, 255, 255, 0.6); + text-transform: uppercase; + letter-spacing: 1px; +} + +.planet-terrain-actions { + display: flex; + gap: 10px; +} + +.planet-terrain-action { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 16px; + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 12px; + color: white; + font-size: 14px; + cursor: pointer; + transition: all 0.2s ease; +} + +.planet-terrain-action:hover { + background: rgba(255, 255, 255, 0.15); +} + +.planet-terrain-post { + background: linear-gradient(135deg, #9b59b6, #8e44ad); + border: none; + font-weight: 600; +} + +.planet-terrain-post:hover { transform: scale(1.05); - box-shadow: 0 12px 40px rgba(155, 89, 182, 0.5); + box-shadow: 0 4px 16px rgba(155, 89, 182, 0.4); } -.planets-world-create i { +.planet-terrain-canvas { + position: absolute; + inset: 0; +} + +.planet-terrain-map { + width: 100%; + height: 100%; +} + +.planet-terrain-loading { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 16px; +} + +.planet-terrain-loading p { + color: rgba(255, 255, 255, 0.8); font-size: 16px; } +/* Planet post markers on terrain */ +.planet-post-marker { + cursor: pointer; + transition: transform 0.2s ease; +} + +.planet-post-marker:hover { + transform: scale(1.1); + z-index: 100; +} + /* Loading overlay */ .planets-world-loading { position: absolute; diff --git a/src/components/World/PlanetsWorld.jsx b/src/components/World/PlanetsWorld.jsx index b05b232..d5a326f 100644 --- a/src/components/World/PlanetsWorld.jsx +++ b/src/components/World/PlanetsWorld.jsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef, useState } from 'react'; +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'; @@ -12,7 +12,7 @@ import './PlanetsWorld.css'; */ export default function PlanetsWorld({ theme, onOpenMap, onOpenIslands }) { const { t } = useTranslation(); - const { authenticated } = useAuth(); + const { authenticated, username } = useAuth(); const containerRef = useRef(null); const mapRef = useRef(null); const markersRef = useRef([]); @@ -22,7 +22,11 @@ export default function PlanetsWorld({ theme, onOpenMap, onOpenIslands }) { const [loading, setLoading] = useState(true); const [searchQuery, setSearchQuery] = useState(''); const [hoveredPlanet, setHoveredPlanet] = useState(null); - const [selectedPlanet, setSelectedPlanet] = useState(null); + + // Terrain view state + const [zoomedPlanet, setZoomedPlanet] = useState(null); + const [planetPosts, setPlanetPosts] = useState([]); + const [terrainLoading, setTerrainLoading] = useState(false); // Fetch planets useEffect(() => { @@ -52,6 +56,31 @@ export default function PlanetsWorld({ theme, onOpenMap, onOpenIslands }) { ) : planets; + // Load planet posts when zooming into terrain view + useEffect(() => { + if (!zoomedPlanet) { + setPlanetPosts([]); + return; + } + setTerrainLoading(true); + // TODO: Add fetchPlanetPosts API function + // For now, simulate with empty posts + setTimeout(() => { + setPlanetPosts([]); + setTerrainLoading(false); + }, 500); + }, [zoomedPlanet]); + + // Handle zoom into planet terrain + const handleZoomToPlanet = useCallback((planet) => { + setZoomedPlanet(planet); + }, []); + + // Handle back from terrain view + const handleBackFromTerrain = useCallback(() => { + setZoomedPlanet(null); + }, []); + // Initialize MapLibre useEffect(() => { if (!containerRef.current || loading) return; @@ -115,7 +144,6 @@ export default function PlanetsWorld({ theme, onOpenMap, onOpenIslands }) { // Create planet markers in orbital rings const markers = []; - const ringCount = Math.ceil(filteredPlanets.length / 6); filteredPlanets.forEach((planet, idx) => { const ring = Math.floor(idx / 6); @@ -132,7 +160,7 @@ export default function PlanetsWorld({ theme, onOpenMap, onOpenIslands }) { el.addEventListener('mouseleave', () => setHoveredPlanet(null)); el.addEventListener('click', (e) => { e.stopPropagation(); - setSelectedPlanet(planet); + handleZoomToPlanet(planet); }); const marker = new maplibregl.Marker({ @@ -164,7 +192,7 @@ export default function PlanetsWorld({ theme, onOpenMap, onOpenIslands }) { markersRef.current = []; map.remove(); }; - }, [filteredPlanets, loading, t]); + }, [filteredPlanets, loading, t, handleZoomToPlanet]); return (
@@ -198,7 +226,7 @@ export default function PlanetsWorld({ theme, onOpenMap, onOpenIslands }) {
{/* Hovered planet tooltip */} - {hoveredPlanet && ( + {hoveredPlanet && !zoomedPlanet && (
@@ -213,59 +241,6 @@ export default function PlanetsWorld({ theme, onOpenMap, onOpenIslands }) {
)} - {/* Planet detail modal */} - {selectedPlanet && ( -
setSelectedPlanet(null)}> -
e.stopPropagation()}> - - -
-
- -
-

{selectedPlanet.name}

-

{selectedPlanet.description || t('worlds.noDescription')}

-
- -
-
- {selectedPlanet.member_count || 0} - {t('worlds.members')} -
-
- {selectedPlanet.post_count || 0} - {t('worlds.posts')} -
-
- {selectedPlanet.theme || 'default'} - {t('worlds.theme')} -
-
- -
- - -
-
-
- )} - - {/* Create Planet CTA */} - {authenticated && ( - - )} - {/* Loading overlay */} {loading && (
@@ -273,10 +248,232 @@ export default function PlanetsWorld({ theme, onOpenMap, onOpenIslands }) {

{t('worlds.loadingPlanets')}

)} + + {/* Planet Terrain View - Full screen when zoomed */} + {zoomedPlanet && ( +
+
+ +
+

{zoomedPlanet.name || 'Planet'}

+ {zoomedPlanet.theme || 'default'} +
+
+ + {authenticated && ( + + )} +
+
+ + {/* Terrain canvas with posts */} +
+ {terrainLoading ? ( +
+
+

{t('worlds.loadingTerrain')}

+
+ ) : ( + + )} +
+
+ )}
); } +/** + * 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 = ` +
+ +
+
${(post.title || post.caption || '').slice(0, 30)}...
+ `; + 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
; +} + +/** + * 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 */