feat: stash

This commit is contained in:
tiennm99 committed 2025-08-05 01:22:49 +07:00
1 parent 93ebc26f60
commit d99ce8dc0c
8 files changed
+772 -230

No files matched your search

+40 -17
View File
@@ -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
View File
@@ -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",
+299 -16
View File
@@ -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
View File
@@ -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
View File
@@ -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)}
>
+184
View File
@@ -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' }}
/>
)
}
+59
View File
@@ -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}`}
/>
)
}
+106
View File
@@ -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
}
}