diff --git a/components/search/Actions.tsx b/components/search/Actions.tsx
index d7a4cf7..9d02f6c 100644
--- a/components/search/Actions.tsx
+++ b/components/search/Actions.tsx
@@ -67,6 +67,14 @@ export const actions: Action[] = [
subtitle: 'Rotating Globe with grab',
perform: () => (window.location.href = '/globe'),
},
+ {
+ id: 'map',
+ name: '3D Map',
+ section: 'Awesome',
+ icon: ,
+ subtitle: 'Full screen 3D map centered on your location',
+ perform: () => (window.location.href = '/map'),
+ },
{
id: 'emoji',
name: 'Emoji',
diff --git a/pages/api/ip-lookup.ts b/pages/api/ip-lookup.ts
index 5b65a44..87869d1 100644
--- a/pages/api/ip-lookup.ts
+++ b/pages/api/ip-lookup.ts
@@ -1,26 +1,49 @@
import type { NextApiRequest, NextApiResponse } from 'next'
type Data = {
- latitude: number,
+ latitude: number
longitude: number
}
+
type ApiResponse = {
- loc: string
+ loc?: string
+}
+
+const fallbackLocation: Data = {
+ latitude: 40.7128,
+ longitude: -74.006,
+}
+
+function getClientIp(req: NextApiRequest) {
+ const forwardedFor = req.headers['x-forwarded-for']
+ const forwardedIp = Array.isArray(forwardedFor)
+ ? forwardedFor[0]
+ : forwardedFor?.split(',')[0]
+ const cloudflareIp = req.headers['cf-connecting-ip']
+ const headerIp = Array.isArray(cloudflareIp) ? cloudflareIp[0] : cloudflareIp
+
+ return headerIp || forwardedIp || req.socket.remoteAddress || ''
}
export default async function handler(
req: NextApiRequest,
- res: NextApiResponse
+ res: NextApiResponse,
) {
- const clientIP = req.headers['cf-connecting-ip']
- const ipResponse = await fetch(`https://ipinfo.io/${clientIP}/json?token=ef8461623ce04a`)
+ const clientIp = getClientIp(req)
+ const ipResponse = await fetch(
+ `https://ipinfo.io/${clientIp}/json?token=ef8461623ce04a`,
+ )
const apiResponse: ApiResponse = await ipResponse.json()
- const [lat, long] = apiResponse.loc.split(",")
+ if (!apiResponse.loc) {
+ res.status(200).json(fallbackLocation)
+ return
+ }
+ const [lat, long] = apiResponse.loc.split(',')
const data: Data = {
latitude: parseFloat(lat),
- longitude: parseFloat(long)
+ longitude: parseFloat(long),
}
res.status(200).json(data)
diff --git a/pages/map.tsx b/pages/map.tsx
new file mode 100644
index 0000000..7b1bced
--- /dev/null
+++ b/pages/map.tsx
@@ -0,0 +1,281 @@
+import Head from 'next/head'
+import Script from 'next/script'
+import { useEffect, useRef, useState } from 'react'
+import { MapPin } from 'lucide-react'
+
+import Page from '@/components/page'
+
+type Coordinates = {
+ latitude: number
+ longitude: number
+}
+
+type LocationSource = 'browser' | 'ip' | 'default'
+
+type LocationState = {
+ coordinates: Coordinates
+ source: LocationSource
+ label: string
+}
+
+type MapLibre = {
+ Map: new (options: Record) => MapInstance
+ Marker: new (options?: Record) => MapMarker
+ NavigationControl: new (options?: Record) => unknown
+ GeolocateControl: new (options?: Record) => unknown
+ FullscreenControl: new (options?: Record) => unknown
+}
+
+type MapInstance = {
+ addControl: (control: unknown, position?: string) => void
+ flyTo: (options: Record) => void
+ remove: () => void
+}
+
+type MapMarker = {
+ setLngLat: (coordinates: [number, number]) => MapMarker
+ addTo: (map: MapInstance) => MapMarker
+ remove: () => void
+}
+
+declare global {
+ interface Window {
+ maplibregl?: MapLibre
+ }
+}
+
+const MAP_STYLE = 'https://tiles.openfreemap.org/styles/liberty'
+const DEFAULT_LOCATION: LocationState = {
+ coordinates: {
+ latitude: 40.7128,
+ longitude: -74.006,
+ },
+ source: 'default',
+ label: 'default location',
+}
+
+const browserLocationOptions: PositionOptions = {
+ enableHighAccuracy: true,
+ timeout: 10000,
+ maximumAge: 60000,
+}
+
+function getBrowserLocation() {
+ return new Promise((resolve, reject) => {
+ if (!navigator.geolocation) {
+ reject(new Error('Browser geolocation is not available'))
+ return
+ }
+
+ navigator.geolocation.getCurrentPosition(
+ (position) => {
+ resolve({
+ coordinates: {
+ latitude: position.coords.latitude,
+ longitude: position.coords.longitude,
+ },
+ source: 'browser',
+ label: 'browser location',
+ })
+ },
+ reject,
+ browserLocationOptions,
+ )
+ })
+}
+
+async function getIpLocation() {
+ const response = await fetch('/api/ip-lookup')
+
+ if (!response.ok) {
+ throw new Error('IP location lookup failed')
+ }
+
+ const coordinates: Coordinates = await response.json()
+
+ if (
+ !Number.isFinite(coordinates.latitude) ||
+ !Number.isFinite(coordinates.longitude)
+ ) {
+ throw new Error('IP location lookup returned invalid coordinates')
+ }
+
+ return {
+ coordinates,
+ source: 'ip' as const,
+ label: 'IP address location',
+ }
+}
+
+const FullScreen3DMap = () => {
+ const mapContainerRef = useRef(null)
+ const mapRef = useRef(null)
+ const markerRef = useRef(null)
+ const [isMapLibreReady, setIsMapLibreReady] = useState(false)
+ const [location, setLocation] = useState(null)
+ const [status, setStatus] = useState('Requesting browser location…')
+
+ useEffect(() => {
+ let cancelled = false
+
+ const loadLocation = async () => {
+ try {
+ const browserLocation = await getBrowserLocation()
+
+ if (!cancelled) {
+ setLocation(browserLocation)
+ setStatus('Centered on your browser location')
+ }
+ } catch {
+ if (!cancelled) {
+ setStatus('Browser location unavailable. Falling back to IP address…')
+ }
+
+ try {
+ const ipLocation = await getIpLocation()
+
+ if (!cancelled) {
+ setLocation(ipLocation)
+ setStatus('Centered on your IP address location')
+ }
+ } catch {
+ if (!cancelled) {
+ setLocation(DEFAULT_LOCATION)
+ setStatus('Location lookup failed. Showing the default map center')
+ }
+ }
+ }
+ }
+
+ loadLocation()
+
+ return () => {
+ cancelled = true
+ }
+ }, [])
+
+ useEffect(() => {
+ if (
+ !isMapLibreReady ||
+ !location ||
+ !mapContainerRef.current ||
+ mapRef.current
+ ) {
+ return
+ }
+
+ const maplibregl = window.maplibregl
+
+ if (!maplibregl) {
+ setStatus('Map library failed to load')
+ return
+ }
+
+ const center: [number, number] = [
+ location.coordinates.longitude,
+ location.coordinates.latitude,
+ ]
+ const map = new maplibregl.Map({
+ container: mapContainerRef.current,
+ style: MAP_STYLE,
+ center,
+ zoom: 16,
+ pitch: 60,
+ bearing: -20,
+ antialias: true,
+ attributionControl: { compact: true },
+ })
+
+ map.addControl(
+ new maplibregl.NavigationControl({ visualizePitch: true }),
+ 'top-right',
+ )
+ map.addControl(
+ new maplibregl.GeolocateControl({
+ positionOptions: browserLocationOptions,
+ trackUserLocation: true,
+ showUserHeading: true,
+ }),
+ 'top-right',
+ )
+ map.addControl(new maplibregl.FullscreenControl(), 'top-right')
+
+ markerRef.current = new maplibregl.Marker({ color: '#38bdf8' })
+ .setLngLat(center)
+ .addTo(map)
+ mapRef.current = map
+
+ return () => {
+ markerRef.current?.remove()
+ map.remove()
+ markerRef.current = null
+ mapRef.current = null
+ }
+ }, [isMapLibreReady, location])
+
+ useEffect(() => {
+ if (!location || !mapRef.current || !markerRef.current) {
+ return
+ }
+
+ const center: [number, number] = [
+ location.coordinates.longitude,
+ location.coordinates.latitude,
+ ]
+
+ markerRef.current.setLngLat(center)
+ mapRef.current.flyTo({
+ center,
+ zoom: 16,
+ pitch: 60,
+ bearing: -20,
+ duration: 1200,
+ })
+ }, [location])
+
+ return (
+ <>
+
+ 3D Map Wake Lock
+
+
+