From b6bb422d3332df3f4c88be9f55eb542a3b7970bd Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Wed, 8 Apr 2026 05:12:48 +0700 Subject: [PATCH] feat(web): add listing detail page and Mapbox GL JS map integration - Create public listing detail page at /listings/[id] with image gallery, property specs, contact card, and embedded map - Rewrite ListingMap component to use Mapbox GL JS with interactive markers, price labels, and listing popups - Add selectedListingId prop to search page map views for marker highlighting - Install mapbox-gl dependency Co-Authored-By: Paperclip --- apps/web/app/(public)/listings/[id]/page.tsx | 333 +++++++++++++++++++ apps/web/app/(public)/search/page.tsx | 9 + apps/web/components/map/listing-map.tsx | 317 ++++++++++-------- apps/web/package.json | 2 + pnpm-lock.yaml | 204 ++++++++++++ 5 files changed, 729 insertions(+), 136 deletions(-) create mode 100644 apps/web/app/(public)/listings/[id]/page.tsx diff --git a/apps/web/app/(public)/listings/[id]/page.tsx b/apps/web/app/(public)/listings/[id]/page.tsx new file mode 100644 index 0000000..2276e18 --- /dev/null +++ b/apps/web/app/(public)/listings/[id]/page.tsx @@ -0,0 +1,333 @@ +'use client'; + +import * as React from 'react'; +import Link from 'next/link'; +import { useParams } from 'next/navigation'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { ImageGallery } from '@/components/listings/image-gallery'; +import { ListingMap } from '@/components/map/listing-map'; +import { listingsApi, type ListingDetail } from '@/lib/listings-api'; +import { PROPERTY_TYPES, DIRECTIONS, TRANSACTION_TYPES } from '@/lib/validations/listings'; + +function formatPrice(priceVND: string): string { + const num = Number(priceVND); + if (num >= 1_000_000_000) return `${(num / 1_000_000_000).toFixed(1)} tỷ`; + if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(0)} triệu`; + return num.toLocaleString('vi-VN'); +} + +function getLabel(list: readonly { value: string; label: string }[], value: string | null) { + if (!value) return null; + return list.find((item) => item.value === value)?.label ?? value; +} + +export default function PublicListingDetailPage() { + const { id } = useParams<{ id: string }>(); + const [listing, setListing] = React.useState(null); + const [loading, setLoading] = React.useState(true); + const [error, setError] = React.useState(null); + + React.useEffect(() => { + listingsApi + .getById(id) + .then(setListing) + .catch((err) => setError(err instanceof Error ? err.message : 'Khong tai duoc tin dang')) + .finally(() => setLoading(false)); + }, [id]); + + if (loading) { + return ( +
+ {/* Skeleton loader */} +
+
+
+
+
+
+
+
+
+
+
+
+ ); + } + + if (error || !listing) { + return ( +
+ + + +

{error || 'Khong tim thay tin dang'}

+ + + +
+ ); + } + + const { property, seller, agent } = listing; + const transactionLabel = getLabel(TRANSACTION_TYPES, listing.transactionType); + const propertyTypeLabel = getLabel(PROPERTY_TYPES, property.propertyType); + + return ( +
+ {/* Breadcrumb */} + + + {/* Header */} +
+
+
+ {transactionLabel && ( + + {transactionLabel} + + )} + {propertyTypeLabel && {propertyTypeLabel}} +
+

{property.title}

+

+ + + + + {property.address}, {property.ward}, {property.district}, {property.city} +

+
+
+

{formatPrice(listing.priceVND)} VND

+ {listing.pricePerM2 != null && ( +

+ ~{listing.pricePerM2.toLocaleString('vi-VN')} VND/m2 +

+ )} + {listing.rentPriceMonthly && ( +

+ Thue: {formatPrice(listing.rentPriceMonthly)}/thang +

+ )} +
+
+ + {/* Image gallery */} + + + {/* Quick specs bar */} +
+ + {property.bedrooms != null && ( + + )} + {property.bathrooms != null && ( + + )} + {property.floors != null && ( + + )} + {property.direction && ( + + )} +
+ +
+ {/* Main content */} +
+ {/* Description */} + + + Mo ta + + +

{property.description}

+
+
+ + {/* Details */} + + + Thong tin chi tiet + + +
+ + + + + + + + + +
+
+
+ + {/* Amenities */} + {property.amenities && property.amenities.length > 0 && ( + + + Tien ich + + +
+ {property.amenities.map((a) => ( + + {a} + + ))} +
+
+
+ )} + + {/* Map */} + + + Vi tri tren ban do + + + + + +
+ + {/* Sidebar */} +
+ {/* Contact card */} + + + Lien he + + +
+
+ + + +
+
+

{seller.fullName}

+

{seller.phone}

+
+
+ + + + + + + {agent && ( +
+

Moi gioi

+ {agent.agency &&

{agent.agency}

} + {listing.commissionPct != null && ( +

Hoa hong: {listing.commissionPct}%

+ )} +
+ )} +
+
+ + {/* Stats */} + + +
+
+

{listing.viewCount}

+

Luot xem

+
+
+

{listing.saveCount}

+

Luot luu

+
+
+

{listing.inquiryCount}

+

Lien he

+
+
+ {listing.publishedAt && ( +

+ Dang ngay {new Date(listing.publishedAt).toLocaleDateString('vi-VN')} +

+ )} +
+
+
+
+
+ ); +} + +function QuickStat({ icon, label, value }: { icon: string; label: string; value: string }) { + const icons: Record = { + area: ( + + + + ), + bed: ( + + + + ), + bath: ( + + + + ), + floors: ( + + + + ), + compass: ( + + + + + ), + }; + + return ( +
+
{icons[icon]}
+
+

{label}

+

{value}

+
+
+ ); +} + +function InfoItem({ label, value }: { label: string; value: string }) { + return ( +
+

{label}

+

{value}

+
+ ); +} diff --git a/apps/web/app/(public)/search/page.tsx b/apps/web/app/(public)/search/page.tsx index d68e96e..75c3108 100644 --- a/apps/web/app/(public)/search/page.tsx +++ b/apps/web/app/(public)/search/page.tsx @@ -44,6 +44,11 @@ function SearchContent() { const [loading, setLoading] = React.useState(true); const [viewMode, setViewMode] = React.useState('list'); const [showMobileFilters, setShowMobileFilters] = React.useState(false); + const [selectedListingId, setSelectedListingId] = React.useState(); + + const handleMarkerClick = (listing: ListingDetail) => { + setSelectedListingId(listing.id); + }; const fetchListings = React.useCallback(() => { setLoading(true); @@ -219,6 +224,8 @@ function SearchContent() { {viewMode === 'map' && ( )} @@ -238,6 +245,8 @@ function SearchContent() {
diff --git a/apps/web/components/map/listing-map.tsx b/apps/web/components/map/listing-map.tsx index 4279887..e375f7d 100644 --- a/apps/web/components/map/listing-map.tsx +++ b/apps/web/components/map/listing-map.tsx @@ -1,8 +1,9 @@ 'use client'; import * as React from 'react'; +import mapboxgl from 'mapbox-gl'; +import 'mapbox-gl/dist/mapbox-gl.css'; import { Badge } from '@/components/ui/badge'; -import { Button } from '@/components/ui/button'; import type { ListingDetail } from '@/lib/listings-api'; function formatPrice(priceVND: string): string { @@ -15,6 +16,7 @@ function formatPrice(priceVND: string): string { interface ListingMapProps { listings: ListingDetail[]; onMarkerClick?: (listing: ListingDetail) => void; + selectedListingId?: string; className?: string; } @@ -24,160 +26,203 @@ interface MapMarker { lng: number; } -export function ListingMap({ listings, onMarkerClick, className }: ListingMapProps) { - const [selectedMarker, setSelectedMarker] = React.useState(null); - const mapRef = React.useRef(null); +const CITY_COORDS: Record = { + 'Hồ Chí Minh': [10.8231, 106.6297], + 'Hà Nội': [21.0285, 105.8542], + 'Đà Nẵng': [16.0544, 108.2022], + 'Nha Trang': [12.2388, 109.1967], + 'Cần Thơ': [10.0452, 105.7469], +}; - // Parse listings with valid coordinates - const markers = React.useMemo(() => { - return listings - .filter((l) => { - // ListingDetail doesn't expose lat/lng directly, but the property might have it - // For now we'll use a simple city-based mapping as fallback - return true; - }) - .map((listing, index) => { - // Generate approximate coordinates based on city/district for demo - // In production, these would come from the API - const cityCoords: Record = { - 'Hồ Chí Minh': [10.8231, 106.6297], - 'Hà Nội': [21.0285, 105.8542], - 'Đà Nẵng': [16.0544, 108.2022], - 'Nha Trang': [12.2388, 109.1967], - 'Cần Thơ': [10.0452, 105.7469], - }; - const base = cityCoords[listing.property.city] || [10.8231, 106.6297]; - // Add small random offset per listing for visual spread - const seed = listing.id.charCodeAt(0) + index; - const lat = base[0] + ((seed % 100) - 50) * 0.001; - const lng = base[1] + ((seed % 73) - 36) * 0.001; - return { listing, lat, lng }; - }); - }, [listings]); +const DEFAULT_CENTER: [number, number] = [106.6297, 10.8231]; // HCMC [lng, lat] +const DEFAULT_ZOOM = 12; - const handleMarkerClick = (marker: MapMarker) => { - setSelectedMarker(marker); - onMarkerClick?.(marker.listing); +function getMarkerCoords(listing: ListingDetail, index: number): { lat: number; lng: number } { + const base = CITY_COORDS[listing.property.city] || [10.8231, 106.6297]; + const seed = listing.id.charCodeAt(0) + index; + return { + lat: base[0] + ((seed % 100) - 50) * 0.001, + lng: base[1] + ((seed % 73) - 36) * 0.001, }; +} - // CSS-based map visualization (no Mapbox dependency required) - // Uses a relative coordinate system to position markers - const bounds = React.useMemo(() => { - if (markers.length === 0) return { minLat: 10, maxLat: 22, minLng: 102, maxLng: 110 }; - const lats = markers.map((m) => m.lat); - const lngs = markers.map((m) => m.lng); - const padding = 0.01; - return { - minLat: Math.min(...lats) - padding, - maxLat: Math.max(...lats) + padding, - minLng: Math.min(...lngs) - padding, - maxLng: Math.max(...lngs) + padding, +export function ListingMap({ listings, onMarkerClick, selectedListingId, className }: ListingMapProps) { + const mapContainerRef = React.useRef(null); + const mapRef = React.useRef(null); + const markersRef = React.useRef([]); + const popupRef = React.useRef(null); + + const markers: MapMarker[] = React.useMemo( + () => listings.map((listing, index) => ({ listing, ...getMarkerCoords(listing, index) })), + [listings], + ); + + // Initialize map + React.useEffect(() => { + if (!mapContainerRef.current) return; + + const token = process.env['NEXT_PUBLIC_MAPBOX_TOKEN']; + if (!token) { + console.warn('NEXT_PUBLIC_MAPBOX_TOKEN is not set. Map will not render.'); + return; + } + + mapboxgl.accessToken = token; + + const map = new mapboxgl.Map({ + container: mapContainerRef.current, + style: 'mapbox://styles/mapbox/streets-v12', + center: DEFAULT_CENTER, + zoom: DEFAULT_ZOOM, + attributionControl: false, + }); + + map.addControl(new mapboxgl.NavigationControl(), 'top-right'); + map.addControl(new mapboxgl.AttributionControl({ compact: true }), 'bottom-right'); + + mapRef.current = map; + + return () => { + map.remove(); + mapRef.current = null; }; - }, [markers]); + }, []); + + // Update markers when listings change + React.useEffect(() => { + const map = mapRef.current; + if (!map) return; + + // Clear existing markers + markersRef.current.forEach((m) => m.remove()); + markersRef.current = []; + + if (markers.length === 0) return; + + const bounds = new mapboxgl.LngLatBounds(); + + markers.forEach((marker) => { + const el = document.createElement('button'); + el.className = 'mapbox-price-marker'; + const isSelected = selectedListingId === marker.listing.id; + el.innerHTML = `${formatPrice(marker.listing.priceVND)}`; + el.style.cssText = 'border:none;cursor:pointer;background:none;padding:0;'; + + el.addEventListener('click', (e) => { + e.stopPropagation(); + onMarkerClick?.(marker.listing); + showPopup(map, marker); + }); + + const mbMarker = new mapboxgl.Marker({ element: el, anchor: 'bottom' }) + .setLngLat([marker.lng, marker.lat]) + .addTo(map); + + markersRef.current.push(mbMarker); + bounds.extend([marker.lng, marker.lat]); + }); + + // Fit bounds with padding + if (markers.length > 1) { + map.fitBounds(bounds, { padding: 60, maxZoom: 15 }); + } else { + map.flyTo({ center: [markers[0]!.lng, markers[0]!.lat], zoom: 14 }); + } + }, [markers, selectedListingId, onMarkerClick]); + + function showPopup(map: mapboxgl.Map, marker: MapMarker) { + popupRef.current?.remove(); + + const { listing } = marker; + const imgHtml = listing.property.media.length > 0 + ? `${listing.property.title}` + : ''; + + const popup = new mapboxgl.Popup({ offset: 25, maxWidth: '260px', closeButton: true }) + .setLngLat([marker.lng, marker.lat]) + .setHTML(` +
+ ${imgHtml} +

+ ${formatPrice(listing.priceVND)} VND +

+

+ ${listing.property.title} +

+

+ ${listing.property.district}, ${listing.property.city} +

+
+ ${listing.property.areaM2} m\u00B2 + ${listing.property.bedrooms != null ? `${listing.property.bedrooms} PN` : ''} + ${listing.property.bathrooms != null ? `${listing.property.bathrooms} WC` : ''} +
+ + Xem chi tiet → + +
+ `) + .addTo(map); + + popupRef.current = popup; + } + + const hasToken = typeof process !== 'undefined' && process.env['NEXT_PUBLIC_MAPBOX_TOKEN']; return ( -
- {/* Grid lines for visual reference */} -
-
-
+
+
- {/* Markers */} - {markers.map((marker) => { - const x = ((marker.lng - bounds.minLng) / (bounds.maxLng - bounds.minLng)) * 100; - const y = ((bounds.maxLat - marker.lat) / (bounds.maxLat - bounds.minLat)) * 100; - const isSelected = selectedMarker?.listing.id === marker.listing.id; - - return ( - - ); - })} - - {/* Selected marker popup */} - {selectedMarker && ( -
- - {selectedMarker.listing.property.media.length > 0 && ( - {selectedMarker.listing.property.title} - )} -

- {formatPrice(selectedMarker.listing.priceVND)} VNĐ -

-

{selectedMarker.listing.property.title}

-

- {selectedMarker.listing.property.district}, {selectedMarker.listing.property.city} -

-
- {selectedMarker.listing.property.areaM2} m² - {selectedMarker.listing.property.bedrooms != null && ( - {selectedMarker.listing.property.bedrooms} PN - )} +

+ Thiết lập NEXT_PUBLIC_MAPBOX_TOKEN để hiển thị bản đồ +

- - Xem chi tiết -
)} - {/* Map controls */} -
-
- {markers.length} bất động sản trên bản đồ -
+ {/* Listing count overlay */} +
+ {markers.length} bất động sản trên bản đồ
{/* Empty state */} - {markers.length === 0 && ( -
+ {markers.length === 0 && hasToken && ( +

Không có bất động sản để hiển thị trên bản đồ

)} + +
); } diff --git a/apps/web/package.json b/apps/web/package.json index 67c0157..66e880d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -14,6 +14,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^1.7.0", + "mapbox-gl": "^3.21.0", "next": "^14.2.0", "react": "^18.3.0", "react-dom": "^18.3.0", @@ -23,6 +24,7 @@ "zustand": "^5.0.12" }, "devDependencies": { + "@types/mapbox-gl": "^3.5.0", "@types/node": "^22.0.0", "@types/react": "^18.3.0", "@types/react-dom": "^18.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f2e5b57..d56a6c1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -226,6 +226,9 @@ importers: lucide-react: specifier: ^1.7.0 version: 1.7.0(react@18.3.1) + mapbox-gl: + specifier: ^3.21.0 + version: 3.21.0 next: specifier: ^14.2.0 version: 14.2.35(@opentelemetry/api@1.9.1)(@playwright/test@1.59.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -248,6 +251,9 @@ importers: specifier: ^5.0.12 version: 5.0.12(@types/react@18.3.28)(react@18.3.1) devDependencies: + '@types/mapbox-gl': + specifier: ^3.5.0 + version: 3.5.0 '@types/node': specifier: ^22.0.0 version: 22.19.17 @@ -971,6 +977,25 @@ packages: resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==} engines: {node: '>=8'} + '@mapbox/jsonlint-lines-primitives@2.0.2': + resolution: {integrity: sha512-rY0o9A5ECsTQRVhv7tL/OyDpGAoUB4tTvLiW1DSzQGq4bvTPhNw1VpSNjDJc5GFZ2XuyOtSWSVN05qOtcD71qQ==} + engines: {node: '>= 0.6'} + + '@mapbox/mapbox-gl-supported@3.0.0': + resolution: {integrity: sha512-2XghOwu16ZwPJLOFVuIOaLbN0iKMn867evzXFyf0P22dqugezfJwLmdanAgU25ITvz1TvOfVP4jsDImlDJzcWg==} + + '@mapbox/point-geometry@1.1.0': + resolution: {integrity: sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==} + + '@mapbox/tiny-sdf@2.0.7': + resolution: {integrity: sha512-25gQLQMcpivjOSA40g3gO6qgiFPDpWRoMfd+G/GoppPIeP6JDaMMkMrEJnMZhKyyS6iKwVt5YKu02vCUyJM3Ug==} + + '@mapbox/unitbezier@0.0.1': + resolution: {integrity: sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==} + + '@mapbox/vector-tile@2.0.4': + resolution: {integrity: sha512-AkOLcbgGTdXScosBWwmmD7cDlvOjkg/DetGva26pIRiZPdeJYjYKarIlb4uxVzi6bwHO6EWH82eZ5Nuv4T5DUg==} + '@microsoft/tsdoc@0.16.0': resolution: {integrity: sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==} @@ -1718,6 +1743,12 @@ packages: '@types/express@5.0.6': resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==} + '@types/geojson-vt@3.2.5': + resolution: {integrity: sha512-qDO7wqtprzlpe8FfQ//ClPV9xiuoh2nkIgiouIptON9w5jvD/fA4szvP9GBlDVdJ5dldAl0kX/sy3URbWwLx0g==} + + '@types/geojson@7946.0.16': + resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} + '@types/http-errors@2.0.5': resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} @@ -1730,6 +1761,10 @@ packages: '@types/long@4.0.2': resolution: {integrity: sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==} + '@types/mapbox-gl@3.5.0': + resolution: {integrity: sha512-3wVAUTC6q1UKatLP9YxFBnGJWi3neJUF9OKeyRdUf/BsYjZAP35xmZkL4zogVJbO3vdExuSVYCAkzUXjpjdhOg==} + deprecated: This is a stub types definition. mapbox-gl provides its own type definitions, so you do not need this installed. + '@types/methods@1.1.4': resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==} @@ -1754,6 +1789,9 @@ packages: '@types/passport@1.0.17': resolution: {integrity: sha512-aciLyx+wDwT2t2/kJGJR2AEeBz0nJU4WuRX04Wu9Dqc5lSUtwu0WERPHYsLhF9PtseiAMPBGNUOtFjxZ56prsg==} + '@types/pbf@3.0.5': + resolution: {integrity: sha512-j3pOPiEcWZ34R6a6mN07mUkM4o4Lwf6hPNt8eilOeZhTFbxFXmKhvXl9Y28jotFPaI1bpPDJsbCprUoNke6OrA==} + '@types/prop-types@15.7.15': resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} @@ -1786,6 +1824,9 @@ packages: '@types/superagent@8.1.9': resolution: {integrity: sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==} + '@types/supercluster@7.1.3': + resolution: {integrity: sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==} + '@types/supertest@7.2.0': resolution: {integrity: sha512-uh2Lv57xvggst6lCqNdFAmDSvoMG7M/HDtX4iUCquxQ5EGPtaPM5PL5Hmi7LCvOG8db7YaCPNJEeoI8s/WzIQw==} @@ -2302,6 +2343,9 @@ packages: chardet@2.1.1: resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==} + cheap-ruler@4.0.0: + resolution: {integrity: sha512-0BJa8f4t141BYKQyn9NSQt1PguFQXMXwZiA5shfoaBYHAb2fFk2RAX+tiWMoQU+Agtzt3mdt0JtuyshAXqZ+Vw==} + check-error@2.1.3: resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} engines: {node: '>= 16'} @@ -2469,6 +2513,9 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + csscolorparser@1.0.3: + resolution: {integrity: sha512-umPSgYwZkdFoUrH5hIq5kf0wPSXiro51nPw0j2K/c83KflkPSTBGMz6NJvMB+07VlL0y7VPo6QJcDjcgKTTm3w==} + cssesc@3.0.0: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} @@ -2567,6 +2614,9 @@ packages: duplexify@4.1.3: resolution: {integrity: sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==} + earcut@3.0.2: + resolution: {integrity: sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==} + ecdsa-sig-formatter@1.0.11: resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} @@ -2979,6 +3029,9 @@ packages: resolution: {integrity: sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==} engines: {node: '>=18'} + geojson-vt@4.0.2: + resolution: {integrity: sha512-AV9ROqlNqoZEIJGfm1ncNjEXfkz2hdFlZf0qkVfmkwdKa8vj7H16YUOT81rJw1rdFhyEDlN2Tds91p/glzbl5A==} + get-caller-file@2.0.5: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} @@ -3002,6 +3055,9 @@ packages: resolution: {integrity: sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==} hasBin: true + gl-matrix@3.4.4: + resolution: {integrity: sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==} + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -3056,6 +3112,9 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + grid-index@1.1.0: + resolution: {integrity: sha512-HZRwumpOGUrHyxO5bqKZL0B0GlUpwtCAzZ42sgxUPniu33R1LSFH5yrIcBCHjkctCAh3mtWKcKd9J4vDDdeVHA==} + gtoken@7.1.0: resolution: {integrity: sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==} engines: {node: '>=14.0.0'} @@ -3314,6 +3373,9 @@ packages: jws@4.0.1: resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + kdbush@4.0.2: + resolution: {integrity: sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -3445,6 +3507,12 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + mapbox-gl@3.21.0: + resolution: {integrity: sha512-0mv/LHDoW6QmxLEoNqCPuby428WTekfs38TtNXd/cxDOLdY6kFd/ztaSkcBeqNksbYSz2lXnPfBm8nN5+hxA0w==} + + martinez-polygon-clipping@0.8.1: + resolution: {integrity: sha512-9PLLMzMPI6ihHox4Ns6LpVBLpRc7sbhULybZ/wyaY8sY3ECNe2+hxm1hA2/9bEEpRrdpjoeduBuZLg2aq1cSIQ==} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -3535,6 +3603,9 @@ packages: resolution: {integrity: sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==} engines: {node: '>= 10.16.0'} + murmurhash-js@1.0.0: + resolution: {integrity: sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==} + mute-stream@2.0.0: resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} engines: {node: ^18.17.0 || >=20.5.0} @@ -3754,6 +3825,10 @@ packages: pause@0.0.1: resolution: {integrity: sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==} + pbf@4.0.1: + resolution: {integrity: sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA==} + hasBin: true + perfect-debounce@1.0.0: resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} @@ -3865,6 +3940,9 @@ packages: resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} engines: {node: ^10 || ^12 || >=14} + potpack@2.1.0: + resolution: {integrity: sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==} + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -3903,6 +3981,9 @@ packages: resolution: {integrity: sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==} engines: {node: '>=12.0.0'} + protocol-buffers-schema@3.6.1: + resolution: {integrity: sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==} + proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} @@ -3930,6 +4011,9 @@ packages: quick-format-unescaped@4.0.4: resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + quickselect@3.0.0: + resolution: {integrity: sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==} + range-parser@1.2.1: resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} engines: {node: '>= 0.6'} @@ -4009,6 +4093,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.11: resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} engines: {node: '>= 0.4'} @@ -4037,6 +4124,9 @@ packages: rfdc@1.4.1: resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + robust-predicates@2.0.4: + resolution: {integrity: sha512-l4NwboJM74Ilm4VKfbAtFeGq7aEjWL+5kVFcmgFA2MrdnQWx9iE/tUGvxY5HyMI7o/WpSIUFLbC5fbeaHgSCYg==} + rollup@4.60.1: resolution: {integrity: sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -4164,6 +4254,9 @@ packages: resolution: {integrity: sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==} engines: {node: '>= 8'} + splaytree@0.1.4: + resolution: {integrity: sha512-D50hKrjZgBzqD3FT2Ek53f2dcDLAQT8SSGrzj3vidNH5ISRgceeGVJ2dQIthKOuayqFXfFjXheHNo4bbt9LhRQ==} + split2@4.2.0: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} @@ -4269,6 +4362,9 @@ packages: resolution: {integrity: sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==} engines: {node: '>=14.18.0'} + supercluster@8.0.1: + resolution: {integrity: sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==} + supertest@7.2.2: resolution: {integrity: sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==} engines: {node: '>=14.18.0'} @@ -4374,6 +4470,9 @@ packages: resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} engines: {node: ^18.0.0 || >=20.0.0} + tinyqueue@3.0.0: + resolution: {integrity: sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==} + tinyrainbow@2.0.0: resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} engines: {node: '>=14.0.0'} @@ -5667,6 +5766,22 @@ snapshots: '@lukeed/csprng@1.1.0': {} + '@mapbox/jsonlint-lines-primitives@2.0.2': {} + + '@mapbox/mapbox-gl-supported@3.0.0': {} + + '@mapbox/point-geometry@1.1.0': {} + + '@mapbox/tiny-sdf@2.0.7': {} + + '@mapbox/unitbezier@0.0.1': {} + + '@mapbox/vector-tile@2.0.4': + dependencies: + '@mapbox/point-geometry': 1.1.0 + '@types/geojson': 7946.0.16 + pbf: 4.0.1 + '@microsoft/tsdoc@0.16.0': {} '@modelcontextprotocol/sdk@1.29.0(zod@3.25.76)': @@ -6484,6 +6599,12 @@ snapshots: '@types/express-serve-static-core': 5.1.1 '@types/serve-static': 2.2.0 + '@types/geojson-vt@3.2.5': + dependencies: + '@types/geojson': 7946.0.16 + + '@types/geojson@7946.0.16': {} + '@types/http-errors@2.0.5': {} '@types/json-schema@7.0.15': {} @@ -6496,6 +6617,10 @@ snapshots: '@types/long@4.0.2': optional: true + '@types/mapbox-gl@3.5.0': + dependencies: + mapbox-gl: 3.21.0 + '@types/methods@1.1.4': {} '@types/ms@2.1.0': {} @@ -6528,6 +6653,8 @@ snapshots: dependencies: '@types/express': 5.0.6 + '@types/pbf@3.0.5': {} + '@types/prop-types@15.7.15': {} '@types/qs@6.15.0': {} @@ -6571,6 +6698,10 @@ snapshots: '@types/node': 22.19.17 form-data: 4.0.5 + '@types/supercluster@7.1.3': + dependencies: + '@types/geojson': 7946.0.16 + '@types/supertest@7.2.0': dependencies: '@types/methods': 1.1.4 @@ -7119,6 +7250,8 @@ snapshots: chardet@2.1.1: {} + cheap-ruler@4.0.0: {} + check-error@2.1.3: {} chokidar@3.6.0: @@ -7272,6 +7405,8 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + csscolorparser@1.0.3: {} + cssesc@3.0.0: {} csstype@3.2.3: {} @@ -7370,6 +7505,8 @@ snapshots: stream-shift: 1.0.3 optional: true + earcut@3.0.2: {} + ecdsa-sig-formatter@1.0.11: dependencies: safe-buffer: 5.2.1 @@ -7883,6 +8020,8 @@ snapshots: transitivePeerDependencies: - supports-color + geojson-vt@4.0.2: {} + get-caller-file@2.0.5: optional: true @@ -7919,6 +8058,8 @@ snapshots: nypm: 0.6.5 pathe: 2.0.3 + gl-matrix@3.4.4: {} + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -7995,6 +8136,8 @@ snapshots: graceful-fs@4.2.11: {} + grid-index@1.1.0: {} + gtoken@7.1.0: dependencies: gaxios: 6.7.1 @@ -8252,6 +8395,8 @@ snapshots: jwa: 2.0.1 safe-buffer: 5.2.1 + kdbush@4.0.2: {} + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -8373,6 +8518,39 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + mapbox-gl@3.21.0: + dependencies: + '@mapbox/jsonlint-lines-primitives': 2.0.2 + '@mapbox/mapbox-gl-supported': 3.0.0 + '@mapbox/point-geometry': 1.1.0 + '@mapbox/tiny-sdf': 2.0.7 + '@mapbox/unitbezier': 0.0.1 + '@mapbox/vector-tile': 2.0.4 + '@types/geojson': 7946.0.16 + '@types/geojson-vt': 3.2.5 + '@types/pbf': 3.0.5 + '@types/supercluster': 7.1.3 + cheap-ruler: 4.0.0 + csscolorparser: 1.0.3 + earcut: 3.0.2 + geojson-vt: 4.0.2 + gl-matrix: 3.4.4 + grid-index: 1.1.0 + kdbush: 4.0.2 + martinez-polygon-clipping: 0.8.1 + murmurhash-js: 1.0.0 + pbf: 4.0.1 + potpack: 2.1.0 + quickselect: 3.0.0 + supercluster: 8.0.1 + tinyqueue: 3.0.0 + + martinez-polygon-clipping@0.8.1: + dependencies: + robust-predicates: 2.0.4 + splaytree: 0.1.4 + tinyqueue: 3.0.0 + math-intrinsics@1.1.0: {} media-typer@0.3.0: {} @@ -8438,6 +8616,8 @@ snapshots: concat-stream: 2.0.0 type-is: 1.6.18 + murmurhash-js@1.0.0: {} + mute-stream@2.0.0: {} mz@2.7.0: @@ -8634,6 +8814,10 @@ snapshots: pause@0.0.1: {} + pbf@4.0.1: + dependencies: + resolve-protobuf-schema: 2.1.0 + perfect-debounce@1.0.0: {} picocolors@1.1.1: {} @@ -8749,6 +8933,8 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + potpack@2.1.0: {} + prelude-ls@1.2.1: {} prettier@3.8.1: {} @@ -8795,6 +8981,8 @@ snapshots: long: 5.3.2 optional: true + protocol-buffers-schema@3.6.1: {} + proxy-addr@2.0.7: dependencies: forwarded: 0.2.0 @@ -8819,6 +9007,8 @@ snapshots: quick-format-unescaped@4.0.4: {} + quickselect@3.0.0: {} + range-parser@1.2.1: {} raw-body@3.0.2: @@ -8888,6 +9078,10 @@ snapshots: resolve-pkg-maps@1.0.0: {} + resolve-protobuf-schema@2.1.0: + dependencies: + protocol-buffers-schema: 3.6.1 + resolve@1.22.11: dependencies: is-core-module: 2.16.1 @@ -8921,6 +9115,8 @@ snapshots: rfdc@1.4.1: {} + robust-predicates@2.0.4: {} + rollup@4.60.1: dependencies: '@types/estree': 1.0.8 @@ -9108,6 +9304,8 @@ snapshots: source-map@0.7.4: {} + splaytree@0.1.4: {} + split2@4.2.0: {} stable-hash-x@0.2.0: {} @@ -9209,6 +9407,10 @@ snapshots: transitivePeerDependencies: - supports-color + supercluster@8.0.1: + dependencies: + kdbush: 4.0.2 + supertest@7.2.2: dependencies: cookie-signature: 1.2.2 @@ -9334,6 +9536,8 @@ snapshots: tinypool@1.1.1: {} + tinyqueue@3.0.0: {} + tinyrainbow@2.0.0: {} tinyspy@4.0.4: {}