solanapolis 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +518 -0
- package/bin/solanapolis.js +197 -0
- package/convex/_generated/api.d.ts +175 -0
- package/convex/_generated/api.js +23 -0
- package/convex/_generated/dataModel.d.ts +60 -0
- package/convex/_generated/server.d.ts +143 -0
- package/convex/_generated/server.js +93 -0
- package/convex/agent/conversation.ts +352 -0
- package/convex/agent/embeddingsCache.ts +110 -0
- package/convex/agent/memory.ts +450 -0
- package/convex/agent/schema.ts +53 -0
- package/convex/aiChat.ts +54 -0
- package/convex/aiTown/agent.ts +382 -0
- package/convex/aiTown/agentDescription.ts +27 -0
- package/convex/aiTown/agentInputs.ts +155 -0
- package/convex/aiTown/agentOperations.ts +178 -0
- package/convex/aiTown/conversation.ts +395 -0
- package/convex/aiTown/conversationMembership.ts +38 -0
- package/convex/aiTown/game.ts +371 -0
- package/convex/aiTown/ids.ts +32 -0
- package/convex/aiTown/inputHandler.ts +9 -0
- package/convex/aiTown/inputs.ts +25 -0
- package/convex/aiTown/insertInput.ts +20 -0
- package/convex/aiTown/location.ts +32 -0
- package/convex/aiTown/main.ts +154 -0
- package/convex/aiTown/movement.ts +189 -0
- package/convex/aiTown/player.ts +310 -0
- package/convex/aiTown/playerDescription.ts +35 -0
- package/convex/aiTown/schema.ts +79 -0
- package/convex/aiTown/world.ts +65 -0
- package/convex/aiTown/worldMap.ts +74 -0
- package/convex/chat.ts +79 -0
- package/convex/constants.ts +78 -0
- package/convex/convex.config.ts +6 -0
- package/convex/crons.ts +89 -0
- package/convex/engine/abstractGame.ts +199 -0
- package/convex/engine/historicalObject.ts +355 -0
- package/convex/engine/schema.ts +56 -0
- package/convex/http.ts +36 -0
- package/convex/init.ts +110 -0
- package/convex/messages.ts +53 -0
- package/convex/npcCarAgents.ts +415 -0
- package/convex/schema.ts +61 -0
- package/convex/streaming.ts +23 -0
- package/convex/testing.ts +202 -0
- package/convex/tsconfig.json +18 -0
- package/convex/util/FastIntegerCompression.ts +221 -0
- package/convex/util/assertNever.ts +4 -0
- package/convex/util/asyncMap.ts +20 -0
- package/convex/util/compression.ts +71 -0
- package/convex/util/geometry.ts +132 -0
- package/convex/util/isSimpleObject.ts +11 -0
- package/convex/util/llm.ts +724 -0
- package/convex/util/minheap.ts +38 -0
- package/convex/util/object.ts +22 -0
- package/convex/util/sleep.ts +3 -0
- package/convex/util/types.ts +33 -0
- package/convex/util/xxhash.ts +228 -0
- package/convex/world.ts +257 -0
- package/data/animations/campfire.json +45 -0
- package/data/animations/gentlesparkle.json +37 -0
- package/data/animations/gentlesplash.json +61 -0
- package/data/animations/gentlewaterfall.json +61 -0
- package/data/animations/windmill.json +78 -0
- package/data/characters.ts +121 -0
- package/data/convertMap.js +74 -0
- package/data/gentle.js +330 -0
- package/data/spritesheets/f1.ts +75 -0
- package/data/spritesheets/f2.ts +75 -0
- package/data/spritesheets/f3.ts +75 -0
- package/data/spritesheets/f4.ts +75 -0
- package/data/spritesheets/f5.ts +75 -0
- package/data/spritesheets/f6.ts +75 -0
- package/data/spritesheets/f7.ts +75 -0
- package/data/spritesheets/f8.ts +75 -0
- package/data/spritesheets/p1.ts +59 -0
- package/data/spritesheets/p2.ts +59 -0
- package/data/spritesheets/p3.ts +59 -0
- package/data/spritesheets/player.ts +59 -0
- package/data/spritesheets/types.ts +26 -0
- package/eslint.config.mjs +37 -0
- package/next.config.ts +7 -0
- package/package.json +85 -0
- package/postcss.config.mjs +7 -0
- package/public/file.svg +1 -0
- package/public/globe.svg +1 -0
- package/public/helius-icon.svg +84 -0
- package/public/helius-logo.svg +85 -0
- package/public/next.svg +1 -0
- package/public/plane.glb +0 -0
- package/public/vercel.svg +1 -0
- package/public/window.svg +1 -0
- package/scripts/clear-city.ts +74 -0
- package/scripts/seed-wallets.ts +185 -0
- package/scripts/setup-webhook.ts +73 -0
- package/src/app/api/auth/callback/route.ts +6 -0
- package/src/app/api/auth/link-wallet/route.ts +6 -0
- package/src/app/api/auth/phantom/route.ts +6 -0
- package/src/app/api/broadcast-position/route.ts +59 -0
- package/src/app/api/leaderboard/route.ts +85 -0
- package/src/app/api/network-stats/route.ts +86 -0
- package/src/app/api/parcel-reward/route.ts +181 -0
- package/src/app/api/queue-status/route.ts +30 -0
- package/src/app/api/snapshots/route.ts +37 -0
- package/src/app/api/transactions/enhanced/route.ts +57 -0
- package/src/app/api/treasury/route.ts +83 -0
- package/src/app/api/wallet/[address]/balances/route.ts +124 -0
- package/src/app/api/wallet/[address]/identity/route.ts +32 -0
- package/src/app/api/wallet/[address]/route.ts +216 -0
- package/src/app/api/wallet/[address]/traded-tokens/route.ts +41 -0
- package/src/app/api/wallets/route.ts +68 -0
- package/src/app/api/webhooks/helius/route.ts +76 -0
- package/src/app/auth/callback/page.tsx +29 -0
- package/src/app/favicon.ico +0 -0
- package/src/app/globals.css +39 -0
- package/src/app/layout.tsx +43 -0
- package/src/app/page.tsx +16 -0
- package/src/components/AITownNPCs.tsx +206 -0
- package/src/components/ActivityFeed.tsx +189 -0
- package/src/components/AuthPanel.tsx +163 -0
- package/src/components/BeachScene.tsx +280 -0
- package/src/components/Building.tsx +138 -0
- package/src/components/CesiumFlight.tsx +1768 -0
- package/src/components/CesiumGlobe.tsx +616 -0
- package/src/components/CitizenCard.tsx +442 -0
- package/src/components/CitizenCardModal.tsx +153 -0
- package/src/components/CityGrid.tsx +313 -0
- package/src/components/CityLandmarks.tsx +427 -0
- package/src/components/CityScene.tsx +1289 -0
- package/src/components/CitySlotsBadge.tsx +68 -0
- package/src/components/CockpitHUD.tsx +460 -0
- package/src/components/ConvexWrapper.tsx +19 -0
- package/src/components/DubaiDistrict.tsx +630 -0
- package/src/components/FlightMiniMap.tsx +133 -0
- package/src/components/GameChat.tsx +383 -0
- package/src/components/GameHUD.tsx +393 -0
- package/src/components/Ground.tsx +14 -0
- package/src/components/HowItWorksModal.tsx +251 -0
- package/src/components/IngestionBanner.tsx +123 -0
- package/src/components/InstancedBuildings.tsx +316 -0
- package/src/components/InstancedCars.tsx +504 -0
- package/src/components/InstancedCityPlanes.tsx +259 -0
- package/src/components/InstancedHouses.tsx +246 -0
- package/src/components/InstancedLampPosts.tsx +201 -0
- package/src/components/InstancedResidentCars.tsx +357 -0
- package/src/components/InstancedRoadDashes.tsx +42 -0
- package/src/components/InstancedSkyscrapers.tsx +434 -0
- package/src/components/InstancedTrees.tsx +67 -0
- package/src/components/LeaderboardPanel.tsx +136 -0
- package/src/components/MultiplayerPlanes.tsx +128 -0
- package/src/components/NetworkStats.tsx +83 -0
- package/src/components/NewBuildingSpotlight.tsx +93 -0
- package/src/components/ParcelChallengeBanner.tsx +242 -0
- package/src/components/ParcelReward.tsx +191 -0
- package/src/components/Park.tsx +42 -0
- package/src/components/PhantomWrapper.tsx +22 -0
- package/src/components/PixelStreamViewer.tsx +335 -0
- package/src/components/PlaneMode.tsx +190 -0
- package/src/components/PlayerCar.tsx +211 -0
- package/src/components/PlayerPlane.tsx +255 -0
- package/src/components/ProjectileRenderer.tsx +249 -0
- package/src/components/QueueStatusBanner.tsx +86 -0
- package/src/components/RealPlayerTags.tsx +82 -0
- package/src/components/SceneLighting.tsx +382 -0
- package/src/components/SelectionBeam.tsx +59 -0
- package/src/components/SwapPanel.tsx +104 -0
- package/src/components/SwapParticles.tsx +237 -0
- package/src/components/TreasureGate.tsx +505 -0
- package/src/components/WalletPanel.tsx +421 -0
- package/src/components/WalletSearch.tsx +244 -0
- package/src/components/WelcomeOverlay.tsx +135 -0
- package/src/components/WindowTooltip.tsx +498 -0
- package/src/context/AuthContext.tsx +230 -0
- package/src/lib/bot-detection.ts +125 -0
- package/src/lib/building-math.ts +136 -0
- package/src/lib/building-shader.ts +253 -0
- package/src/lib/car-paths.ts +244 -0
- package/src/lib/car-system.ts +182 -0
- package/src/lib/city-constants.ts +29 -0
- package/src/lib/city-slots.ts +35 -0
- package/src/lib/city-zoning.ts +64 -0
- package/src/lib/collision-map.ts +147 -0
- package/src/lib/day-night.ts +252 -0
- package/src/lib/export-card.ts +28 -0
- package/src/lib/helius-webhook.ts +90 -0
- package/src/lib/helius.ts +74 -0
- package/src/lib/house-shader.ts +119 -0
- package/src/lib/mock-data.ts +56 -0
- package/src/lib/multiplayer-manager.ts +329 -0
- package/src/lib/plane-physics.ts +66 -0
- package/src/lib/player-car.ts +147 -0
- package/src/lib/player-plane.ts +200 -0
- package/src/lib/projectile-system.ts +272 -0
- package/src/lib/skyscraper-types.ts +52 -0
- package/src/lib/sound-engine.ts +464 -0
- package/src/lib/supabase-admin.ts +9 -0
- package/src/lib/supabase.ts +8 -0
- package/src/lib/swap-events.ts +70 -0
- package/src/middleware.ts +37 -0
- package/src/types/phantom.d.ts +16 -0
- package/src/types/wallet.ts +20 -0
- package/tsconfig.json +34 -0
|
@@ -0,0 +1,616 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState, useCallback } from "react";
|
|
4
|
+
import type {
|
|
5
|
+
Viewer,
|
|
6
|
+
Cartesian2,
|
|
7
|
+
Cartesian3,
|
|
8
|
+
Cesium3DTileFeature,
|
|
9
|
+
Cesium3DTileset,
|
|
10
|
+
} from "cesium";
|
|
11
|
+
|
|
12
|
+
const CESIUM_CDN = "https://cesium.com/downloads/cesiumjs/releases/1.139/Build/Cesium/";
|
|
13
|
+
|
|
14
|
+
type CesiumModule = typeof import("cesium");
|
|
15
|
+
|
|
16
|
+
interface CesiumGlobeProps {
|
|
17
|
+
walletAddress: string | null;
|
|
18
|
+
onExit: () => void;
|
|
19
|
+
/** Transition to Cesium flight mode at given coordinates */
|
|
20
|
+
onFlyAt?: (lon: number, lat: number, alt: number, heading: number) => void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// โโ Featured locations โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
24
|
+
const GLOBE_LOCATIONS = [
|
|
25
|
+
{ name: "New York", flag: "๐บ๐ธ", lon: -74.006, lat: 40.7128, alt: 800, pitch: -45 },
|
|
26
|
+
{ name: "London", flag: "๐ฌ๐ง", lon: -0.1278, lat: 51.5074, alt: 600, pitch: -45 },
|
|
27
|
+
{ name: "Tokyo", flag: "๐ฏ๐ต", lon: 139.6917, lat: 35.6895, alt: 700, pitch: -45 },
|
|
28
|
+
{ name: "Dubai", flag: "๐ฆ๐ช", lon: 55.2708, lat: 25.2048, alt: 900, pitch: -45 },
|
|
29
|
+
{ name: "Paris", flag: "๐ซ๐ท", lon: 2.3522, lat: 48.8566, alt: 600, pitch: -45 },
|
|
30
|
+
{ name: "San Francisco", flag: "๐บ๐ธ", lon: -122.4194, lat: 37.7749, alt: 700, pitch: -45 },
|
|
31
|
+
{ name: "Sydney", flag: "๐ฆ๐บ", lon: 151.2093, lat: -33.8688, alt: 800, pitch: -45 },
|
|
32
|
+
{ name: "Giza", flag: "๐ช๐ฌ", lon: 31.1342, lat: 29.9792, alt: 1200, pitch: -35 },
|
|
33
|
+
{ name: "Solana Beach", flag: "โก", lon: -117.2700, lat: 32.9915, alt: 600, pitch: -45 },
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
// โโ Metadata keys to show in popover โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
37
|
+
const METADATA_LABELS: Record<string, string> = {
|
|
38
|
+
"cesium#name": "Name",
|
|
39
|
+
"name": "Name",
|
|
40
|
+
"building": "Type",
|
|
41
|
+
"addr:street": "Street",
|
|
42
|
+
"addr:housenumber": "Number",
|
|
43
|
+
"addr:city": "City",
|
|
44
|
+
"building:levels": "Floors",
|
|
45
|
+
"height": "Height",
|
|
46
|
+
"roof:shape": "Roof",
|
|
47
|
+
"source": "Source",
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
interface MetadataInfo {
|
|
51
|
+
entries: Array<{ label: string; value: string }>;
|
|
52
|
+
screenX: number;
|
|
53
|
+
screenY: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface StreetViewInfo {
|
|
57
|
+
lat: number;
|
|
58
|
+
lon: number;
|
|
59
|
+
url: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// โโโ Component โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
63
|
+
export default function CesiumGlobe({ walletAddress, onExit, onFlyAt }: CesiumGlobeProps) {
|
|
64
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
65
|
+
const viewerRef = useRef<Viewer | null>(null);
|
|
66
|
+
const cesiumRef = useRef<CesiumModule | null>(null);
|
|
67
|
+
const buildingsRef = useRef<Cesium3DTileset | null>(null);
|
|
68
|
+
|
|
69
|
+
const [status, setStatus] = useState<"loading" | "ready" | "error">("loading");
|
|
70
|
+
const [initError, setInitError] = useState<string | null>(null);
|
|
71
|
+
const [metadata, setMetadata] = useState<MetadataInfo | null>(null);
|
|
72
|
+
const [showLocations, setShowLocations] = useState(false);
|
|
73
|
+
const [cameraInfo, setCameraInfo] = useState({ lat: 0, lon: 0, alt: 0 });
|
|
74
|
+
const [showBuildings, setShowBuildings] = useState(true);
|
|
75
|
+
const [streetView, setStreetView] = useState<StreetViewInfo | null>(null);
|
|
76
|
+
const [streetViewLoading, setStreetViewLoading] = useState(false);
|
|
77
|
+
|
|
78
|
+
const lastPickRef = useRef(0);
|
|
79
|
+
|
|
80
|
+
// โโ Fly to location โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
81
|
+
const flyTo = useCallback((lon: number, lat: number, alt: number, pitch: number) => {
|
|
82
|
+
const viewer = viewerRef.current;
|
|
83
|
+
const Cesium = cesiumRef.current;
|
|
84
|
+
if (!viewer || !Cesium) return;
|
|
85
|
+
|
|
86
|
+
viewer.camera.flyTo({
|
|
87
|
+
destination: Cesium.Cartesian3.fromDegrees(lon, lat, alt),
|
|
88
|
+
orientation: {
|
|
89
|
+
heading: Cesium.Math.toRadians(0),
|
|
90
|
+
pitch: Cesium.Math.toRadians(pitch),
|
|
91
|
+
roll: 0,
|
|
92
|
+
},
|
|
93
|
+
duration: 2.0,
|
|
94
|
+
});
|
|
95
|
+
setShowLocations(false);
|
|
96
|
+
setMetadata(null);
|
|
97
|
+
}, []);
|
|
98
|
+
|
|
99
|
+
// โโ Initialize Cesium โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
if (!containerRef.current) return;
|
|
102
|
+
let mounted = true;
|
|
103
|
+
|
|
104
|
+
async function init() {
|
|
105
|
+
try {
|
|
106
|
+
(window as Window & { CESIUM_BASE_URL?: string }).CESIUM_BASE_URL = CESIUM_CDN;
|
|
107
|
+
|
|
108
|
+
if (!document.querySelector("#cesium-css")) {
|
|
109
|
+
const link = document.createElement("link");
|
|
110
|
+
link.id = "cesium-css";
|
|
111
|
+
link.rel = "stylesheet";
|
|
112
|
+
link.href = `${CESIUM_CDN}Widgets/widgets.css`;
|
|
113
|
+
document.head.appendChild(link);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const Cesium: CesiumModule = await import("cesium");
|
|
117
|
+
if (!mounted || !containerRef.current) return;
|
|
118
|
+
cesiumRef.current = Cesium;
|
|
119
|
+
|
|
120
|
+
const token = process.env.NEXT_PUBLIC_CESIUM_ION_TOKEN;
|
|
121
|
+
if (token) Cesium.Ion.defaultAccessToken = token;
|
|
122
|
+
|
|
123
|
+
const viewer = new Cesium.Viewer(containerRef.current, {
|
|
124
|
+
animation: false,
|
|
125
|
+
baseLayerPicker: false,
|
|
126
|
+
fullscreenButton: false,
|
|
127
|
+
geocoder: false,
|
|
128
|
+
homeButton: false,
|
|
129
|
+
infoBox: false,
|
|
130
|
+
sceneModePicker: false,
|
|
131
|
+
selectionIndicator: false,
|
|
132
|
+
timeline: false,
|
|
133
|
+
navigationHelpButton: false,
|
|
134
|
+
vrButton: false,
|
|
135
|
+
scene3DOnly: true,
|
|
136
|
+
requestRenderMode: false,
|
|
137
|
+
});
|
|
138
|
+
viewerRef.current = viewer;
|
|
139
|
+
|
|
140
|
+
// Hide credits
|
|
141
|
+
(viewer.cesiumWidget.creditContainer as HTMLElement).style.display = "none";
|
|
142
|
+
|
|
143
|
+
// โโ Terrain โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
144
|
+
try {
|
|
145
|
+
viewer.terrainProvider = await Cesium.createWorldTerrainAsync();
|
|
146
|
+
} catch {
|
|
147
|
+
/* ellipsoid fallback */
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// โโ Atmosphere & sky โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
151
|
+
if (viewer.scene.skyAtmosphere) {
|
|
152
|
+
viewer.scene.skyAtmosphere.show = true;
|
|
153
|
+
}
|
|
154
|
+
viewer.scene.globe.enableLighting = true;
|
|
155
|
+
viewer.scene.globe.atmosphereLightIntensity = 8.0;
|
|
156
|
+
|
|
157
|
+
// โโ OSM Buildings 3D Tiles โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
158
|
+
try {
|
|
159
|
+
const tileset = await Cesium.Cesium3DTileset.fromIonAssetId(96188);
|
|
160
|
+
viewer.scene.primitives.add(tileset);
|
|
161
|
+
buildingsRef.current = tileset;
|
|
162
|
+
|
|
163
|
+
// Style: height-based gradient
|
|
164
|
+
tileset.style = new Cesium.Cesium3DTileStyle({
|
|
165
|
+
color: {
|
|
166
|
+
conditions: [
|
|
167
|
+
["${feature['cesium#estimatedHeight']} >= 200", "color('hsl(0, 80%, 65%)')"],
|
|
168
|
+
["${feature['cesium#estimatedHeight']} >= 100", "color('hsl(30, 80%, 60%)')"],
|
|
169
|
+
["${feature['cesium#estimatedHeight']} >= 50", "color('hsl(50, 80%, 55%)')"],
|
|
170
|
+
["${feature['cesium#estimatedHeight']} >= 20", "color('hsl(180, 40%, 55%)')"],
|
|
171
|
+
["true", "color('hsl(210, 30%, 55%)')"],
|
|
172
|
+
],
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
} catch (e) {
|
|
176
|
+
console.warn("Could not load OSM Buildings:", e);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// โโ Initial camera: Earth from space โโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
180
|
+
viewer.camera.setView({
|
|
181
|
+
destination: Cesium.Cartesian3.fromDegrees(-74.006, 40.7128, 15_000_000),
|
|
182
|
+
orientation: {
|
|
183
|
+
heading: 0,
|
|
184
|
+
pitch: Cesium.Math.toRadians(-90),
|
|
185
|
+
roll: 0,
|
|
186
|
+
},
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// Smooth zoom to NYC after a beat
|
|
190
|
+
setTimeout(() => {
|
|
191
|
+
if (!mounted) return;
|
|
192
|
+
viewer.camera.flyTo({
|
|
193
|
+
destination: Cesium.Cartesian3.fromDegrees(-74.006, 40.7128, 2000),
|
|
194
|
+
orientation: {
|
|
195
|
+
heading: Cesium.Math.toRadians(10),
|
|
196
|
+
pitch: Cesium.Math.toRadians(-35),
|
|
197
|
+
roll: 0,
|
|
198
|
+
},
|
|
199
|
+
duration: 4.0,
|
|
200
|
+
});
|
|
201
|
+
}, 800);
|
|
202
|
+
|
|
203
|
+
// โโ Camera change listener for HUD โโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
204
|
+
viewer.camera.changed.addEventListener(() => {
|
|
205
|
+
if (!mounted) return;
|
|
206
|
+
const carto = Cesium.Cartographic.fromCartesian(viewer.camera.positionWC);
|
|
207
|
+
setCameraInfo({
|
|
208
|
+
lat: +Cesium.Math.toDegrees(carto.latitude).toFixed(4),
|
|
209
|
+
lon: +Cesium.Math.toDegrees(carto.longitude).toFixed(4),
|
|
210
|
+
alt: Math.round(carto.height),
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
viewer.camera.percentageChanged = 0.01;
|
|
214
|
+
|
|
215
|
+
// โโ Mouse move โ metadata picking โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
216
|
+
const handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas);
|
|
217
|
+
|
|
218
|
+
handler.setInputAction((movement: { endPosition: Cartesian2 }) => {
|
|
219
|
+
if (!mounted) return;
|
|
220
|
+
const now = performance.now();
|
|
221
|
+
if (now - lastPickRef.current < 80) return; // throttle
|
|
222
|
+
lastPickRef.current = now;
|
|
223
|
+
|
|
224
|
+
const picked = viewer.scene.pick(movement.endPosition);
|
|
225
|
+
if (picked && picked instanceof Cesium.Cesium3DTileFeature) {
|
|
226
|
+
const feature = picked as Cesium3DTileFeature;
|
|
227
|
+
const entries: MetadataInfo["entries"] = [];
|
|
228
|
+
|
|
229
|
+
// Gather metadata
|
|
230
|
+
const ids = feature.getPropertyIds();
|
|
231
|
+
for (const id of ids) {
|
|
232
|
+
const label = METADATA_LABELS[id];
|
|
233
|
+
if (label) {
|
|
234
|
+
const val = String(feature.getProperty(id));
|
|
235
|
+
if (val && val !== "undefined" && val !== "null" && val !== "0") {
|
|
236
|
+
entries.push({ label, value: val });
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Add estimated height if available
|
|
242
|
+
try {
|
|
243
|
+
const estH = feature.getProperty("cesium#estimatedHeight");
|
|
244
|
+
if (estH && !entries.find(e => e.label === "Height")) {
|
|
245
|
+
entries.push({ label: "Height", value: `${Math.round(Number(estH))}m` });
|
|
246
|
+
}
|
|
247
|
+
} catch { /* ignore */ }
|
|
248
|
+
|
|
249
|
+
if (entries.length > 0) {
|
|
250
|
+
setMetadata({
|
|
251
|
+
entries,
|
|
252
|
+
screenX: movement.endPosition.x,
|
|
253
|
+
screenY: movement.endPosition.y,
|
|
254
|
+
});
|
|
255
|
+
} else {
|
|
256
|
+
setMetadata(null);
|
|
257
|
+
}
|
|
258
|
+
} else {
|
|
259
|
+
setMetadata(null);
|
|
260
|
+
}
|
|
261
|
+
}, Cesium.ScreenSpaceEventType.MOUSE_MOVE);
|
|
262
|
+
|
|
263
|
+
// โโ Click โ Fly here transition โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
264
|
+
handler.setInputAction((click: { position: Cartesian2 }) => {
|
|
265
|
+
if (!onFlyAt) return;
|
|
266
|
+
const ray = viewer.camera.getPickRay(click.position);
|
|
267
|
+
if (!ray) return;
|
|
268
|
+
const cartesian = viewer.scene.globe.pick(ray, viewer.scene);
|
|
269
|
+
if (!cartesian) return;
|
|
270
|
+
const carto = Cesium.Cartographic.fromCartesian(cartesian);
|
|
271
|
+
const lon = Cesium.Math.toDegrees(carto.longitude);
|
|
272
|
+
const lat = Cesium.Math.toDegrees(carto.latitude);
|
|
273
|
+
// Get camera heading for flight direction
|
|
274
|
+
const heading = Cesium.Math.toDegrees(viewer.camera.heading);
|
|
275
|
+
onFlyAt(lon, lat, 500, heading);
|
|
276
|
+
}, Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK);
|
|
277
|
+
|
|
278
|
+
// โโ Shift+click โ Google Street View โโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
279
|
+
handler.setInputAction((click: { position: Cartesian2 }) => {
|
|
280
|
+
const ray = viewer.camera.getPickRay(click.position);
|
|
281
|
+
if (!ray) return;
|
|
282
|
+
const cartesian = viewer.scene.globe.pick(ray, viewer.scene);
|
|
283
|
+
if (!cartesian) return;
|
|
284
|
+
const carto = Cesium.Cartographic.fromCartesian(cartesian);
|
|
285
|
+
const lat = Cesium.Math.toDegrees(carto.latitude);
|
|
286
|
+
const lon = Cesium.Math.toDegrees(carto.longitude);
|
|
287
|
+
fetchStreetView(lat, lon);
|
|
288
|
+
}, Cesium.ScreenSpaceEventType.LEFT_CLICK, Cesium.KeyboardEventModifier.SHIFT);
|
|
289
|
+
|
|
290
|
+
if (mounted) setStatus("ready");
|
|
291
|
+
} catch (e) {
|
|
292
|
+
console.error("Globe init failed:", e);
|
|
293
|
+
if (mounted) {
|
|
294
|
+
setInitError(String(e));
|
|
295
|
+
setStatus("error");
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
init();
|
|
301
|
+
|
|
302
|
+
return () => {
|
|
303
|
+
mounted = false;
|
|
304
|
+
try { viewerRef.current?.destroy(); } catch { /* ignore */ }
|
|
305
|
+
viewerRef.current = null;
|
|
306
|
+
};
|
|
307
|
+
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
|
308
|
+
|
|
309
|
+
// โโ Toggle buildings visibility โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
310
|
+
useEffect(() => {
|
|
311
|
+
if (buildingsRef.current) {
|
|
312
|
+
buildingsRef.current.show = showBuildings;
|
|
313
|
+
}
|
|
314
|
+
}, [showBuildings]);
|
|
315
|
+
|
|
316
|
+
// โโ Keyboard โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
317
|
+
useEffect(() => {
|
|
318
|
+
function onKey(e: KeyboardEvent) {
|
|
319
|
+
if (e.key === "Escape") {
|
|
320
|
+
if (streetView) { setStreetView(null); return; }
|
|
321
|
+
onExit();
|
|
322
|
+
}
|
|
323
|
+
if (e.key === "l" || e.key === "L") setShowLocations(v => !v);
|
|
324
|
+
if (e.key === "b" || e.key === "B") setShowBuildings(v => !v);
|
|
325
|
+
if (e.key === "v" || e.key === "V") setStreetView(null); // dismiss street view
|
|
326
|
+
}
|
|
327
|
+
window.addEventListener("keydown", onKey);
|
|
328
|
+
return () => window.removeEventListener("keydown", onKey);
|
|
329
|
+
}, [onExit, streetView]);
|
|
330
|
+
|
|
331
|
+
// โโ Zoom to Earth view (reset) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
332
|
+
const resetToEarthView = useCallback(() => {
|
|
333
|
+
const viewer = viewerRef.current;
|
|
334
|
+
const Cesium = cesiumRef.current;
|
|
335
|
+
if (!viewer || !Cesium) return;
|
|
336
|
+
viewer.camera.flyTo({
|
|
337
|
+
destination: Cesium.Cartesian3.fromDegrees(0, 20, 20_000_000),
|
|
338
|
+
orientation: {
|
|
339
|
+
heading: 0,
|
|
340
|
+
pitch: Cesium.Math.toRadians(-90),
|
|
341
|
+
roll: 0,
|
|
342
|
+
},
|
|
343
|
+
duration: 2.5,
|
|
344
|
+
});
|
|
345
|
+
setMetadata(null);
|
|
346
|
+
}, []);
|
|
347
|
+
|
|
348
|
+
// โโ Format altitude โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
349
|
+
function formatAlt(m: number): string {
|
|
350
|
+
if (m > 100_000) return `${(m / 1000).toFixed(0)} km`;
|
|
351
|
+
if (m > 1000) return `${(m / 1000).toFixed(1)} km`;
|
|
352
|
+
return `${m} m`;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// โโ Fetch Street View โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
356
|
+
async function fetchStreetView(lat: number, lon: number) {
|
|
357
|
+
setStreetViewLoading(true);
|
|
358
|
+
try {
|
|
359
|
+
const token = process.env.NEXT_PUBLIC_CESIUM_ION_TOKEN;
|
|
360
|
+
if (!token) {
|
|
361
|
+
// Fallback: use Google's static map as a preview
|
|
362
|
+
setStreetView({
|
|
363
|
+
lat, lon,
|
|
364
|
+
url: `https://maps.googleapis.com/maps/api/streetview?size=640x400&location=${lat},${lon}&fov=90&heading=0&pitch=0`,
|
|
365
|
+
});
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Try Cesium Ion's experimental Google Street View endpoint
|
|
370
|
+
const resp = await fetch("https://api.cesium.com/v1/experimental/panoramas/google", {
|
|
371
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
if (resp.ok) {
|
|
375
|
+
const data = await resp.json();
|
|
376
|
+
const baseUrl = data.options?.url || "https://assets.cesium.com/proxy/0/maps/api/streetview";
|
|
377
|
+
const key = data.options?.key || "";
|
|
378
|
+
const svUrl = `${baseUrl}?size=640x400&location=${lat},${lon}&fov=90&heading=0&pitch=0&key=${key}`;
|
|
379
|
+
setStreetView({ lat, lon, url: svUrl });
|
|
380
|
+
} else {
|
|
381
|
+
// Fallback: just show coordinates
|
|
382
|
+
setStreetView({
|
|
383
|
+
lat, lon,
|
|
384
|
+
url: `https://maps.googleapis.com/maps/api/streetview?size=640x400&location=${lat},${lon}&fov=90&heading=0&pitch=0`,
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
} catch {
|
|
388
|
+
setStreetView(null);
|
|
389
|
+
} finally {
|
|
390
|
+
setStreetViewLoading(false);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
return (
|
|
395
|
+
<div className="fixed inset-0 z-50 bg-black">
|
|
396
|
+
<div ref={containerRef} className="w-full h-full" />
|
|
397
|
+
|
|
398
|
+
{/* โโ Loading โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */}
|
|
399
|
+
{status === "loading" && (
|
|
400
|
+
<div className="absolute inset-0 flex items-center justify-center bg-[#0a0a12]">
|
|
401
|
+
<div className="text-center">
|
|
402
|
+
<div className="w-12 h-12 border-2 border-[#E35930]/30 border-t-[#E35930] rounded-full animate-spin mx-auto mb-5" />
|
|
403
|
+
<h2 className="text-xl font-bold mb-1" style={{ color: "#E35930" }}>SOLANApolis</h2>
|
|
404
|
+
<p className="text-white/60 text-sm mb-1">Loading globeโฆ</p>
|
|
405
|
+
<p className="text-white/25 text-xs tracking-wide">3D Earth ยท OSM Buildings ยท Cesium</p>
|
|
406
|
+
</div>
|
|
407
|
+
</div>
|
|
408
|
+
)}
|
|
409
|
+
|
|
410
|
+
{/* โโ Error โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */}
|
|
411
|
+
{status === "error" && (
|
|
412
|
+
<div className="absolute inset-0 flex items-center justify-center bg-[#0a0a12]">
|
|
413
|
+
<div className="text-center bg-black/80 border border-red-500/20 rounded-3xl px-10 py-8 shadow-2xl max-w-md">
|
|
414
|
+
<div className="text-4xl mb-4">โ ๏ธ</div>
|
|
415
|
+
<h2 className="text-xl font-bold text-white mb-2">Globe Error</h2>
|
|
416
|
+
<p className="text-white/50 text-sm mb-4">{initError || "Something went wrong."}</p>
|
|
417
|
+
<div className="flex gap-3 justify-center">
|
|
418
|
+
<button onClick={() => window.location.reload()}
|
|
419
|
+
className="px-5 py-2.5 bg-[#E35930]/15 hover:bg-[#E35930]/25 border border-[#E35930]/30 rounded-xl text-sm text-[#E35930] transition-colors cursor-pointer">
|
|
420
|
+
Reload
|
|
421
|
+
</button>
|
|
422
|
+
<button onClick={onExit}
|
|
423
|
+
className="px-5 py-2.5 bg-white/5 hover:bg-white/10 border border-white/10 rounded-xl text-sm text-white/60 transition-colors cursor-pointer">
|
|
424
|
+
Return to City
|
|
425
|
+
</button>
|
|
426
|
+
</div>
|
|
427
|
+
</div>
|
|
428
|
+
</div>
|
|
429
|
+
)}
|
|
430
|
+
|
|
431
|
+
{/* โโ HUD โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */}
|
|
432
|
+
{status === "ready" && (
|
|
433
|
+
<>
|
|
434
|
+
{/* Top centre โ coordinates + altitude */}
|
|
435
|
+
<div className="absolute top-4 left-1/2 -translate-x-1/2 flex items-center gap-5 bg-black/75 backdrop-blur-xl border border-white/10 rounded-2xl px-6 py-3 pointer-events-none">
|
|
436
|
+
<HudStat label="LAT" value={cameraInfo.lat.toFixed(2)} />
|
|
437
|
+
<div className="w-px h-8 bg-white/10" />
|
|
438
|
+
<HudStat label="LON" value={cameraInfo.lon.toFixed(2)} />
|
|
439
|
+
<div className="w-px h-8 bg-white/10" />
|
|
440
|
+
<HudStat label="ALT" value={formatAlt(cameraInfo.alt)} />
|
|
441
|
+
</div>
|
|
442
|
+
|
|
443
|
+
{/* Top-right: controls */}
|
|
444
|
+
<div className="absolute top-4 right-4 flex items-center gap-2">
|
|
445
|
+
{walletAddress && (
|
|
446
|
+
<div className="px-3 py-2 bg-purple-500/10 border border-purple-400/20 rounded-xl text-xs text-purple-300/80 font-mono">
|
|
447
|
+
๐ {walletAddress.slice(0, 4)}โฆ{walletAddress.slice(-4)}
|
|
448
|
+
</div>
|
|
449
|
+
)}
|
|
450
|
+
<button
|
|
451
|
+
onClick={() => setShowBuildings(v => !v)}
|
|
452
|
+
className={`px-3 py-2 rounded-xl text-[10px] tracking-widest font-mono transition-colors cursor-pointer border ${
|
|
453
|
+
showBuildings
|
|
454
|
+
? "bg-[#E35930]/15 border-[#E35930]/30 text-[#E35930]"
|
|
455
|
+
: "bg-black/60 border-white/10 text-white/40"
|
|
456
|
+
}`}
|
|
457
|
+
title="Toggle buildings (B)"
|
|
458
|
+
>
|
|
459
|
+
๐ข {showBuildings ? "ON" : "OFF"}
|
|
460
|
+
</button>
|
|
461
|
+
<button
|
|
462
|
+
onClick={resetToEarthView}
|
|
463
|
+
className="px-3 py-2 bg-black/60 hover:bg-black/80 border border-white/10 hover:border-white/25 rounded-xl text-xs text-white/50 hover:text-white/80 transition-colors cursor-pointer"
|
|
464
|
+
title="Zoom out to Earth view"
|
|
465
|
+
>
|
|
466
|
+
๐ Earth
|
|
467
|
+
</button>
|
|
468
|
+
<button
|
|
469
|
+
onClick={onExit}
|
|
470
|
+
className="px-4 py-2 bg-black/70 backdrop-blur-xl border border-white/10 rounded-xl text-sm text-white/70 hover:text-white hover:border-white/25 transition-colors cursor-pointer"
|
|
471
|
+
>
|
|
472
|
+
โ City
|
|
473
|
+
</button>
|
|
474
|
+
</div>
|
|
475
|
+
|
|
476
|
+
{/* Top-left: location picker */}
|
|
477
|
+
<div className="absolute top-4 left-4 flex items-center gap-2">
|
|
478
|
+
<div className="relative">
|
|
479
|
+
<button
|
|
480
|
+
onClick={() => setShowLocations(v => !v)}
|
|
481
|
+
className="px-3 py-2 bg-black/60 hover:bg-black/80 border border-white/10 hover:border-white/25 rounded-xl text-xs text-white/50 hover:text-white/80 transition-colors cursor-pointer"
|
|
482
|
+
title="Jump to location (L)"
|
|
483
|
+
>
|
|
484
|
+
๐ Locations
|
|
485
|
+
</button>
|
|
486
|
+
{showLocations && (
|
|
487
|
+
<div className="absolute top-full left-0 mt-2 w-52 bg-black/90 backdrop-blur-xl border border-white/10 rounded-2xl overflow-hidden shadow-2xl z-10">
|
|
488
|
+
<div className="px-4 py-2.5 border-b border-white/10">
|
|
489
|
+
<p className="text-[10px] text-white/40 tracking-widest font-medium">FLY TO CITY</p>
|
|
490
|
+
</div>
|
|
491
|
+
<div className="max-h-72 overflow-y-auto">
|
|
492
|
+
{GLOBE_LOCATIONS.map(loc => (
|
|
493
|
+
<button
|
|
494
|
+
key={loc.name}
|
|
495
|
+
onClick={() => flyTo(loc.lon, loc.lat, loc.alt, loc.pitch)}
|
|
496
|
+
className="w-full flex items-center gap-3 px-4 py-2.5 hover:bg-white/5 text-left transition-colors cursor-pointer"
|
|
497
|
+
>
|
|
498
|
+
<span className="text-base">{loc.flag}</span>
|
|
499
|
+
<div>
|
|
500
|
+
<p className="text-xs text-white/80 font-medium">{loc.name}</p>
|
|
501
|
+
<p className="text-[10px] text-white/30">{loc.alt}m ยท {loc.lat.toFixed(1)}ยฐ, {loc.lon.toFixed(1)}ยฐ</p>
|
|
502
|
+
</div>
|
|
503
|
+
</button>
|
|
504
|
+
))}
|
|
505
|
+
</div>
|
|
506
|
+
</div>
|
|
507
|
+
)}
|
|
508
|
+
</div>
|
|
509
|
+
{onFlyAt && (
|
|
510
|
+
<div className="px-3 py-2 bg-black/40 border border-white/5 rounded-xl text-[10px] text-white/25 pointer-events-none">
|
|
511
|
+
Double-click to fly there
|
|
512
|
+
</div>
|
|
513
|
+
)}
|
|
514
|
+
</div>
|
|
515
|
+
|
|
516
|
+
{/* Bottom-left: controls legend */}
|
|
517
|
+
<div className="absolute bottom-6 left-5 bg-black/50 backdrop-blur-xl border border-white/[0.08] rounded-2xl px-4 py-3 text-xs text-white/35 space-y-1">
|
|
518
|
+
<p className="text-white/50 font-medium mb-1">Globe Controls</p>
|
|
519
|
+
<p><span className="text-white/45">Left-click drag</span> Rotate</p>
|
|
520
|
+
<p><span className="text-white/45">Right-click drag</span> Pan</p>
|
|
521
|
+
<p><span className="text-white/45">Scroll</span> Zoom</p>
|
|
522
|
+
<p><span className="text-white/45">Middle-click</span> Tilt</p>
|
|
523
|
+
<p><span className="text-white/45">Double-click</span> Fly here</p>
|
|
524
|
+
<p><span className="text-white/45">Shift+click</span> Street View</p>
|
|
525
|
+
<p><span className="text-white/45">B</span> Toggle buildings</p>
|
|
526
|
+
<p><span className="text-white/45">L</span> Locations</p>
|
|
527
|
+
<p><span className="text-white/45">Esc</span> Return to city</p>
|
|
528
|
+
</div>
|
|
529
|
+
</>
|
|
530
|
+
)}
|
|
531
|
+
|
|
532
|
+
{/* โโ Metadata popover โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */}
|
|
533
|
+
{metadata && (
|
|
534
|
+
<div
|
|
535
|
+
className="absolute z-20 pointer-events-none"
|
|
536
|
+
style={{
|
|
537
|
+
left: metadata.screenX + 16,
|
|
538
|
+
top: metadata.screenY - 20,
|
|
539
|
+
}}
|
|
540
|
+
>
|
|
541
|
+
<div className="bg-black/85 backdrop-blur-xl border border-white/15 rounded-xl px-4 py-3 shadow-2xl min-w-[160px] max-w-[260px]">
|
|
542
|
+
<p className="text-[9px] text-[#E35930] tracking-widest font-medium mb-1.5 uppercase">Building Info</p>
|
|
543
|
+
{metadata.entries.map(({ label, value }) => (
|
|
544
|
+
<div key={label} className="flex justify-between items-baseline gap-3 py-0.5">
|
|
545
|
+
<span className="text-[10px] text-white/40 shrink-0">{label}</span>
|
|
546
|
+
<span className="text-[11px] text-white/80 font-medium text-right truncate">{value}</span>
|
|
547
|
+
</div>
|
|
548
|
+
))}
|
|
549
|
+
</div>
|
|
550
|
+
</div>
|
|
551
|
+
)}
|
|
552
|
+
|
|
553
|
+
{/* โโ Street View panel โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */}
|
|
554
|
+
{(streetView || streetViewLoading) && (
|
|
555
|
+
<div className="absolute bottom-20 right-5 z-30 w-[480px]">
|
|
556
|
+
<div className="bg-black/90 backdrop-blur-xl border border-white/15 rounded-2xl overflow-hidden shadow-2xl">
|
|
557
|
+
<div className="flex items-center justify-between px-4 py-2.5 border-b border-white/10">
|
|
558
|
+
<div className="flex items-center gap-2">
|
|
559
|
+
<span className="text-sm">๐ท</span>
|
|
560
|
+
<span className="text-[10px] text-white/40 tracking-widest font-medium">STREET VIEW</span>
|
|
561
|
+
{streetView && (
|
|
562
|
+
<span className="text-[10px] text-white/25 font-mono">
|
|
563
|
+
{streetView.lat.toFixed(4)}ยฐ, {streetView.lon.toFixed(4)}ยฐ
|
|
564
|
+
</span>
|
|
565
|
+
)}
|
|
566
|
+
</div>
|
|
567
|
+
<button
|
|
568
|
+
onClick={() => setStreetView(null)}
|
|
569
|
+
className="text-white/40 hover:text-white/80 text-lg leading-none transition-colors cursor-pointer"
|
|
570
|
+
>
|
|
571
|
+
ร
|
|
572
|
+
</button>
|
|
573
|
+
</div>
|
|
574
|
+
<div className="relative w-full aspect-[16/10] bg-black/60">
|
|
575
|
+
{streetViewLoading ? (
|
|
576
|
+
<div className="absolute inset-0 flex items-center justify-center">
|
|
577
|
+
<div className="w-6 h-6 border-2 border-[#E35930]/30 border-t-[#E35930] rounded-full animate-spin" />
|
|
578
|
+
</div>
|
|
579
|
+
) : streetView ? (
|
|
580
|
+
<img
|
|
581
|
+
src={streetView.url}
|
|
582
|
+
alt={`Street View at ${streetView.lat.toFixed(4)}, ${streetView.lon.toFixed(4)}`}
|
|
583
|
+
className="w-full h-full object-cover"
|
|
584
|
+
onError={(e) => {
|
|
585
|
+
// If image fails, show placeholder
|
|
586
|
+
(e.target as HTMLImageElement).style.display = "none";
|
|
587
|
+
}}
|
|
588
|
+
/>
|
|
589
|
+
) : null}
|
|
590
|
+
{streetView && (
|
|
591
|
+
<div className="absolute bottom-2 left-2 px-2 py-1 bg-black/60 rounded-lg text-[10px] text-white/40">
|
|
592
|
+
Shift+click another location ยท Esc to close
|
|
593
|
+
</div>
|
|
594
|
+
)}
|
|
595
|
+
</div>
|
|
596
|
+
</div>
|
|
597
|
+
</div>
|
|
598
|
+
)}
|
|
599
|
+
|
|
600
|
+
{/* Location backdrop close */}
|
|
601
|
+
{showLocations && (
|
|
602
|
+
<div className="absolute inset-0 z-[5]" onClick={() => setShowLocations(false)} />
|
|
603
|
+
)}
|
|
604
|
+
</div>
|
|
605
|
+
);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// โโ Sub-components โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
609
|
+
function HudStat({ label, value }: { label: string; value: string }) {
|
|
610
|
+
return (
|
|
611
|
+
<div className="text-center min-w-[55px]">
|
|
612
|
+
<div className="text-white/40 text-[10px] font-medium tracking-widest mb-0.5">{label}</div>
|
|
613
|
+
<div className="text-white font-mono text-sm font-bold leading-none">{value}</div>
|
|
614
|
+
</div>
|
|
615
|
+
);
|
|
616
|
+
}
|