diff --git a/apps/backend/src/factory.ts b/apps/backend/src/factory.ts index f0b497a..9bc9f91 100644 --- a/apps/backend/src/factory.ts +++ b/apps/backend/src/factory.ts @@ -4,8 +4,7 @@ import { poweredBy } from 'hono/powered-by' import { prettyJSON } from 'hono/pretty-json' import { trimTrailingSlash } from 'hono/trailing-slash' import type OpenAI from 'openai' -import type { Client } from 'openapi-fetch' -import type { paths } from './lib/odptApiPath' +import type { OdptClient } from './lib/odptApiPath' import { aiMiddleware } from './middlewares/ai' import { corsMiddleware } from './middlewares/cors' import { odptClientMiddleware } from './middlewares/odptClient' @@ -19,8 +18,8 @@ export type BindingsType = { type VariablesType = { aiModel: LanguageModel openaiClient: OpenAI - odptClient: Client - odptChallengeClient: Client + odptClient: OdptClient + odptChallengeClient: OdptClient } type HonoConfigType = { diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index 0daf2e6..132dcbd 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -1,5 +1,6 @@ import { honoFactory } from './factory' import { healthRoute } from './routes/health' +import { nearestStationRoute } from './routes/nearestStation' import { speechRoute } from './routes/speech' import { transcriptionRoute } from './routes/transcription' @@ -9,6 +10,7 @@ const routes = app .route('/health', healthRoute) .route('/transcription', transcriptionRoute) .route('/speech', speechRoute) + .route('/nearestStation', nearestStationRoute) export type HonoRoutes = typeof routes diff --git a/apps/backend/src/lib/odptApiPath.ts b/apps/backend/src/lib/odptApiPath.ts index afb964a..57f1f03 100644 --- a/apps/backend/src/lib/odptApiPath.ts +++ b/apps/backend/src/lib/odptApiPath.ts @@ -1,4 +1,5 @@ import type { paths } from 'odpt-openapi-generated' +import type { Client } from 'openapi-fetch' const API_KEY_PROPERTY_NAME = 'acl:consumerKey' as const @@ -23,4 +24,6 @@ type OmitApiKey = { type OmitApiKeyFromPaths = OmitApiKey -export type { OmitApiKeyFromPaths as paths } +type OdptClient = Client + +export type { OmitApiKeyFromPaths as paths, OdptClient } diff --git a/apps/backend/src/routes/nearestStation.ts b/apps/backend/src/routes/nearestStation.ts new file mode 100644 index 0000000..f37d812 --- /dev/null +++ b/apps/backend/src/routes/nearestStation.ts @@ -0,0 +1,40 @@ +import { vValidator } from '@hono/valibot-validator' +import { array, object, pipe, string, transform, union } from 'valibot' +import { honoFactory } from '../factory' +import { locationSchema } from '../types/location' +import { stationUseCases } from '../usecases/stationUseCase' + +const getReqQuerySchema = object({ + lat: pipe( + union([string(), array(string())]), + transform((input) => { + const value = Array.isArray(input) ? input[0] : input + const parsed = Number(value) + if (Number.isNaN(parsed)) throw new Error('lat must be a number') + return parsed + }), + locationSchema.entries.lat, + ), + lon: pipe( + union([string(), array(string())]), + transform((input) => { + const value = Array.isArray(input) ? input[0] : input + const parsed = Number(value) + if (Number.isNaN(parsed)) throw new Error('lon must be a number') + return parsed + }), + locationSchema.entries.lon, + ), +}) + +export const nearestStationRoute = honoFactory + .createApp() + .get('/', vValidator('query', getReqQuerySchema), async (c) => { + const { lat, lon } = c.req.valid('query') + try { + const nearestStation = await stationUseCases.getNearestStation({ lat, lon }, c.var.odptClient) + return c.json({ station: nearestStation }, 200) + } catch (e) { + return c.json({ error: e }, 500) + } + }) diff --git a/apps/backend/src/types/location.ts b/apps/backend/src/types/location.ts new file mode 100644 index 0000000..b3e7a87 --- /dev/null +++ b/apps/backend/src/types/location.ts @@ -0,0 +1,8 @@ +import { type InferOutput, maxValue, minValue, number, object, pipe } from 'valibot' + +export const locationSchema = object({ + lat: pipe(number(), minValue(-90), maxValue(90)), + lon: pipe(number(), minValue(-180), maxValue(180)), +}) + +export type Location = InferOutput diff --git a/apps/backend/src/usecases/stationUseCase.ts b/apps/backend/src/usecases/stationUseCase.ts new file mode 100644 index 0000000..431cbd1 --- /dev/null +++ b/apps/backend/src/usecases/stationUseCase.ts @@ -0,0 +1,28 @@ +import type { OdptClient } from '../lib/odptApiPath' +import type { Location } from '../types/location' +import { calculateSphericalDistance } from '../utils/calculateSphericalDistance' + +export const stationUseCases = { + getNearestStation: async ({ lat, lon }: Location, odptClient: OdptClient) => { + const maxSearchRadius = 4000 + + const { data: stations } = await odptClient.GET('/places/{RDF_TYPE}', { + params: { path: { RDF_TYPE: 'odpt:Station' }, query: { lat, lon, radius: maxSearchRadius } }, + }) + if (stations === undefined) throw new Error('Failed to fetch stations') + + const nearestStation = stations?.reduce((nearest, current) => { + const nearestDistance = calculateSphericalDistance( + { lat, lon }, + { lat: nearest['geo:lat'], lon: nearest['geo:long'] }, + ) + const currentDistance = calculateSphericalDistance( + { lat, lon }, + { lat: current['geo:lat'], lon: current['geo:long'] }, + ) + return currentDistance < nearestDistance ? current : nearest + }) + + return nearestStation + }, +} diff --git a/apps/backend/src/utils/calculateSphericalDistance.ts b/apps/backend/src/utils/calculateSphericalDistance.ts new file mode 100644 index 0000000..f6be1be --- /dev/null +++ b/apps/backend/src/utils/calculateSphericalDistance.ts @@ -0,0 +1,28 @@ +import type { Location } from '../types/location' + +const degreeToRadian = (degree: number) => degree * (Math.PI / 180) +const EARTH_RADIUS_KM = 6371 + +/** + * 2点の緯度経度からその球面距離を計算する関数 + * + * @param from - 出発地点の位置情報 + * @param to - 到着地点の位置情報 + * @returns 2つの位置情報間の距離(キロメートル) + */ +export const calculateSphericalDistance = (from: Location, to: Location): number => { + const deltaLatitude = degreeToRadian(to.lat - from.lat) + const deltaLongitude = degreeToRadian(to.lon - from.lon) + + // Haversineの公式を使用して、2点間の大円距離を求めるために使用される値'a'を計算 + const a = + Math.sin(deltaLatitude / 2) * Math.sin(deltaLatitude / 2) + + Math.cos(degreeToRadian(from.lat)) * + Math.cos(degreeToRadian(to.lat)) * + Math.sin(deltaLongitude / 2) * + Math.sin(deltaLongitude / 2) + + const centralAngle = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)) + + return EARTH_RADIUS_KM * centralAngle +} diff --git a/apps/frontend/src/hooks/useCurrentLocation.ts b/apps/frontend/src/hooks/useCurrentLocation.ts new file mode 100644 index 0000000..b58b68d --- /dev/null +++ b/apps/frontend/src/hooks/useCurrentLocation.ts @@ -0,0 +1,13 @@ +import { useEffect, useState } from 'react' + +export const useCurrentLocation = () => { + const [currentGeolocation, setGeolocation] = useState() + + useEffect(() => { + navigator.geolocation.getCurrentPosition((position) => { + setGeolocation(position) + }) + }, []) + + return { currentGeolocation } +}