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.
Files changed (202) hide show
  1. package/README.md +518 -0
  2. package/bin/solanapolis.js +197 -0
  3. package/convex/_generated/api.d.ts +175 -0
  4. package/convex/_generated/api.js +23 -0
  5. package/convex/_generated/dataModel.d.ts +60 -0
  6. package/convex/_generated/server.d.ts +143 -0
  7. package/convex/_generated/server.js +93 -0
  8. package/convex/agent/conversation.ts +352 -0
  9. package/convex/agent/embeddingsCache.ts +110 -0
  10. package/convex/agent/memory.ts +450 -0
  11. package/convex/agent/schema.ts +53 -0
  12. package/convex/aiChat.ts +54 -0
  13. package/convex/aiTown/agent.ts +382 -0
  14. package/convex/aiTown/agentDescription.ts +27 -0
  15. package/convex/aiTown/agentInputs.ts +155 -0
  16. package/convex/aiTown/agentOperations.ts +178 -0
  17. package/convex/aiTown/conversation.ts +395 -0
  18. package/convex/aiTown/conversationMembership.ts +38 -0
  19. package/convex/aiTown/game.ts +371 -0
  20. package/convex/aiTown/ids.ts +32 -0
  21. package/convex/aiTown/inputHandler.ts +9 -0
  22. package/convex/aiTown/inputs.ts +25 -0
  23. package/convex/aiTown/insertInput.ts +20 -0
  24. package/convex/aiTown/location.ts +32 -0
  25. package/convex/aiTown/main.ts +154 -0
  26. package/convex/aiTown/movement.ts +189 -0
  27. package/convex/aiTown/player.ts +310 -0
  28. package/convex/aiTown/playerDescription.ts +35 -0
  29. package/convex/aiTown/schema.ts +79 -0
  30. package/convex/aiTown/world.ts +65 -0
  31. package/convex/aiTown/worldMap.ts +74 -0
  32. package/convex/chat.ts +79 -0
  33. package/convex/constants.ts +78 -0
  34. package/convex/convex.config.ts +6 -0
  35. package/convex/crons.ts +89 -0
  36. package/convex/engine/abstractGame.ts +199 -0
  37. package/convex/engine/historicalObject.ts +355 -0
  38. package/convex/engine/schema.ts +56 -0
  39. package/convex/http.ts +36 -0
  40. package/convex/init.ts +110 -0
  41. package/convex/messages.ts +53 -0
  42. package/convex/npcCarAgents.ts +415 -0
  43. package/convex/schema.ts +61 -0
  44. package/convex/streaming.ts +23 -0
  45. package/convex/testing.ts +202 -0
  46. package/convex/tsconfig.json +18 -0
  47. package/convex/util/FastIntegerCompression.ts +221 -0
  48. package/convex/util/assertNever.ts +4 -0
  49. package/convex/util/asyncMap.ts +20 -0
  50. package/convex/util/compression.ts +71 -0
  51. package/convex/util/geometry.ts +132 -0
  52. package/convex/util/isSimpleObject.ts +11 -0
  53. package/convex/util/llm.ts +724 -0
  54. package/convex/util/minheap.ts +38 -0
  55. package/convex/util/object.ts +22 -0
  56. package/convex/util/sleep.ts +3 -0
  57. package/convex/util/types.ts +33 -0
  58. package/convex/util/xxhash.ts +228 -0
  59. package/convex/world.ts +257 -0
  60. package/data/animations/campfire.json +45 -0
  61. package/data/animations/gentlesparkle.json +37 -0
  62. package/data/animations/gentlesplash.json +61 -0
  63. package/data/animations/gentlewaterfall.json +61 -0
  64. package/data/animations/windmill.json +78 -0
  65. package/data/characters.ts +121 -0
  66. package/data/convertMap.js +74 -0
  67. package/data/gentle.js +330 -0
  68. package/data/spritesheets/f1.ts +75 -0
  69. package/data/spritesheets/f2.ts +75 -0
  70. package/data/spritesheets/f3.ts +75 -0
  71. package/data/spritesheets/f4.ts +75 -0
  72. package/data/spritesheets/f5.ts +75 -0
  73. package/data/spritesheets/f6.ts +75 -0
  74. package/data/spritesheets/f7.ts +75 -0
  75. package/data/spritesheets/f8.ts +75 -0
  76. package/data/spritesheets/p1.ts +59 -0
  77. package/data/spritesheets/p2.ts +59 -0
  78. package/data/spritesheets/p3.ts +59 -0
  79. package/data/spritesheets/player.ts +59 -0
  80. package/data/spritesheets/types.ts +26 -0
  81. package/eslint.config.mjs +37 -0
  82. package/next.config.ts +7 -0
  83. package/package.json +85 -0
  84. package/postcss.config.mjs +7 -0
  85. package/public/file.svg +1 -0
  86. package/public/globe.svg +1 -0
  87. package/public/helius-icon.svg +84 -0
  88. package/public/helius-logo.svg +85 -0
  89. package/public/next.svg +1 -0
  90. package/public/plane.glb +0 -0
  91. package/public/vercel.svg +1 -0
  92. package/public/window.svg +1 -0
  93. package/scripts/clear-city.ts +74 -0
  94. package/scripts/seed-wallets.ts +185 -0
  95. package/scripts/setup-webhook.ts +73 -0
  96. package/src/app/api/auth/callback/route.ts +6 -0
  97. package/src/app/api/auth/link-wallet/route.ts +6 -0
  98. package/src/app/api/auth/phantom/route.ts +6 -0
  99. package/src/app/api/broadcast-position/route.ts +59 -0
  100. package/src/app/api/leaderboard/route.ts +85 -0
  101. package/src/app/api/network-stats/route.ts +86 -0
  102. package/src/app/api/parcel-reward/route.ts +181 -0
  103. package/src/app/api/queue-status/route.ts +30 -0
  104. package/src/app/api/snapshots/route.ts +37 -0
  105. package/src/app/api/transactions/enhanced/route.ts +57 -0
  106. package/src/app/api/treasury/route.ts +83 -0
  107. package/src/app/api/wallet/[address]/balances/route.ts +124 -0
  108. package/src/app/api/wallet/[address]/identity/route.ts +32 -0
  109. package/src/app/api/wallet/[address]/route.ts +216 -0
  110. package/src/app/api/wallet/[address]/traded-tokens/route.ts +41 -0
  111. package/src/app/api/wallets/route.ts +68 -0
  112. package/src/app/api/webhooks/helius/route.ts +76 -0
  113. package/src/app/auth/callback/page.tsx +29 -0
  114. package/src/app/favicon.ico +0 -0
  115. package/src/app/globals.css +39 -0
  116. package/src/app/layout.tsx +43 -0
  117. package/src/app/page.tsx +16 -0
  118. package/src/components/AITownNPCs.tsx +206 -0
  119. package/src/components/ActivityFeed.tsx +189 -0
  120. package/src/components/AuthPanel.tsx +163 -0
  121. package/src/components/BeachScene.tsx +280 -0
  122. package/src/components/Building.tsx +138 -0
  123. package/src/components/CesiumFlight.tsx +1768 -0
  124. package/src/components/CesiumGlobe.tsx +616 -0
  125. package/src/components/CitizenCard.tsx +442 -0
  126. package/src/components/CitizenCardModal.tsx +153 -0
  127. package/src/components/CityGrid.tsx +313 -0
  128. package/src/components/CityLandmarks.tsx +427 -0
  129. package/src/components/CityScene.tsx +1289 -0
  130. package/src/components/CitySlotsBadge.tsx +68 -0
  131. package/src/components/CockpitHUD.tsx +460 -0
  132. package/src/components/ConvexWrapper.tsx +19 -0
  133. package/src/components/DubaiDistrict.tsx +630 -0
  134. package/src/components/FlightMiniMap.tsx +133 -0
  135. package/src/components/GameChat.tsx +383 -0
  136. package/src/components/GameHUD.tsx +393 -0
  137. package/src/components/Ground.tsx +14 -0
  138. package/src/components/HowItWorksModal.tsx +251 -0
  139. package/src/components/IngestionBanner.tsx +123 -0
  140. package/src/components/InstancedBuildings.tsx +316 -0
  141. package/src/components/InstancedCars.tsx +504 -0
  142. package/src/components/InstancedCityPlanes.tsx +259 -0
  143. package/src/components/InstancedHouses.tsx +246 -0
  144. package/src/components/InstancedLampPosts.tsx +201 -0
  145. package/src/components/InstancedResidentCars.tsx +357 -0
  146. package/src/components/InstancedRoadDashes.tsx +42 -0
  147. package/src/components/InstancedSkyscrapers.tsx +434 -0
  148. package/src/components/InstancedTrees.tsx +67 -0
  149. package/src/components/LeaderboardPanel.tsx +136 -0
  150. package/src/components/MultiplayerPlanes.tsx +128 -0
  151. package/src/components/NetworkStats.tsx +83 -0
  152. package/src/components/NewBuildingSpotlight.tsx +93 -0
  153. package/src/components/ParcelChallengeBanner.tsx +242 -0
  154. package/src/components/ParcelReward.tsx +191 -0
  155. package/src/components/Park.tsx +42 -0
  156. package/src/components/PhantomWrapper.tsx +22 -0
  157. package/src/components/PixelStreamViewer.tsx +335 -0
  158. package/src/components/PlaneMode.tsx +190 -0
  159. package/src/components/PlayerCar.tsx +211 -0
  160. package/src/components/PlayerPlane.tsx +255 -0
  161. package/src/components/ProjectileRenderer.tsx +249 -0
  162. package/src/components/QueueStatusBanner.tsx +86 -0
  163. package/src/components/RealPlayerTags.tsx +82 -0
  164. package/src/components/SceneLighting.tsx +382 -0
  165. package/src/components/SelectionBeam.tsx +59 -0
  166. package/src/components/SwapPanel.tsx +104 -0
  167. package/src/components/SwapParticles.tsx +237 -0
  168. package/src/components/TreasureGate.tsx +505 -0
  169. package/src/components/WalletPanel.tsx +421 -0
  170. package/src/components/WalletSearch.tsx +244 -0
  171. package/src/components/WelcomeOverlay.tsx +135 -0
  172. package/src/components/WindowTooltip.tsx +498 -0
  173. package/src/context/AuthContext.tsx +230 -0
  174. package/src/lib/bot-detection.ts +125 -0
  175. package/src/lib/building-math.ts +136 -0
  176. package/src/lib/building-shader.ts +253 -0
  177. package/src/lib/car-paths.ts +244 -0
  178. package/src/lib/car-system.ts +182 -0
  179. package/src/lib/city-constants.ts +29 -0
  180. package/src/lib/city-slots.ts +35 -0
  181. package/src/lib/city-zoning.ts +64 -0
  182. package/src/lib/collision-map.ts +147 -0
  183. package/src/lib/day-night.ts +252 -0
  184. package/src/lib/export-card.ts +28 -0
  185. package/src/lib/helius-webhook.ts +90 -0
  186. package/src/lib/helius.ts +74 -0
  187. package/src/lib/house-shader.ts +119 -0
  188. package/src/lib/mock-data.ts +56 -0
  189. package/src/lib/multiplayer-manager.ts +329 -0
  190. package/src/lib/plane-physics.ts +66 -0
  191. package/src/lib/player-car.ts +147 -0
  192. package/src/lib/player-plane.ts +200 -0
  193. package/src/lib/projectile-system.ts +272 -0
  194. package/src/lib/skyscraper-types.ts +52 -0
  195. package/src/lib/sound-engine.ts +464 -0
  196. package/src/lib/supabase-admin.ts +9 -0
  197. package/src/lib/supabase.ts +8 -0
  198. package/src/lib/swap-events.ts +70 -0
  199. package/src/middleware.ts +37 -0
  200. package/src/types/phantom.d.ts +16 -0
  201. package/src/types/wallet.ts +20 -0
  202. 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
+ }