diff --git a/src/app/api/geoip/route.ts b/src/app/api/geoip/route.ts new file mode 100644 index 0000000..9cfb381 --- /dev/null +++ b/src/app/api/geoip/route.ts @@ -0,0 +1,30 @@ +import { type NextRequest } from 'next/server' + +export async function GET(request: NextRequest): Promise { + const forwarded = request.headers.get('x-forwarded-for'); + const clientIp = forwarded ? forwarded.split(',')[0].trim() : null; + + if (!clientIp) { + return Response.json({ latitude: null, longitude: null }); + } + + // Try ip-api.com first (free, reliable for real user IPs) + try { + const res = await fetch(`http://ip-api.com/json/${clientIp}?fields=status,lat,lon`, { cache: 'no-store' }); + const json = await res.json(); + if (json.status === 'success' && typeof json.lat === 'number' && typeof json.lon === 'number') { + return Response.json({ latitude: json.lat, longitude: json.lon }); + } + } catch {} + + // Fall back to ipwho.is + try { + const res = await fetch(`https://ipwho.is/${clientIp}`, { cache: 'no-store' }); + const json = await res.json(); + if (json.success && typeof json.latitude === 'number' && typeof json.longitude === 'number') { + return Response.json({ latitude: json.latitude, longitude: json.longitude }); + } + } catch {} + + return Response.json({ latitude: null, longitude: null }); +} diff --git a/src/components/map/mapPageContent.tsx b/src/components/map/mapPageContent.tsx index 028ba0b..572cdd2 100644 --- a/src/components/map/mapPageContent.tsx +++ b/src/components/map/mapPageContent.tsx @@ -16,6 +16,7 @@ import { CITY_DATA } from '@/data/citydata'; import { CityDataProps, DataRangeKeys, ViewState } from '@/data/interfaces'; import { calculateLayerRanges } from '@/components/utils/layerUtils'; import { localStorageHelpers, loadInitialState } from '@/components/utils/localStorageUtils'; +import { useIpCityDefault } from '@/components/utils/useIpCityDefault'; import { useMapTourLogic } from '@/components/utils/mapTourUtils'; import { LAYER_CONSTANTS, NUM_LAYERS_OPTIONS } from '@/components/utils/pageConstants'; import type { NumLayersMode } from '@/components/utils/pageConstants'; @@ -301,6 +302,13 @@ export default function MapPageContent(): JSX.Element { transitionInterpolator: new FlyToInterpolator() }), []); + useIpCityDefault((detectedIdx) => { + setIdx(detectedIdx); + setViewState(createViewStateForCity(detectedIdx)); + const theseLayers = Object.keys(CITY_DATA.citiesArray[detectedIdx].dataRanges); + setCityLayers(theseLayers); + }); + const handleIdxChange = useCallback((idx: number) => { setIdx(idx); setViewState(createViewStateForCity(idx)); diff --git a/src/components/summarise/summarisePage.test.tsx b/src/components/summarise/summarisePage.test.tsx index 500518b..2d6bf01 100644 --- a/src/components/summarise/summarisePage.test.tsx +++ b/src/components/summarise/summarisePage.test.tsx @@ -18,6 +18,7 @@ vi.mock('@/components/utils/localStorageUtils', () => ({ getItem: vi.fn(() => null), setItem: vi.fn(), }, + fetchIpDefaultCityIdx: vi.fn(() => Promise.resolve(0)), })) describe('SummarisePage', () => { diff --git a/src/components/summarise/summarisePageContent.tsx b/src/components/summarise/summarisePageContent.tsx index cfb565e..c291ed8 100644 --- a/src/components/summarise/summarisePageContent.tsx +++ b/src/components/summarise/summarisePageContent.tsx @@ -7,6 +7,7 @@ import Control from '@/components/summarise/control'; import styles from '@/styles/summarise.module.css'; import Content from '@/components/summarise/citySummaryData'; import { localStorageHelpers } from '@/components/utils/localStorageUtils'; +import { useIpCityDefault } from '@/components/utils/useIpCityDefault'; export default function SummarisePageContent(): JSX.Element { @@ -28,6 +29,8 @@ export default function SummarisePageContent(): JSX.Element { const hasFullQuery = urlData.hasFullQuery; const [idx, setIdx] = useState(urlData.idx); + useIpCityDefault(setIdx); + useEffect(() => { if (hasFullQuery) return; const cityName = contentArray[idx]?.name ?? ''; diff --git a/src/components/transform/transformPage.test.tsx b/src/components/transform/transformPage.test.tsx index 539c29c..278d7e8 100644 --- a/src/components/transform/transformPage.test.tsx +++ b/src/components/transform/transformPage.test.tsx @@ -7,7 +7,8 @@ vi.mock('@/components/utils/localStorageUtils', () => ({ getItem: vi.fn(() => null), setItem: vi.fn(), removeItem: vi.fn() - } + }, + fetchIpDefaultCityIdx: vi.fn(() => Promise.resolve(0)), })) vi.mock('@/components/utils/transformTourUtils', () => ({ diff --git a/src/components/transform/transformPage.tsx b/src/components/transform/transformPage.tsx index 44cebec..f97ad67 100644 --- a/src/components/transform/transformPage.tsx +++ b/src/components/transform/transformPage.tsx @@ -16,6 +16,7 @@ import { CityDataProps } from "@/data/interfaces"; import { CITY_DATA } from '@/data/citydata'; import { DataRangeKeys, ViewState } from '@/data/interfaces'; import { localStorageHelpers } from '@/components/utils/localStorageUtils'; +import { useIpCityDefault } from '@/components/utils/useIpCityDefault'; import { useTransformTourLogic } from '@/components/utils/transformTourUtils'; import { LAYER_CONSTANTS, OUTPUT_LAYER_TYPES } from '@/components/utils/pageConstants'; import type { OutputLayerType } from '@/components/utils/pageConstants'; @@ -282,6 +283,11 @@ export default function TransformPage(): JSX.Element { transitionInterpolator: new FlyToInterpolator() }), []); + useIpCityDefault((detectedIdx) => { + setIdx(detectedIdx); + setViewState(createViewStateForCity(detectedIdx)); + }); + const citiesData = useMemo(() => ({ citiesArray: CITY_DATA.citiesArray, citiesCount: CITY_DATA.citiesArray.length, diff --git a/src/components/utils/localStorageUtils.tsx b/src/components/utils/localStorageUtils.tsx index a10cc73..558cb1f 100644 --- a/src/components/utils/localStorageUtils.tsx +++ b/src/components/utils/localStorageUtils.tsx @@ -1,5 +1,13 @@ import { useState, useReducer } from 'react'; import { DataRangeKeys } from '@/data/interfaces'; +import { findNearestCityIdx } from '@/data/citydata'; + +export async function fetchIpDefaultCityIdx(): Promise { + const res = await fetch('/api/geoip'); + const json = await res.json(); + if (typeof json.latitude !== 'number' || typeof json.longitude !== 'number') return 0; + return findNearestCityIdx(json.latitude as number, json.longitude as number); +} export const localStorageHelpers = { diff --git a/src/components/utils/useIpCityDefault.tsx b/src/components/utils/useIpCityDefault.tsx new file mode 100644 index 0000000..1f6750a --- /dev/null +++ b/src/components/utils/useIpCityDefault.tsx @@ -0,0 +1,23 @@ +"use client" + +import { useEffect, useRef } from 'react'; +import { localStorageHelpers, fetchIpDefaultCityIdx } from './localStorageUtils'; + +export function useIpCityDefault(onResolve: (idx: number) => void): void { + const callbackRef = useRef(onResolve); + useEffect(() => { callbackRef.current = onResolve; }); + + useEffect(() => { + // '0' means either "never detected" or "failed detection" — treat both as unset + const stored = localStorageHelpers.getItem('uaCityIdx'); + if (stored !== null && stored !== '0') return; + fetchIpDefaultCityIdx() + .then(idx => { + if (idx > 0) { + localStorageHelpers.setItem('uaCityIdx', idx.toString()); + callbackRef.current(idx); + } + }) + .catch(() => {}); + }, []); +} diff --git a/src/data/citydata.tsx b/src/data/citydata.tsx index 112c002..e824cfe 100644 --- a/src/data/citydata.tsx +++ b/src/data/citydata.tsx @@ -1478,3 +1478,14 @@ const washington = { export const CITY_DATA = { "citiesArray": [berlin, hamburg, london, mannheim, muenster, paris, philadelphia, utrecht, washington], }; + +export function findNearestCityIdx(lat: number, lon: number): number { + return CITY_DATA.citiesArray.reduce((bestIdx, city, i) => { + const dLat = city.initialViewState.latitude - lat; + const dLon = city.initialViewState.longitude - lon; + const best = CITY_DATA.citiesArray[bestIdx].initialViewState; + const bLat = best.latitude - lat; + const bLon = best.longitude - lon; + return (dLat * dLat + dLon * dLon) < (bLat * bLat + bLon * bLon) ? i : bestIdx; + }, 0); +}