mirror of
https://github.com/tiennm99/vngeoguessr.git
synced 2026-10-14 20:17:55 +00:00
feat: stash
This commit is contained in:
1 parent
93ebc26f60
commit
d99ce8dc0c
8 files changed
+772
-230
No files matched your search
@@ -2,7 +2,7 @@
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## 🇻🇳 VNGeoGuessr - Project Configuration
|
||||
## VNGeoGuessr - Project Configuration
|
||||
|
||||
A Vietnamese location-guessing game where players identify locations within Vietnamese cities using panoramic street views.
|
||||
|
||||
@@ -13,7 +13,8 @@ A Vietnamese location-guessing game where players identify locations within Viet
|
||||
- **Styling**: Tailwind CSS v4
|
||||
- **Component Library**: shadcn/ui
|
||||
- **State Management**: Zustand
|
||||
- **Maps**: Google Maps JavaScript API
|
||||
- **Maps**: Leaflet + OpenStreetMap
|
||||
- **Street View**: Mapillary API
|
||||
- **Backend**: Supabase (Database, Auth, Storage, Realtime)
|
||||
- **Geo Calculations**: Turf.js
|
||||
- **Testing**: Jest + React Testing Library
|
||||
@@ -78,15 +79,20 @@ src/
|
||||
## 📦 Required Dependencies
|
||||
|
||||
```bash
|
||||
# Core dependencies
|
||||
pnpm add @supabase/supabase-js @supabase/auth-helpers-nextjs
|
||||
pnpm add zustand @turf/turf @googlemaps/js-api-loader
|
||||
pnpm add -D @types/google.maps
|
||||
pnpm add zustand @turf/turf
|
||||
pnpm add leaflet react-leaflet
|
||||
pnpm add mapillary-js
|
||||
|
||||
# Development dependencies
|
||||
pnpm add -D @types/leaflet
|
||||
```
|
||||
|
||||
## 🌍 Environment Variables
|
||||
|
||||
```bash
|
||||
NEXT_PUBLIC_GOOGLE_MAPS_API_KEY=your_api_key_here
|
||||
NEXT_PUBLIC_MAPILLARY_ACCESS_TOKEN=your_mapillary_access_token
|
||||
NEXT_PUBLIC_SUPABASE_URL=your_supabase_url
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key
|
||||
```
|
||||
@@ -96,14 +102,14 @@ NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key
|
||||
1. **Authentication**: Login required for score saving, guest mode available with warning
|
||||
2. **City Selection**: Choose from Vietnamese cities (Ho Chi Minh, Hanoi, Da Nang, etc.)
|
||||
3. **Location Display**: Server sends random city location without metadata
|
||||
4. **Guessing Phase**:
|
||||
- View panoramic image (no location hints)
|
||||
4. **Guessing Phase**:
|
||||
- View Mapillary street-level imagery (no location hints)
|
||||
- 3-minute timer per location
|
||||
- Place guess on city overview map
|
||||
- Place guess on OpenStreetMap via Leaflet
|
||||
- Submit guess or skip to new location
|
||||
5. **Scoring**: Distance-based points (0-5 scale):
|
||||
- 0-50m = 5 points
|
||||
- 50m-100m = 4 points
|
||||
- 50m-100m = 4 points
|
||||
- 100m-200m = 3 points
|
||||
- 200m-500m = 2 points
|
||||
- 500m-1km = 1 point
|
||||
@@ -125,9 +131,10 @@ NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key
|
||||
|
||||
## 🗺️ Maps Integration
|
||||
|
||||
- Panoramic street view images (no location metadata)
|
||||
- City overview maps for guessing
|
||||
- Distance calculation and visualization
|
||||
- **Mapillary**: Street-level imagery from crowd-sourced photos
|
||||
- **OpenStreetMap + Leaflet**: Interactive maps for location guessing
|
||||
- **No Location Metadata**: Secure API responses without coordinates
|
||||
- Distance calculation and visualization with result overlays
|
||||
|
||||
## 💾 Database Schema
|
||||
|
||||
@@ -143,8 +150,10 @@ leaderboard (user_id, city_id, best_score, total_games, last_played)
|
||||
|
||||
- Use shadcn/ui for UI components
|
||||
- Responsive mobile-first design
|
||||
- Lazy load Google Maps API
|
||||
- Use Leaflet with OpenStreetMap tiles
|
||||
- Use Mapillary Viewer for street imagery
|
||||
- Implement RLS policies for security
|
||||
- Handle Mapillary API rate limits gracefully
|
||||
|
||||
## 💡 Code Examples
|
||||
|
||||
@@ -156,9 +165,9 @@ const useGameStore = create<GameState>((set) => ({
|
||||
totalScore: 0,
|
||||
timeRemaining: 180, // 3 minutes in seconds
|
||||
guesses: [],
|
||||
addGuess: (guess) => set((state) => ({
|
||||
addGuess: (guess) => set((state) => ({
|
||||
guesses: [...state.guesses, guess],
|
||||
totalScore: state.totalScore + guess.points
|
||||
totalScore: state.totalScore + guess.points
|
||||
})),
|
||||
resetTimer: () => set({ timeRemaining: 180 })
|
||||
}))
|
||||
@@ -173,12 +182,26 @@ const calculatePoints = (distanceM: number): number => {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Mapillary image retrieval
|
||||
const getMapillaryImage = async (lat: number, lng: number) => {
|
||||
const response = await fetch(
|
||||
`https://graph.mapillary.com/images?access_token=${token}&fields=id,thumb_2048_url&bbox=${bbox}`
|
||||
)
|
||||
return response.json()
|
||||
}
|
||||
|
||||
// Leaflet map initialization
|
||||
const map = L.map('map').setView([lat, lng], 13)
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenStreetMap contributors'
|
||||
}).addTo(map)
|
||||
|
||||
// Secure API response (no location hints)
|
||||
const getRandomLocation = async (cityId: string) => {
|
||||
return {
|
||||
id: 'location_id',
|
||||
panoramaData: 'base64_image_data', // No lat/lng included
|
||||
mapillary_image_id: 'image_id_here', // No lat/lng included
|
||||
cityBounds: { north: 10.8, south: 10.7, east: 106.8, west: 106.6 }
|
||||
}
|
||||
}
|
||||
```
|
||||
```
|
||||
+4
-2
@@ -9,24 +9,26 @@
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@googlemaps/js-api-loader": "^1.16.10",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@supabase/auth-helpers-nextjs": "^0.10.0",
|
||||
"@supabase/supabase-js": "^2.53.0",
|
||||
"@turf/turf": "^7.2.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.536.0",
|
||||
"mapillary-js": "^4.1.2",
|
||||
"next": "15.4.5",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-leaflet": "^5.0.0",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"zustand": "^5.0.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/google.maps": "^3.58.1",
|
||||
"@types/leaflet": "^1.9.20",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
|
||||
Generated
+299
-16
@@ -8,9 +8,6 @@ importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@googlemaps/js-api-loader':
|
||||
specifier: ^1.16.10
|
||||
version: 1.16.10
|
||||
'@radix-ui/react-slot':
|
||||
specifier: ^1.2.3
|
||||
version: 1.2.3(@types/react@19.1.9)(react@19.1.0)
|
||||
@@ -29,9 +26,15 @@ importers:
|
||||
clsx:
|
||||
specifier: ^2.1.1
|
||||
version: 2.1.1
|
||||
leaflet:
|
||||
specifier: ^1.9.4
|
||||
version: 1.9.4
|
||||
lucide-react:
|
||||
specifier: ^0.536.0
|
||||
version: 0.536.0(react@19.1.0)
|
||||
mapillary-js:
|
||||
specifier: ^4.1.2
|
||||
version: 4.1.2
|
||||
next:
|
||||
specifier: 15.4.5
|
||||
version: 15.4.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
@@ -41,6 +44,9 @@ importers:
|
||||
react-dom:
|
||||
specifier: 19.1.0
|
||||
version: 19.1.0(react@19.1.0)
|
||||
react-leaflet:
|
||||
specifier: ^5.0.0
|
||||
version: 5.0.0(leaflet@1.9.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
tailwind-merge:
|
||||
specifier: ^3.3.1
|
||||
version: 3.3.1
|
||||
@@ -54,9 +60,9 @@ importers:
|
||||
'@tailwindcss/postcss':
|
||||
specifier: ^4
|
||||
version: 4.1.11
|
||||
'@types/google.maps':
|
||||
specifier: ^3.58.1
|
||||
version: 3.58.1
|
||||
'@types/leaflet':
|
||||
specifier: ^1.9.20
|
||||
version: 1.9.20
|
||||
'@types/node':
|
||||
specifier: ^20
|
||||
version: 20.19.9
|
||||
@@ -139,9 +145,6 @@ packages:
|
||||
resolution: {integrity: sha512-Ul5l+lHEcw3L5+k8POx6r74mxEYKG5kOb6Xpy2gCRW6zweT6TEhAf8vhxGgjhqrd/VO/Dirhsb+1hNpD1ue9hw==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@googlemaps/js-api-loader@1.16.10':
|
||||
resolution: {integrity: sha512-c2erv2k7P2ilYzMmtYcMgAR21AULosQuUHJbStnrvRk2dG93k5cqptDrh9A8p+ZNlyhiqEOgHW7N9PAizdUM7Q==}
|
||||
|
||||
'@humanfs/core@0.19.1':
|
||||
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
|
||||
engines: {node: '>=18.18.0'}
|
||||
@@ -392,6 +395,13 @@ packages:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@react-leaflet/core@3.0.0':
|
||||
resolution: {integrity: sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==}
|
||||
peerDependencies:
|
||||
leaflet: ^1.9.0
|
||||
react: ^19.0.0
|
||||
react-dom: ^19.0.0
|
||||
|
||||
'@rtsao/scc@1.1.0':
|
||||
resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==}
|
||||
|
||||
@@ -874,27 +884,45 @@ packages:
|
||||
'@types/d3-voronoi@1.1.12':
|
||||
resolution: {integrity: sha512-DauBl25PKZZ0WVJr42a6CNvI6efsdzofl9sajqZr2Gf5Gu733WkDdUGiPkUHXiUvYGzNNlFQde2wdZdfQPG+yw==}
|
||||
|
||||
'@types/earcut@2.1.4':
|
||||
resolution: {integrity: sha512-qp3m9PPz4gULB9MhjGID7wpo3gJ4bTGXm7ltNDsmOvsPduTeHp8wSW9YckBj3mljeOh4F0m2z/0JKAALRKbmLQ==}
|
||||
|
||||
'@types/estree@1.0.8':
|
||||
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
||||
|
||||
'@types/geojson@7946.0.16':
|
||||
resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==}
|
||||
|
||||
'@types/google.maps@3.58.1':
|
||||
resolution: {integrity: sha512-X9QTSvGJ0nCfMzYOnaVs/k6/4L+7F5uCS+4iUmkLEls6J9S/Phv+m/i3mDeyc49ZBgwab3EFO1HEoBY7k98EGQ==}
|
||||
|
||||
'@types/json-schema@7.0.15':
|
||||
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
|
||||
|
||||
'@types/json5@0.0.29':
|
||||
resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==}
|
||||
|
||||
'@types/leaflet@1.9.20':
|
||||
resolution: {integrity: sha512-rooalPMlk61LCaLOvBF2VIf9M47HgMQqi5xQ9QRi7c8PkdIe0WrIi5IxXUXQjAdL0c+vcQ01mYWbthzmp9GHWw==}
|
||||
|
||||
'@types/node@14.18.63':
|
||||
resolution: {integrity: sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==}
|
||||
|
||||
'@types/node@20.19.9':
|
||||
resolution: {integrity: sha512-cuVNgarYWZqxRJDQHEB58GEONhOK79QVR/qYx4S7kcUObQvUwvFnYxJuuHUKm2aieN9X3yZB4LZsuYNU1Qphsw==}
|
||||
|
||||
'@types/pako@1.0.7':
|
||||
resolution: {integrity: sha512-YBtzT2ztNF6R/9+UXj2wTGFnC9NklAnASt3sC0h2m1bbH7G6FyBIkt4AN8ThZpNfxUo1b2iMVO0UawiJymEt8A==}
|
||||
|
||||
'@types/pbf@3.0.5':
|
||||
resolution: {integrity: sha512-j3pOPiEcWZ34R6a6mN07mUkM4o4Lwf6hPNt8eilOeZhTFbxFXmKhvXl9Y28jotFPaI1bpPDJsbCprUoNke6OrA==}
|
||||
|
||||
'@types/phoenix@1.6.6':
|
||||
resolution: {integrity: sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==}
|
||||
|
||||
'@types/polylabel@1.1.3':
|
||||
resolution: {integrity: sha512-9Zw2KoDpi+T4PZz2G6pO2xArE0m/GSMTW1MIxF2s8ZY8x9XDO6fv9um0ydRGvcbkFLlaq8yNK6eZxnmMZtDgWQ==}
|
||||
|
||||
'@types/rbush@3.0.4':
|
||||
resolution: {integrity: sha512-knSt9cCW8jj1ZSFcFeBZaX++OucmfPxxHiRwTahZfJlnQsek7O0bazTJHWD2RVj9LEoejUYF2de3/stf+QXcXw==}
|
||||
|
||||
'@types/react-dom@19.1.7':
|
||||
resolution: {integrity: sha512-i5ZzwYpqjmrKenzkoLM2Ibzt6mAsM7pxB6BCIouEVVmgiqaMj1TjaK7hnA36hbW5aZv20kx7Lw6hWzPWg0Rurw==}
|
||||
peerDependencies:
|
||||
@@ -903,6 +931,12 @@ packages:
|
||||
'@types/react@19.1.9':
|
||||
resolution: {integrity: sha512-WmdoynAX8Stew/36uTSVMcLJJ1KRh6L3IZRx1PZ7qJtBqT3dYTgyDTx8H1qoRghErydW7xw9mSJ3wS//tCRpFA==}
|
||||
|
||||
'@types/three@0.134.0':
|
||||
resolution: {integrity: sha512-4YB+99Rgqq27EjiYTItEoZtdjLnTh8W9LxowgpC9eWsjaQJIL4Kn/ZcUKAnW3gB/jS4hqGN8iqmid+RcUZDzpA==}
|
||||
|
||||
'@types/virtual-dom@2.1.4':
|
||||
resolution: {integrity: sha512-Y7L/frVydXRd16MevczslJZQu+QWsrqZlj6ytk7mST3xen0fkx7Ollw31By/89A8Wq+nfNWm/IoTR1ac/0fRhA==}
|
||||
|
||||
'@types/ws@8.18.1':
|
||||
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
|
||||
|
||||
@@ -1151,6 +1185,9 @@ packages:
|
||||
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
browser-split@0.0.1:
|
||||
resolution: {integrity: sha512-JhvgRb2ihQhsljNda3BI8/UcRHVzrVwo3Q+P8vDtSiyobXuFpuZ9mq+MbRGMnC22CjW3RrfXdg6j6ITX8M+7Ow==}
|
||||
|
||||
call-bind-apply-helpers@1.0.2:
|
||||
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -1167,6 +1204,9 @@ packages:
|
||||
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
camelize@1.0.1:
|
||||
resolution: {integrity: sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==}
|
||||
|
||||
caniuse-lite@1.0.30001731:
|
||||
resolution: {integrity: sha512-lDdp2/wrOmTRWuoB5DpfNkC0rJDU8DqRa6nYL6HK6sytw70QMopt/NIc/9SM7ylItlBWfACXk0tEn37UWM/+mg==}
|
||||
|
||||
@@ -1278,6 +1318,9 @@ packages:
|
||||
resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
dom-walk@0.1.2:
|
||||
resolution: {integrity: sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w==}
|
||||
|
||||
dunder-proto@1.0.1:
|
||||
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -1292,6 +1335,9 @@ packages:
|
||||
resolution: {integrity: sha512-6Jw4sE1maoRJo3q8MsSIn2onJFbLTOjY9hlx4DZXmOKvLRd1Ok2kXmAGXaafL2+ijsJZ1ClYbl/pmqr9+k4iUQ==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
|
||||
error@4.4.0:
|
||||
resolution: {integrity: sha512-SNDKualLUtT4StGFP7xNfuFybL2f6iJujFtrWuvJqGbVQGaN+adE23veqzPz1hjUjTunLi2EnJ+0SJxtbJreKw==}
|
||||
|
||||
es-abstract@1.24.0:
|
||||
resolution: {integrity: sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -1444,6 +1490,9 @@ packages:
|
||||
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
ev-store@7.0.0:
|
||||
resolution: {integrity: sha512-otazchNRnGzp2YarBJ+GXKVGvhxVATB1zmaStxJBYet0Dyq7A9VhH8IUEB/gRcL6Ch52lfpgPTRJ2m49epyMsQ==}
|
||||
|
||||
fast-deep-equal@3.1.3:
|
||||
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
|
||||
|
||||
@@ -1534,6 +1583,9 @@ packages:
|
||||
resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
|
||||
global@4.4.0:
|
||||
resolution: {integrity: sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w==}
|
||||
|
||||
globals@14.0.0:
|
||||
resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -1579,6 +1631,9 @@ packages:
|
||||
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
ieee754@1.2.1:
|
||||
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
|
||||
|
||||
ignore@5.3.2:
|
||||
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
|
||||
engines: {node: '>= 4'}
|
||||
@@ -1595,6 +1650,9 @@ packages:
|
||||
resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
|
||||
engines: {node: '>=0.8.19'}
|
||||
|
||||
individual@3.0.0:
|
||||
resolution: {integrity: sha512-rUY5vtT748NMRbEMrTNiFfy29BgGZwGXUi2NFUVMWQrogSLzlJvQV9eeMWi+g1aVaQ53tpyLAQtd5x/JH0Nh1g==}
|
||||
|
||||
internal-slot@1.1.0:
|
||||
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -1669,6 +1727,9 @@ packages:
|
||||
resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
|
||||
engines: {node: '>=0.12.0'}
|
||||
|
||||
is-object@1.0.2:
|
||||
resolution: {integrity: sha512-2rRIahhZr2UWb45fIOuvZGpFtz0TyOZLf32KxBbSoUCeZR495zCKlWUKKUByk3geS2eAs7ZAABt0Y/Rx0GiQGA==}
|
||||
|
||||
is-regex@1.2.1:
|
||||
resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -1765,6 +1826,9 @@ packages:
|
||||
resolution: {integrity: sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==}
|
||||
engines: {node: '>=0.10'}
|
||||
|
||||
leaflet@1.9.4:
|
||||
resolution: {integrity: sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==}
|
||||
|
||||
levn@0.4.1:
|
||||
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
@@ -1840,6 +1904,10 @@ packages:
|
||||
lodash.merge@4.6.2:
|
||||
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
|
||||
|
||||
long@3.2.0:
|
||||
resolution: {integrity: sha512-ZYvPPOMqUwPoDsbJaR10iQJYnMuZhRTvHYl62ErLIEX7RgFlziSBUUvrt3OVfc47QlHHpzPZYP17g3Fv7oeJkg==}
|
||||
engines: {node: '>=0.6'}
|
||||
|
||||
loose-envify@1.4.0:
|
||||
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
|
||||
hasBin: true
|
||||
@@ -1852,9 +1920,15 @@ packages:
|
||||
magic-string@0.30.17:
|
||||
resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==}
|
||||
|
||||
mapillary-js@4.1.2:
|
||||
resolution: {integrity: sha512-LYj6xbp/Y9wfBkr/prRwgJGb3+KwvzUri0yl5QEpMEH7oC1p52ErshWmPXysKefmm6JaFML4D7FJ89viwJ4T5g==}
|
||||
|
||||
marchingsquares@1.3.3:
|
||||
resolution: {integrity: sha512-gz6nNQoVK7Lkh2pZulrT4qd4347S/toG9RXH2pyzhLgkL5mLkBoqgv4EvAGXcV0ikDW72n/OQb3Xe8bGagQZCg==}
|
||||
|
||||
martinez-polygon-clipping@0.7.4:
|
||||
resolution: {integrity: sha512-jBEwrKtA0jTagUZj2bnmb4Yg2s4KnJGRePStgI7bAVjtcipKiF39R4LZ2V/UT61jMYWrTcBhPazexeqd6JAVtw==}
|
||||
|
||||
math-intrinsics@1.1.0:
|
||||
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -1867,6 +1941,9 @@ packages:
|
||||
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
|
||||
engines: {node: '>=8.6'}
|
||||
|
||||
min-document@2.19.0:
|
||||
resolution: {integrity: sha512-9Wy1B3m3f66bPPmU5hdA4DR4PB2OfDU/+GS3yAB7IQozE3tqXaVv2zOjgla7MEGSRv95+ILmOuvhLkOK6wJtCQ==}
|
||||
|
||||
minimatch@3.1.2:
|
||||
resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
|
||||
|
||||
@@ -1906,6 +1983,9 @@ packages:
|
||||
natural-compare@1.4.0:
|
||||
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
|
||||
|
||||
next-tick@0.2.2:
|
||||
resolution: {integrity: sha512-f7h4svPtl+QidoBv4taKXUjJ70G2asaZ8G28nS0OkqaalX8dwwrtWtyxEDPK62AC00ur/+/E0pUwBwY5EPn15Q==}
|
||||
|
||||
next@15.4.5:
|
||||
resolution: {integrity: sha512-nJ4v+IO9CPmbmcvsPebIoX3Q+S7f6Fu08/dEWu0Ttfa+wVwQRh9epcmsyCPjmL2b8MxC+CkBR97jgDhUUztI3g==}
|
||||
engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0}
|
||||
@@ -1975,6 +2055,9 @@ packages:
|
||||
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
pako@2.1.0:
|
||||
resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==}
|
||||
|
||||
parent-module@1.0.1:
|
||||
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -1990,6 +2073,10 @@ packages:
|
||||
path-parse@1.0.7:
|
||||
resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
|
||||
|
||||
pbf@3.3.0:
|
||||
resolution: {integrity: sha512-XDF38WCH3z5OV/OVa8GKUNtLAyneuzbCisx7QUCF8Q6Nutx0WnJrQe5O+kOtBlLfRNUws98Y58Lblp+NJG5T4Q==}
|
||||
hasBin: true
|
||||
|
||||
picocolors@1.1.1:
|
||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||
|
||||
@@ -2010,6 +2097,9 @@ packages:
|
||||
polyclip-ts@0.16.8:
|
||||
resolution: {integrity: sha512-JPtKbDRuPEuAjuTdhR62Gph7Is2BS1Szx69CFOO3g71lpJDFo78k4tFyi+qFOMVPePEzdSKkpGU3NBXPHHjvKQ==}
|
||||
|
||||
polylabel@1.1.0:
|
||||
resolution: {integrity: sha512-bxaGcA40sL3d6M4hH72Z4NdLqxpXRsCFk8AITYg6x1rn1Ei3izf00UMLklerBZTO49aPA3CYrIwVulx2Bce2pA==}
|
||||
|
||||
possible-typed-array-names@1.1.0:
|
||||
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -2026,9 +2116,16 @@ packages:
|
||||
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
|
||||
process@0.11.10:
|
||||
resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==}
|
||||
engines: {node: '>= 0.6.0'}
|
||||
|
||||
prop-types@15.8.1:
|
||||
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
|
||||
|
||||
protocol-buffers-schema@3.6.0:
|
||||
resolution: {integrity: sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==}
|
||||
|
||||
punycode@2.3.1:
|
||||
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -2056,6 +2153,13 @@ packages:
|
||||
react-is@16.13.1:
|
||||
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
|
||||
|
||||
react-leaflet@5.0.0:
|
||||
resolution: {integrity: sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==}
|
||||
peerDependencies:
|
||||
leaflet: ^1.9.0
|
||||
react: ^19.0.0
|
||||
react-dom: ^19.0.0
|
||||
|
||||
react@19.1.0:
|
||||
resolution: {integrity: sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -2075,6 +2179,9 @@ packages:
|
||||
resolve-pkg-maps@1.0.0:
|
||||
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
|
||||
|
||||
resolve-protobuf-schema@2.1.0:
|
||||
resolution: {integrity: sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==}
|
||||
|
||||
resolve@1.22.10:
|
||||
resolution: {integrity: sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -2097,6 +2204,12 @@ packages:
|
||||
run-parallel@1.2.0:
|
||||
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
|
||||
|
||||
rxjs@7.8.2:
|
||||
resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==}
|
||||
|
||||
s2-geometry@1.2.10:
|
||||
resolution: {integrity: sha512-5WejfQu1XZ25ZerW8uL6xP1sM2krcOYKhI6TbfybGRf+vTQLrm3E+4n0+1lWg+MYqFjPzoe51zKhn2sBRMCt5g==}
|
||||
|
||||
safe-array-concat@1.1.3:
|
||||
resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==}
|
||||
engines: {node: '>=0.4'}
|
||||
@@ -2177,6 +2290,9 @@ packages:
|
||||
splaytree-ts@1.0.2:
|
||||
resolution: {integrity: sha512-0kGecIZNIReCSiznK3uheYB8sbstLjCZLiwcQwbmLhgHJj2gz6OnSPkVzJQCMnmEz1BQ4gPK59ylhBoEWOhGNA==}
|
||||
|
||||
splaytree@0.1.4:
|
||||
resolution: {integrity: sha512-D50hKrjZgBzqD3FT2Ek53f2dcDLAQT8SSGrzj3vidNH5ISRgceeGVJ2dQIthKOuayqFXfFjXheHNo4bbt9LhRQ==}
|
||||
|
||||
stable-hash@0.0.5:
|
||||
resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==}
|
||||
|
||||
@@ -2184,6 +2300,9 @@ packages:
|
||||
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
string-template@0.2.1:
|
||||
resolution: {integrity: sha512-Yptehjogou2xm4UJbxJ4CxgZx12HBfeystp0y3x7s4Dj32ltVVG1Gg8YhKjHZkHicuKpZX/ffilA8505VbUbpw==}
|
||||
|
||||
string.prototype.includes@2.0.1:
|
||||
resolution: {integrity: sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -2253,10 +2372,16 @@ packages:
|
||||
resolution: {integrity: sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
three@0.134.0:
|
||||
resolution: {integrity: sha512-LbBerg7GaSPjYtTOnu41AMp7tV6efUNR3p4Wk5NzkSsNTBuA5mDGOfwwZL1jhhVMLx9V20HolIUo0+U3AXehbg==}
|
||||
|
||||
tinyglobby@0.2.14:
|
||||
resolution: {integrity: sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
tinyqueue@1.2.3:
|
||||
resolution: {integrity: sha512-Qz9RgWuO9l8lT+Y9xvbzhPT2efIUIFd69N7eF7tJ9lnQl0iLj1M7peK7IoUGZL9DJHw9XftqLreccfxcQgYLxA==}
|
||||
|
||||
tinyqueue@2.0.3:
|
||||
resolution: {integrity: sha512-ppJZNDuKGgxzkHihX8v9v9G5f+18gzaTfrukGrq6ueg0lmH4nqVnA2IPG0AEH3jKEk2GRJCUhDoqpoiw3PHLBA==}
|
||||
|
||||
@@ -2328,6 +2453,9 @@ packages:
|
||||
uri-js@4.4.1:
|
||||
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
|
||||
|
||||
virtual-dom@2.1.1:
|
||||
resolution: {integrity: sha512-wb6Qc9Lbqug0kRqo/iuApfBpJJAq14Sk1faAnSmtqXiwahg7PVTvWMs9L02Z8nNIMqbwsxzBAA90bbtRLbw0zg==}
|
||||
|
||||
webidl-conversions@3.0.1:
|
||||
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
|
||||
|
||||
@@ -2371,6 +2499,16 @@ packages:
|
||||
utf-8-validate:
|
||||
optional: true
|
||||
|
||||
x-is-array@0.1.0:
|
||||
resolution: {integrity: sha512-goHPif61oNrr0jJgsXRfc8oqtYzvfiMJpTqwE7Z4y9uH+T3UozkGqQ4d2nX9mB9khvA8U2o/UbPOFjgC7hLWIA==}
|
||||
|
||||
x-is-string@0.1.0:
|
||||
resolution: {integrity: sha512-GojqklwG8gpzOVEVki5KudKNoq7MbbjYZCbyWzEz7tyPA7eleiE0+ePwOWQQRb5fm86rD3S8Tc0tSFf3AOv50w==}
|
||||
|
||||
xtend@4.0.2:
|
||||
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
|
||||
engines: {node: '>=0.4'}
|
||||
|
||||
yallist@5.0.0:
|
||||
resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -2466,8 +2604,6 @@ snapshots:
|
||||
'@eslint/core': 0.15.1
|
||||
levn: 0.4.1
|
||||
|
||||
'@googlemaps/js-api-loader@1.16.10': {}
|
||||
|
||||
'@humanfs/core@0.19.1': {}
|
||||
|
||||
'@humanfs/node@0.16.6':
|
||||
@@ -2649,6 +2785,12 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@types/react': 19.1.9
|
||||
|
||||
'@react-leaflet/core@3.0.0(leaflet@1.9.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
|
||||
dependencies:
|
||||
leaflet: 1.9.4
|
||||
react: 19.1.0
|
||||
react-dom: 19.1.0(react@19.1.0)
|
||||
|
||||
'@rtsao/scc@1.1.0': {}
|
||||
|
||||
'@rushstack/eslint-patch@1.12.0': {}
|
||||
@@ -3900,22 +4042,36 @@ snapshots:
|
||||
|
||||
'@types/d3-voronoi@1.1.12': {}
|
||||
|
||||
'@types/earcut@2.1.4': {}
|
||||
|
||||
'@types/estree@1.0.8': {}
|
||||
|
||||
'@types/geojson@7946.0.16': {}
|
||||
|
||||
'@types/google.maps@3.58.1': {}
|
||||
|
||||
'@types/json-schema@7.0.15': {}
|
||||
|
||||
'@types/json5@0.0.29': {}
|
||||
|
||||
'@types/leaflet@1.9.20':
|
||||
dependencies:
|
||||
'@types/geojson': 7946.0.16
|
||||
|
||||
'@types/node@14.18.63': {}
|
||||
|
||||
'@types/node@20.19.9':
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
|
||||
'@types/pako@1.0.7': {}
|
||||
|
||||
'@types/pbf@3.0.5': {}
|
||||
|
||||
'@types/phoenix@1.6.6': {}
|
||||
|
||||
'@types/polylabel@1.1.3': {}
|
||||
|
||||
'@types/rbush@3.0.4': {}
|
||||
|
||||
'@types/react-dom@19.1.7(@types/react@19.1.9)':
|
||||
dependencies:
|
||||
'@types/react': 19.1.9
|
||||
@@ -3924,6 +4080,10 @@ snapshots:
|
||||
dependencies:
|
||||
csstype: 3.1.3
|
||||
|
||||
'@types/three@0.134.0': {}
|
||||
|
||||
'@types/virtual-dom@2.1.4': {}
|
||||
|
||||
'@types/ws@8.18.1':
|
||||
dependencies:
|
||||
'@types/node': 20.19.9
|
||||
@@ -4197,6 +4357,8 @@ snapshots:
|
||||
dependencies:
|
||||
fill-range: 7.1.1
|
||||
|
||||
browser-split@0.0.1: {}
|
||||
|
||||
call-bind-apply-helpers@1.0.2:
|
||||
dependencies:
|
||||
es-errors: 1.3.0
|
||||
@@ -4216,6 +4378,8 @@ snapshots:
|
||||
|
||||
callsites@3.1.0: {}
|
||||
|
||||
camelize@1.0.1: {}
|
||||
|
||||
caniuse-lite@1.0.30001731: {}
|
||||
|
||||
chalk@4.1.2:
|
||||
@@ -4326,6 +4490,8 @@ snapshots:
|
||||
dependencies:
|
||||
esutils: 2.0.3
|
||||
|
||||
dom-walk@0.1.2: {}
|
||||
|
||||
dunder-proto@1.0.1:
|
||||
dependencies:
|
||||
call-bind-apply-helpers: 1.0.2
|
||||
@@ -4341,6 +4507,12 @@ snapshots:
|
||||
graceful-fs: 4.2.11
|
||||
tapable: 2.2.2
|
||||
|
||||
error@4.4.0:
|
||||
dependencies:
|
||||
camelize: 1.0.1
|
||||
string-template: 0.2.1
|
||||
xtend: 4.0.2
|
||||
|
||||
es-abstract@1.24.0:
|
||||
dependencies:
|
||||
array-buffer-byte-length: 1.0.2
|
||||
@@ -4641,6 +4813,10 @@ snapshots:
|
||||
|
||||
esutils@2.0.3: {}
|
||||
|
||||
ev-store@7.0.0:
|
||||
dependencies:
|
||||
individual: 3.0.0
|
||||
|
||||
fast-deep-equal@3.1.3: {}
|
||||
|
||||
fast-glob@3.3.1:
|
||||
@@ -4752,6 +4928,11 @@ snapshots:
|
||||
dependencies:
|
||||
is-glob: 4.0.3
|
||||
|
||||
global@4.4.0:
|
||||
dependencies:
|
||||
min-document: 2.19.0
|
||||
process: 0.11.10
|
||||
|
||||
globals@14.0.0: {}
|
||||
|
||||
globalthis@1.0.4:
|
||||
@@ -4787,6 +4968,8 @@ snapshots:
|
||||
dependencies:
|
||||
function-bind: 1.1.2
|
||||
|
||||
ieee754@1.2.1: {}
|
||||
|
||||
ignore@5.3.2: {}
|
||||
|
||||
ignore@7.0.5: {}
|
||||
@@ -4798,6 +4981,8 @@ snapshots:
|
||||
|
||||
imurmurhash@0.1.4: {}
|
||||
|
||||
individual@3.0.0: {}
|
||||
|
||||
internal-slot@1.1.0:
|
||||
dependencies:
|
||||
es-errors: 1.3.0
|
||||
@@ -4879,6 +5064,8 @@ snapshots:
|
||||
|
||||
is-number@7.0.0: {}
|
||||
|
||||
is-object@1.0.2: {}
|
||||
|
||||
is-regex@1.2.1:
|
||||
dependencies:
|
||||
call-bound: 1.0.4
|
||||
@@ -4974,6 +5161,8 @@ snapshots:
|
||||
dependencies:
|
||||
language-subtag-registry: 0.3.23
|
||||
|
||||
leaflet@1.9.4: {}
|
||||
|
||||
levn@0.4.1:
|
||||
dependencies:
|
||||
prelude-ls: 1.2.1
|
||||
@@ -5030,6 +5219,8 @@ snapshots:
|
||||
|
||||
lodash.merge@4.6.2: {}
|
||||
|
||||
long@3.2.0: {}
|
||||
|
||||
loose-envify@1.4.0:
|
||||
dependencies:
|
||||
js-tokens: 4.0.0
|
||||
@@ -5042,8 +5233,35 @@ snapshots:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.4
|
||||
|
||||
mapillary-js@4.1.2:
|
||||
dependencies:
|
||||
'@types/earcut': 2.1.4
|
||||
'@types/node': 14.18.63
|
||||
'@types/pako': 1.0.7
|
||||
'@types/pbf': 3.0.5
|
||||
'@types/polylabel': 1.1.3
|
||||
'@types/rbush': 3.0.4
|
||||
'@types/three': 0.134.0
|
||||
'@types/virtual-dom': 2.1.4
|
||||
earcut: 2.2.4
|
||||
martinez-polygon-clipping: 0.7.4
|
||||
pako: 2.1.0
|
||||
pbf: 3.3.0
|
||||
polylabel: 1.1.0
|
||||
rbush: 3.0.1
|
||||
rxjs: 7.8.2
|
||||
s2-geometry: 1.2.10
|
||||
three: 0.134.0
|
||||
virtual-dom: 2.1.1
|
||||
|
||||
marchingsquares@1.3.3: {}
|
||||
|
||||
martinez-polygon-clipping@0.7.4:
|
||||
dependencies:
|
||||
robust-predicates: 2.0.4
|
||||
splaytree: 0.1.4
|
||||
tinyqueue: 1.2.3
|
||||
|
||||
math-intrinsics@1.1.0: {}
|
||||
|
||||
merge2@1.4.1: {}
|
||||
@@ -5053,6 +5271,10 @@ snapshots:
|
||||
braces: 3.0.3
|
||||
picomatch: 2.3.1
|
||||
|
||||
min-document@2.19.0:
|
||||
dependencies:
|
||||
dom-walk: 0.1.2
|
||||
|
||||
minimatch@3.1.2:
|
||||
dependencies:
|
||||
brace-expansion: 1.1.12
|
||||
@@ -5079,6 +5301,8 @@ snapshots:
|
||||
|
||||
natural-compare@1.4.0: {}
|
||||
|
||||
next-tick@0.2.2: {}
|
||||
|
||||
next@15.4.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
|
||||
dependencies:
|
||||
'@next/env': 15.4.5
|
||||
@@ -5167,6 +5391,8 @@ snapshots:
|
||||
dependencies:
|
||||
p-limit: 3.1.0
|
||||
|
||||
pako@2.1.0: {}
|
||||
|
||||
parent-module@1.0.1:
|
||||
dependencies:
|
||||
callsites: 3.1.0
|
||||
@@ -5177,6 +5403,11 @@ snapshots:
|
||||
|
||||
path-parse@1.0.7: {}
|
||||
|
||||
pbf@3.3.0:
|
||||
dependencies:
|
||||
ieee754: 1.2.1
|
||||
resolve-protobuf-schema: 2.1.0
|
||||
|
||||
picocolors@1.1.1: {}
|
||||
|
||||
picomatch@2.3.1: {}
|
||||
@@ -5194,6 +5425,10 @@ snapshots:
|
||||
bignumber.js: 9.3.1
|
||||
splaytree-ts: 1.0.2
|
||||
|
||||
polylabel@1.1.0:
|
||||
dependencies:
|
||||
tinyqueue: 2.0.3
|
||||
|
||||
possible-typed-array-names@1.1.0: {}
|
||||
|
||||
postcss@8.4.31:
|
||||
@@ -5210,12 +5445,16 @@ snapshots:
|
||||
|
||||
prelude-ls@1.2.1: {}
|
||||
|
||||
process@0.11.10: {}
|
||||
|
||||
prop-types@15.8.1:
|
||||
dependencies:
|
||||
loose-envify: 1.4.0
|
||||
object-assign: 4.1.1
|
||||
react-is: 16.13.1
|
||||
|
||||
protocol-buffers-schema@3.6.0: {}
|
||||
|
||||
punycode@2.3.1: {}
|
||||
|
||||
queue-microtask@1.2.3: {}
|
||||
@@ -5239,6 +5478,13 @@ snapshots:
|
||||
|
||||
react-is@16.13.1: {}
|
||||
|
||||
react-leaflet@5.0.0(leaflet@1.9.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
|
||||
dependencies:
|
||||
'@react-leaflet/core': 3.0.0(leaflet@1.9.4)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
leaflet: 1.9.4
|
||||
react: 19.1.0
|
||||
react-dom: 19.1.0(react@19.1.0)
|
||||
|
||||
react@19.1.0: {}
|
||||
|
||||
reflect.getprototypeof@1.0.10:
|
||||
@@ -5265,6 +5511,10 @@ snapshots:
|
||||
|
||||
resolve-pkg-maps@1.0.0: {}
|
||||
|
||||
resolve-protobuf-schema@2.1.0:
|
||||
dependencies:
|
||||
protocol-buffers-schema: 3.6.0
|
||||
|
||||
resolve@1.22.10:
|
||||
dependencies:
|
||||
is-core-module: 2.16.1
|
||||
@@ -5287,6 +5537,14 @@ snapshots:
|
||||
dependencies:
|
||||
queue-microtask: 1.2.3
|
||||
|
||||
rxjs@7.8.2:
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
s2-geometry@1.2.10:
|
||||
dependencies:
|
||||
long: 3.2.0
|
||||
|
||||
safe-array-concat@1.1.3:
|
||||
dependencies:
|
||||
call-bind: 1.0.8
|
||||
@@ -5411,6 +5669,8 @@ snapshots:
|
||||
|
||||
splaytree-ts@1.0.2: {}
|
||||
|
||||
splaytree@0.1.4: {}
|
||||
|
||||
stable-hash@0.0.5: {}
|
||||
|
||||
stop-iteration-iterator@1.1.0:
|
||||
@@ -5418,6 +5678,8 @@ snapshots:
|
||||
es-errors: 1.3.0
|
||||
internal-slot: 1.1.0
|
||||
|
||||
string-template@0.2.1: {}
|
||||
|
||||
string.prototype.includes@2.0.1:
|
||||
dependencies:
|
||||
call-bind: 1.0.8
|
||||
@@ -5502,11 +5764,15 @@ snapshots:
|
||||
mkdirp: 3.0.1
|
||||
yallist: 5.0.0
|
||||
|
||||
three@0.134.0: {}
|
||||
|
||||
tinyglobby@0.2.14:
|
||||
dependencies:
|
||||
fdir: 6.4.6(picomatch@4.0.3)
|
||||
picomatch: 4.0.3
|
||||
|
||||
tinyqueue@1.2.3: {}
|
||||
|
||||
tinyqueue@2.0.3: {}
|
||||
|
||||
to-regex-range@5.0.1:
|
||||
@@ -5614,6 +5880,17 @@ snapshots:
|
||||
dependencies:
|
||||
punycode: 2.3.1
|
||||
|
||||
virtual-dom@2.1.1:
|
||||
dependencies:
|
||||
browser-split: 0.0.1
|
||||
error: 4.4.0
|
||||
ev-store: 7.0.0
|
||||
global: 4.4.0
|
||||
is-object: 1.0.2
|
||||
next-tick: 0.2.2
|
||||
x-is-array: 0.1.0
|
||||
x-is-string: 0.1.0
|
||||
|
||||
webidl-conversions@3.0.1: {}
|
||||
|
||||
whatwg-url@5.0.0:
|
||||
@@ -5670,6 +5947,12 @@ snapshots:
|
||||
|
||||
ws@8.18.3: {}
|
||||
|
||||
x-is-array@0.1.0: {}
|
||||
|
||||
x-is-string@0.1.0: {}
|
||||
|
||||
xtend@4.0.2: {}
|
||||
|
||||
yallist@5.0.0: {}
|
||||
|
||||
yocto-queue@0.1.0: {}
|
||||
|
||||
+67
-182
@@ -1,15 +1,17 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState, useRef } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { useGameStore } from '@/stores/gameStore'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import { vietnameseCities } from '@/data/cities'
|
||||
import { generateRandomLocation, calculateDistance } from '@/lib/distance'
|
||||
import { calculateDistance } from '@/lib/distance'
|
||||
import { calculatePoints, getScoreDescription } from '@/lib/scoring'
|
||||
import { Loader } from '@googlemaps/js-api-loader'
|
||||
import MapillaryViewer from '@/components/maps/MapillaryViewer'
|
||||
import LeafletMap from '@/components/maps/LeafletMap'
|
||||
import { getRandomMapillaryImage, MapillaryImage } from '@/lib/mapillary'
|
||||
|
||||
export default function GamePage() {
|
||||
const params = useParams()
|
||||
@@ -17,10 +19,7 @@ export default function GamePage() {
|
||||
const cityId = params.cityId as string
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [gameMap, setGameMap] = useState<google.maps.Map | null>(null)
|
||||
const [streetView, setStreetView] = useState<google.maps.StreetViewPanorama | null>(null)
|
||||
const [resultMap, setResultMap] = useState<google.maps.Map | null>(null)
|
||||
const [guessMarker, setGuessMarker] = useState<google.maps.Marker | null>(null)
|
||||
const [mapillaryImage, setMapillaryImage] = useState<MapillaryImage | null>(null)
|
||||
const [guessLocation, setGuessLocation] = useState<{lat: number, lng: number} | null>(null)
|
||||
const [actualLocation, setActualLocation] = useState<{lat: number, lng: number} | null>(null)
|
||||
const [showResults, setShowResults] = useState(false)
|
||||
@@ -28,10 +27,6 @@ export default function GamePage() {
|
||||
const [roundPoints, setRoundPoints] = useState(0)
|
||||
const [canGuess, setCanGuess] = useState(false)
|
||||
|
||||
const mapRef = useRef<HTMLDivElement>(null)
|
||||
const streetViewRef = useRef<HTMLDivElement>(null)
|
||||
const resultMapRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const {
|
||||
selectedCity,
|
||||
timeRemaining,
|
||||
@@ -59,105 +54,47 @@ export default function GamePage() {
|
||||
}
|
||||
}, [timeRemaining, showResults, decrementTimer, isAuthenticated])
|
||||
|
||||
// Initialize Google Maps
|
||||
// Initialize Mapillary and game
|
||||
useEffect(() => {
|
||||
if (!city) return
|
||||
|
||||
const initMaps = async () => {
|
||||
const initGame = async () => {
|
||||
try {
|
||||
const loader = new Loader({
|
||||
apiKey: process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY!,
|
||||
version: 'weekly',
|
||||
})
|
||||
|
||||
await loader.load()
|
||||
console.log('🔄 Loading Mapillary image...')
|
||||
setIsLoading(true)
|
||||
|
||||
// Generate random location in city
|
||||
const location = generateRandomLocation(city.bounds)
|
||||
setActualLocation(location)
|
||||
|
||||
// Initialize street view
|
||||
if (streetViewRef.current) {
|
||||
const panorama = new google.maps.StreetViewPanorama(streetViewRef.current, {
|
||||
position: location,
|
||||
pov: { heading: Math.random() * 360, pitch: 0 },
|
||||
addressControl: false,
|
||||
showRoadLabels: false,
|
||||
disableDefaultUI: true,
|
||||
clickToGo: true,
|
||||
scrollwheel: false
|
||||
})
|
||||
setStreetView(panorama)
|
||||
// Get random Mapillary image
|
||||
const image = await getRandomMapillaryImage(city.bounds)
|
||||
|
||||
if (!image) {
|
||||
console.error('❌ No Mapillary images found in city bounds')
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Initialize guessing map
|
||||
if (mapRef.current) {
|
||||
const map = new google.maps.Map(mapRef.current, {
|
||||
center: {
|
||||
lat: (city.bounds.north + city.bounds.south) / 2,
|
||||
lng: (city.bounds.east + city.bounds.west) / 2
|
||||
},
|
||||
zoom: 11,
|
||||
disableDefaultUI: true,
|
||||
zoomControl: true,
|
||||
gestureHandling: 'greedy'
|
||||
})
|
||||
|
||||
// Add click listener for guessing
|
||||
map.addListener('click', (event: google.maps.MapMouseEvent) => {
|
||||
if (event.latLng && !showResults) {
|
||||
placeGuessMarker(event.latLng)
|
||||
}
|
||||
})
|
||||
|
||||
setGameMap(map)
|
||||
}
|
||||
console.log('✅ Mapillary image loaded:', image.id)
|
||||
setMapillaryImage(image)
|
||||
|
||||
// Set actual location from Mapillary image coordinates
|
||||
const [lng, lat] = image.computed_geometry.coordinates
|
||||
setActualLocation({ lat, lng })
|
||||
console.log('📍 Actual location:', { lat, lng })
|
||||
|
||||
setIsLoading(false)
|
||||
setIsPlaying(true)
|
||||
resetTimer()
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to load Google Maps:', error)
|
||||
console.error('❌ Failed to load game:', error)
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
initMaps()
|
||||
initGame()
|
||||
}, [city, resetTimer, setIsPlaying])
|
||||
|
||||
const placeGuessMarker = (latLng: google.maps.LatLng) => {
|
||||
if (!gameMap) return
|
||||
|
||||
// Remove existing marker
|
||||
if (guessMarker) {
|
||||
guessMarker.setMap(null)
|
||||
}
|
||||
|
||||
// Place new marker
|
||||
const marker = new google.maps.Marker({
|
||||
position: latLng,
|
||||
map: gameMap,
|
||||
draggable: true,
|
||||
title: 'Your Guess'
|
||||
})
|
||||
|
||||
// Update guess location when marker is dragged
|
||||
marker.addListener('dragend', () => {
|
||||
const position = marker.getPosition()
|
||||
if (position) {
|
||||
setGuessLocation({
|
||||
lat: position.lat(),
|
||||
lng: position.lng()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
setGuessMarker(marker)
|
||||
setGuessLocation({
|
||||
lat: latLng.lat(),
|
||||
lng: latLng.lng()
|
||||
})
|
||||
const handleGuessPlaced = (lat: number, lng: number) => {
|
||||
setGuessLocation({ lat, lng })
|
||||
setCanGuess(true)
|
||||
}
|
||||
|
||||
@@ -180,7 +117,7 @@ export default function GamePage() {
|
||||
addGuess({
|
||||
id: `guess-${Date.now()}`,
|
||||
sessionId: 'temp-session',
|
||||
locationId: 'temp-location',
|
||||
locationId: mapillaryImage?.id || 'temp-location',
|
||||
guessLat: guessLocation.lat,
|
||||
guessLng: guessLocation.lng,
|
||||
distanceM: distance,
|
||||
@@ -188,7 +125,6 @@ export default function GamePage() {
|
||||
timeTaken: 180 - timeRemaining
|
||||
})
|
||||
|
||||
showResultsMap()
|
||||
setShowResults(true)
|
||||
setCanGuess(false)
|
||||
}
|
||||
@@ -200,85 +136,31 @@ export default function GamePage() {
|
||||
// No guess made - 0 points
|
||||
setRoundDistance(0)
|
||||
setRoundPoints(0)
|
||||
showResultsMap()
|
||||
setShowResults(true)
|
||||
}
|
||||
}
|
||||
|
||||
const showResultsMap = () => {
|
||||
if (!resultMapRef.current || !actualLocation) return
|
||||
|
||||
const map = new google.maps.Map(resultMapRef.current, {
|
||||
center: actualLocation,
|
||||
zoom: 13,
|
||||
disableDefaultUI: true,
|
||||
zoomControl: true
|
||||
})
|
||||
|
||||
// Actual location marker (green)
|
||||
new google.maps.Marker({
|
||||
position: actualLocation,
|
||||
map: map,
|
||||
title: 'Actual Location',
|
||||
icon: {
|
||||
url: 'http://maps.google.com/mapfiles/ms/icons/green-dot.png'
|
||||
}
|
||||
})
|
||||
|
||||
// Guess location marker (red) if guess was made
|
||||
if (guessLocation) {
|
||||
new google.maps.Marker({
|
||||
position: guessLocation,
|
||||
map: map,
|
||||
title: 'Your Guess',
|
||||
icon: {
|
||||
url: 'http://maps.google.com/mapfiles/ms/icons/red-dot.png'
|
||||
}
|
||||
})
|
||||
|
||||
// Draw line between guess and actual
|
||||
const line = new google.maps.Polyline({
|
||||
path: [actualLocation, guessLocation],
|
||||
strokeColor: '#FF0000',
|
||||
strokeOpacity: 0.8,
|
||||
strokeWeight: 2,
|
||||
geodesic: true
|
||||
})
|
||||
line.setMap(map)
|
||||
|
||||
// Adjust map bounds to show both markers
|
||||
const bounds = new google.maps.LatLngBounds()
|
||||
bounds.extend(actualLocation)
|
||||
bounds.extend(guessLocation)
|
||||
map.fitBounds(bounds)
|
||||
}
|
||||
|
||||
setResultMap(map)
|
||||
}
|
||||
|
||||
const handleNextRound = () => {
|
||||
const handleNextRound = async () => {
|
||||
// Reset for next round
|
||||
setShowResults(false)
|
||||
setGuessLocation(null)
|
||||
setActualLocation(null)
|
||||
setMapillaryImage(null)
|
||||
setRoundDistance(0)
|
||||
setRoundPoints(0)
|
||||
setCanGuess(false)
|
||||
|
||||
if (guessMarker) {
|
||||
guessMarker.setMap(null)
|
||||
setGuessMarker(null)
|
||||
}
|
||||
setIsLoading(true)
|
||||
|
||||
// Generate new location and reinitialize
|
||||
if (city && streetView && gameMap) {
|
||||
const location = generateRandomLocation(city.bounds)
|
||||
setActualLocation(location)
|
||||
|
||||
streetView.setPosition(location)
|
||||
streetView.setPov({ heading: Math.random() * 360, pitch: 0 })
|
||||
|
||||
resetTimer()
|
||||
if (city) {
|
||||
// Get new Mapillary image
|
||||
const image = await getRandomMapillaryImage(city.bounds)
|
||||
if (image) {
|
||||
setMapillaryImage(image)
|
||||
const [lng, lat] = image.computed_geometry.coordinates
|
||||
setActualLocation({ lat, lng })
|
||||
resetTimer()
|
||||
}
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -315,6 +197,7 @@ export default function GamePage() {
|
||||
<CardContent className="p-6 text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||
<p>Loading {city.name}...</p>
|
||||
<p className="text-sm text-gray-500 mt-2">Fetching Mapillary images...</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -341,21 +224,20 @@ export default function GamePage() {
|
||||
{/* Game Layout */}
|
||||
<div className="container mx-auto p-4 h-[calc(100vh-80px)]">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 h-full">
|
||||
{/* Street View */}
|
||||
{/* Mapillary Street View */}
|
||||
<Card className="flex flex-col">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-lg">Street View</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 p-2">
|
||||
<div
|
||||
ref={streetViewRef}
|
||||
className="w-full h-full rounded-lg border"
|
||||
style={{ minHeight: '300px' }}
|
||||
<MapillaryViewer
|
||||
image={mapillaryImage}
|
||||
className="w-full h-full"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Guessing Map or Results */}
|
||||
{/* Leaflet Map or Results */}
|
||||
<Card className="flex flex-col">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-lg">
|
||||
@@ -363,13 +245,23 @@ export default function GamePage() {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 p-2">
|
||||
{showResults ? (
|
||||
<div className="h-full flex flex-col">
|
||||
<div
|
||||
ref={resultMapRef}
|
||||
className="flex-1 rounded-lg border mb-4"
|
||||
style={{ minHeight: '200px' }}
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="flex-1 mb-4">
|
||||
<LeafletMap
|
||||
center={[
|
||||
(city.bounds.north + city.bounds.south) / 2,
|
||||
(city.bounds.east + city.bounds.west) / 2
|
||||
]}
|
||||
zoom={11}
|
||||
onGuessPlaced={showResults ? undefined : handleGuessPlaced}
|
||||
guessLocation={guessLocation}
|
||||
actualLocation={actualLocation}
|
||||
showResults={showResults}
|
||||
className="w-full h-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showResults ? (
|
||||
<div className="text-center space-y-2">
|
||||
<p className="text-lg font-semibold">
|
||||
{getScoreDescription(roundPoints)}
|
||||
@@ -381,14 +273,7 @@ export default function GamePage() {
|
||||
Next Location
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full flex flex-col">
|
||||
<div
|
||||
ref={mapRef}
|
||||
className="flex-1 rounded-lg border mb-4"
|
||||
style={{ minHeight: '200px' }}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-center space-y-2">
|
||||
<p className="text-sm text-gray-600">
|
||||
Click on the map to place your guess
|
||||
@@ -410,8 +295,8 @@ export default function GamePage() {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
+13
-13
@@ -14,7 +14,7 @@ export default function Home() {
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [showGuestWarning, setShowGuestWarning] = useState(false)
|
||||
|
||||
|
||||
const router = useRouter()
|
||||
const { user, isAuthenticated, login, playAsGuest, isLoading } = useAuthStore()
|
||||
const { setSelectedCity } = useGameStore()
|
||||
@@ -46,7 +46,7 @@ export default function Home() {
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-b from-blue-50 to-green-50">
|
||||
<div className="max-w-md w-full mx-4">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-4xl font-bold text-gray-900 mb-2">🇻🇳 VNGeoGuessr</h1>
|
||||
<h1 className="text-4xl font-bold text-gray-900 mb-2">VNGeoGuessr</h1>
|
||||
<p className="text-gray-600">Explore Vietnam through Street View</p>
|
||||
</div>
|
||||
|
||||
@@ -55,7 +55,7 @@ export default function Home() {
|
||||
<CardHeader>
|
||||
<CardTitle>⚠️ Guest Mode Warning</CardTitle>
|
||||
<CardDescription>
|
||||
Playing as a guest means your scores will not be saved to the leaderboard.
|
||||
Playing as a guest means your scores will not be saved to the leaderboard.
|
||||
Are you sure you want to continue?
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
@@ -63,9 +63,9 @@ export default function Home() {
|
||||
<Button onClick={confirmGuestPlay} className="w-full">
|
||||
Yes, Play as Guest
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowGuestWarning(false)}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowGuestWarning(false)}
|
||||
className="w-full"
|
||||
>
|
||||
Cancel
|
||||
@@ -103,9 +103,9 @@ export default function Home() {
|
||||
</Button>
|
||||
</form>
|
||||
<div className="mt-4 pt-4 border-t">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowLogin(false)}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowLogin(false)}
|
||||
className="w-full"
|
||||
>
|
||||
Back
|
||||
@@ -140,7 +140,7 @@ export default function Home() {
|
||||
<div className="min-h-screen bg-gradient-to-b from-blue-50 to-green-50">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-4xl font-bold text-gray-900 mb-2">🇻🇳 VNGeoGuessr</h1>
|
||||
<h1 className="text-4xl font-bold text-gray-900 mb-2">VNGeoGuessr</h1>
|
||||
<p className="text-gray-600 mb-4">Welcome, {user?.name || 'Guest Player'}</p>
|
||||
{user?.isGuest && (
|
||||
<p className="text-amber-600 text-sm">
|
||||
@@ -151,11 +151,11 @@ export default function Home() {
|
||||
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<h2 className="text-2xl font-semibold text-center mb-6">Select a Vietnamese City</h2>
|
||||
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{vietnameseCities.map((city) => (
|
||||
<Card
|
||||
key={city.id}
|
||||
<Card
|
||||
key={city.id}
|
||||
className="cursor-pointer hover:shadow-lg transition-shadow"
|
||||
onClick={() => handleCitySelect(city.id)}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
import L from 'leaflet'
|
||||
import 'leaflet/dist/leaflet.css'
|
||||
|
||||
// Fix for default markers in Leaflet
|
||||
delete (L.Icon.Default.prototype as any)._getIconUrl
|
||||
L.Icon.Default.mergeOptions({
|
||||
iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon-2x.png',
|
||||
iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon.png',
|
||||
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-shadow.png'
|
||||
})
|
||||
|
||||
interface LeafletMapProps {
|
||||
center: [number, number] // [lat, lng]
|
||||
zoom?: number
|
||||
onGuessPlaced?: (lat: number, lng: number) => void
|
||||
guessLocation?: { lat: number, lng: number } | null
|
||||
actualLocation?: { lat: number, lng: number } | null
|
||||
showResults?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function LeafletMap({
|
||||
center,
|
||||
zoom = 10,
|
||||
onGuessPlaced,
|
||||
guessLocation,
|
||||
actualLocation,
|
||||
showResults = false,
|
||||
className = ''
|
||||
}: LeafletMapProps) {
|
||||
const mapRef = useRef<HTMLDivElement>(null)
|
||||
const mapInstanceRef = useRef<L.Map | null>(null)
|
||||
const guessMarkerRef = useRef<L.Marker | null>(null)
|
||||
const actualMarkerRef = useRef<L.Marker | null>(null)
|
||||
const lineRef = useRef<L.Polyline | null>(null)
|
||||
|
||||
// Initialize map only once
|
||||
useEffect(() => {
|
||||
if (!mapRef.current || mapInstanceRef.current) return
|
||||
|
||||
const map = L.map(mapRef.current).setView(center, zoom)
|
||||
|
||||
// Add OpenStreetMap tiles
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenStreetMap contributors',
|
||||
maxZoom: 19
|
||||
}).addTo(map)
|
||||
|
||||
mapInstanceRef.current = map
|
||||
|
||||
return () => {
|
||||
if (mapInstanceRef.current) {
|
||||
mapInstanceRef.current.remove()
|
||||
mapInstanceRef.current = null
|
||||
}
|
||||
}
|
||||
}, []) // Only run once on mount
|
||||
|
||||
// Handle map events separately
|
||||
useEffect(() => {
|
||||
if (!mapInstanceRef.current) return
|
||||
|
||||
const map = mapInstanceRef.current
|
||||
|
||||
// Remove existing click handlers
|
||||
map.off('click')
|
||||
|
||||
// Add click events for guessing
|
||||
if (onGuessPlaced && !showResults) {
|
||||
map.on('click', (e) => {
|
||||
const { lat, lng } = e.latlng
|
||||
onGuessPlaced(lat, lng)
|
||||
})
|
||||
}
|
||||
}, [onGuessPlaced, showResults])
|
||||
|
||||
// Update map view when center/zoom changes
|
||||
useEffect(() => {
|
||||
if (!mapInstanceRef.current) return
|
||||
|
||||
mapInstanceRef.current.setView(center, zoom)
|
||||
}, [center, zoom])
|
||||
|
||||
// Handle guess marker
|
||||
useEffect(() => {
|
||||
if (!mapInstanceRef.current) return
|
||||
|
||||
// Remove existing guess marker
|
||||
if (guessMarkerRef.current) {
|
||||
mapInstanceRef.current.removeLayer(guessMarkerRef.current)
|
||||
guessMarkerRef.current = null
|
||||
}
|
||||
|
||||
// Add new guess marker
|
||||
if (guessLocation) {
|
||||
const redIcon = L.divIcon({
|
||||
className: 'custom-div-icon',
|
||||
html: '<div style="background-color: #ef4444; width: 20px; height: 20px; border-radius: 50%; border: 2px solid white; box-shadow: 0 2px 4px rgba(0,0,0,0.3);"></div>',
|
||||
iconSize: [20, 20],
|
||||
iconAnchor: [10, 10]
|
||||
})
|
||||
|
||||
guessMarkerRef.current = L.marker([guessLocation.lat, guessLocation.lng], {
|
||||
icon: redIcon,
|
||||
draggable: !showResults
|
||||
}).addTo(mapInstanceRef.current)
|
||||
|
||||
// Handle marker drag
|
||||
if (!showResults && onGuessPlaced) {
|
||||
guessMarkerRef.current.on('dragend', (e) => {
|
||||
const marker = e.target as L.Marker
|
||||
const position = marker.getLatLng()
|
||||
onGuessPlaced(position.lat, position.lng)
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [guessLocation, showResults, onGuessPlaced])
|
||||
|
||||
// Handle actual location marker (results view)
|
||||
useEffect(() => {
|
||||
if (!mapInstanceRef.current || !showResults) return
|
||||
|
||||
// Remove existing actual marker
|
||||
if (actualMarkerRef.current) {
|
||||
mapInstanceRef.current.removeLayer(actualMarkerRef.current)
|
||||
actualMarkerRef.current = null
|
||||
}
|
||||
|
||||
// Add actual location marker
|
||||
if (actualLocation) {
|
||||
const greenIcon = L.divIcon({
|
||||
className: 'custom-div-icon',
|
||||
html: '<div style="background-color: #22c55e; width: 20px; height: 20px; border-radius: 50%; border: 2px solid white; box-shadow: 0 2px 4px rgba(0,0,0,0.3);"></div>',
|
||||
iconSize: [20, 20],
|
||||
iconAnchor: [10, 10]
|
||||
})
|
||||
|
||||
actualMarkerRef.current = L.marker([actualLocation.lat, actualLocation.lng], {
|
||||
icon: greenIcon
|
||||
}).addTo(mapInstanceRef.current)
|
||||
}
|
||||
}, [actualLocation, showResults])
|
||||
|
||||
// Handle line between markers (results view)
|
||||
useEffect(() => {
|
||||
if (!mapInstanceRef.current || !showResults) return
|
||||
|
||||
// Remove existing line
|
||||
if (lineRef.current) {
|
||||
mapInstanceRef.current.removeLayer(lineRef.current)
|
||||
lineRef.current = null
|
||||
}
|
||||
|
||||
// Add line between guess and actual
|
||||
if (guessLocation && actualLocation) {
|
||||
lineRef.current = L.polyline([
|
||||
[guessLocation.lat, guessLocation.lng],
|
||||
[actualLocation.lat, actualLocation.lng]
|
||||
], {
|
||||
color: '#ef4444',
|
||||
weight: 2,
|
||||
opacity: 0.8
|
||||
}).addTo(mapInstanceRef.current)
|
||||
|
||||
// Fit map to show both markers
|
||||
const group = new L.FeatureGroup([
|
||||
L.marker([guessLocation.lat, guessLocation.lng]),
|
||||
L.marker([actualLocation.lat, actualLocation.lng])
|
||||
])
|
||||
mapInstanceRef.current.fitBounds(group.getBounds().pad(0.1))
|
||||
}
|
||||
}, [guessLocation, actualLocation, showResults])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={mapRef}
|
||||
className={`w-full h-full rounded-lg border ${className}`}
|
||||
style={{ minHeight: '300px' }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { MapillaryImage } from '@/lib/mapillary'
|
||||
|
||||
interface MapillaryViewerProps {
|
||||
image: MapillaryImage | null
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function MapillaryViewer({ image, className = '' }: MapillaryViewerProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!image || !containerRef.current) return
|
||||
|
||||
// Clear previous content
|
||||
containerRef.current.innerHTML = ''
|
||||
|
||||
// Create image element
|
||||
const imgElement = document.createElement('img')
|
||||
imgElement.src = image.thumb_1024_url
|
||||
imgElement.alt = 'Street view'
|
||||
imgElement.className = 'w-full h-full object-cover rounded-lg'
|
||||
imgElement.style.userSelect = 'none'
|
||||
imgElement.style.pointerEvents = 'none'
|
||||
|
||||
// Prevent drag
|
||||
imgElement.draggable = false
|
||||
imgElement.addEventListener('dragstart', (e) => e.preventDefault())
|
||||
imgElement.addEventListener('contextmenu', (e) => e.preventDefault())
|
||||
|
||||
containerRef.current.appendChild(imgElement)
|
||||
|
||||
return () => {
|
||||
if (containerRef.current) {
|
||||
containerRef.current.innerHTML = ''
|
||||
}
|
||||
}
|
||||
}, [image])
|
||||
|
||||
if (!image) {
|
||||
return (
|
||||
<div className={`bg-gray-200 rounded-lg flex items-center justify-center ${className}`}>
|
||||
<div className="text-center text-gray-500">
|
||||
<div className="text-4xl mb-2">📷</div>
|
||||
<p>Loading street view...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`bg-gray-100 rounded-lg overflow-hidden ${className}`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Mapillary API utilities for fetching street-level imagery
|
||||
*/
|
||||
|
||||
const MAPILLARY_BASE_URL = 'https://graph.mapillary.com'
|
||||
const ACCESS_TOKEN = process.env.NEXT_PUBLIC_MAPILLARY_ACCESS_TOKEN!
|
||||
|
||||
export interface MapillaryImage {
|
||||
id: string
|
||||
thumb_1024_url: string
|
||||
computed_geometry: {
|
||||
type: 'Point'
|
||||
coordinates: [number, number] // [lng, lat]
|
||||
}
|
||||
}
|
||||
|
||||
export interface MapillaryResponse {
|
||||
data: MapillaryImage[]
|
||||
paging?: {
|
||||
next?: string
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for Mapillary images within a bounding box
|
||||
* @param bounds City bounds
|
||||
* @param limit Number of images to fetch (max 2000)
|
||||
* @returns Array of Mapillary images
|
||||
*/
|
||||
export const searchMapillaryImages = async (
|
||||
bounds: {
|
||||
north: number
|
||||
south: number
|
||||
east: number
|
||||
west: number
|
||||
},
|
||||
limit = 100
|
||||
): Promise<MapillaryImage[]> => {
|
||||
const bbox = `${bounds.west},${bounds.south},${bounds.east},${bounds.north}`
|
||||
|
||||
const url = new URL(`${MAPILLARY_BASE_URL}/images`)
|
||||
url.searchParams.set('access_token', ACCESS_TOKEN)
|
||||
url.searchParams.set('bbox', bbox)
|
||||
url.searchParams.set('limit', limit.toString())
|
||||
url.searchParams.set('fields', 'id,thumb_1024_url,computed_geometry')
|
||||
|
||||
try {
|
||||
const response = await fetch(url.toString())
|
||||
if (!response.ok) {
|
||||
throw new Error(`Mapillary API error: ${response.status}`)
|
||||
}
|
||||
|
||||
const data: MapillaryResponse = await response.json()
|
||||
return data.data || []
|
||||
} catch (error) {
|
||||
console.error('Error fetching Mapillary images:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a random Mapillary image from within city bounds
|
||||
* @param bounds City bounds
|
||||
* @returns Random Mapillary image or null if none found
|
||||
*/
|
||||
export const getRandomMapillaryImage = async (
|
||||
bounds: {
|
||||
north: number
|
||||
south: number
|
||||
east: number
|
||||
west: number
|
||||
}
|
||||
): Promise<MapillaryImage | null> => {
|
||||
const images = await searchMapillaryImages(bounds, 200)
|
||||
|
||||
if (images.length === 0) {
|
||||
console.warn('No Mapillary images found in bounds')
|
||||
return null
|
||||
}
|
||||
|
||||
const randomIndex = Math.floor(Math.random() * images.length)
|
||||
return images[randomIndex]
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Mapillary image details by ID
|
||||
* @param imageId Mapillary image ID
|
||||
* @returns Image details
|
||||
*/
|
||||
export const getMapillaryImageById = async (imageId: string): Promise<MapillaryImage | null> => {
|
||||
const url = new URL(`${MAPILLARY_BASE_URL}/${imageId}`)
|
||||
url.searchParams.set('access_token', ACCESS_TOKEN)
|
||||
url.searchParams.set('fields', 'id,thumb_1024_url,computed_geometry')
|
||||
|
||||
try {
|
||||
const response = await fetch(url.toString())
|
||||
if (!response.ok) {
|
||||
throw new Error(`Mapillary API error: ${response.status}`)
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
} catch (error) {
|
||||
console.error('Error fetching Mapillary image:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user