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 + + +