diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 00000000..03711a98
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,54 @@
+name: CI
+
+on:
+ pull_request:
+ push:
+ branches:
+ - main
+
+jobs:
+ verify:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: "22"
+
+ - name: Setup pnpm
+ uses: pnpm/action-setup@v3
+ with:
+ version: 9.0.0
+
+ - name: Setup Bun
+ uses: oven-sh/setup-bun@v2
+ with:
+ bun-version: latest
+
+ - name: Install dependencies
+ run: pnpm install --frozen-lockfile
+
+ - name: Type check
+ run: pnpm check-types
+
+ - name: Test core package
+ run: pnpm --filter ground-codes test
+
+ - name: Test standalone package
+ run: pnpm --filter ground-codes test:standalone
+
+ - name: Test API
+ run: pnpm --filter api-ground-codes test
+
+ - name: Test web client
+ run: pnpm --filter web test
+
+ - name: Install Playwright browser
+ run: pnpm --filter web exec playwright install --with-deps chromium
+
+ - name: Browser smoke test
+ run: pnpm --filter web test:e2e
diff --git a/.github/workflows/deploy-grok-spiral.yml b/.github/workflows/deploy-grok-spiral.yml
index 690cec5f..09df61e7 100644
--- a/.github/workflows/deploy-grok-spiral.yml
+++ b/.github/workflows/deploy-grok-spiral.yml
@@ -6,7 +6,11 @@ on:
- main
paths:
- "apps/grok-spiral/**"
+ - "packages/ui/**"
- ".github/workflows/deploy-grok-spiral.yml"
+ - "package.json"
+ - "pnpm-lock.yaml"
+ - "pnpm-workspace.yaml"
workflow_dispatch:
jobs:
diff --git a/.github/workflows/deploy-web.yml b/.github/workflows/deploy-web.yml
index 315f0b75..836cc8ac 100644
--- a/.github/workflows/deploy-web.yml
+++ b/.github/workflows/deploy-web.yml
@@ -6,7 +6,11 @@ on:
- main
paths:
- "apps/web/**"
+ - "packages/ui/**"
- ".github/workflows/deploy-web.yml"
+ - "package.json"
+ - "pnpm-lock.yaml"
+ - "pnpm-workspace.yaml"
workflow_dispatch:
jobs:
@@ -56,6 +60,12 @@ jobs:
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
NEXT_PUBLIC_GOOGLE_MAPS_API_KEY: ${{ secrets.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY }}
NEXT_PUBLIC_GOOGLE_MAPS_ROADMAP_ID: ${{ secrets.NEXT_PUBLIC_GOOGLE_MAPS_ROADMAP_ID }}
+ NEXT_PUBLIC_GROUND_CODES_API_URL: ${{ secrets.NEXT_PUBLIC_GROUND_CODES_API_URL }}
+ NEXT_PUBLIC_CESIUM_ION_TOKEN: ${{ secrets.NEXT_PUBLIC_CESIUM_ION_TOKEN }}
+ NEXT_PUBLIC_CESIUM_MOON_ASSET_ID: ${{ secrets.NEXT_PUBLIC_CESIUM_MOON_ASSET_ID }}
+ NEXT_PUBLIC_CESIUM_MARS_ASSET_ID: ${{ secrets.NEXT_PUBLIC_CESIUM_MARS_ASSET_ID }}
+ GOOGLE_MAPS_NODEJS_API_KEY: ${{ secrets.GOOGLE_MAPS_NODEJS_API_KEY }}
+ OPENWEATHER_API_KEY: ${{ secrets.OPENWEATHER_API_KEY }}
run: |
cd apps/web
pnpm run pages:build && wrangler pages deploy
diff --git a/.github/workflows/production-smoke.yml b/.github/workflows/production-smoke.yml
new file mode 100644
index 00000000..22f318bf
--- /dev/null
+++ b/.github/workflows/production-smoke.yml
@@ -0,0 +1,20 @@
+name: Production Smoke
+
+on:
+ workflow_dispatch:
+ schedule:
+ - cron: "*/30 * * * *"
+
+jobs:
+ smoke:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Check API readiness
+ run: curl -fsS https://api.ground.codes/readyz
+
+ - name: Check web robots
+ run: curl -fsS https://ground.codes/robots.txt
+
+ - name: Check web sitemap
+ run: curl -fsS https://ground.codes/sitemap.xml
diff --git a/AGENTS.md b/AGENTS.md
index 1c609162..96856820 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -138,6 +138,7 @@ PR 제출 전 위 4개를 모두 통과시킵니다.
- `packages/geoint` 데이터 갱신 시 sample 검증.
- Web 앱의 시각 회귀는 수동을 우선합니다.
- 지구·달·화성 등 행성 지도 렌더링을 변경한 경우, 배포 후 실제 production URL 에서 이미지 스크린샷을 반드시 확인합니다. 네트워크 요청 성공, canvas 생성 여부, 타입 체크만으로 완료 처리하지 않습니다.
+- 행성 지도 UI 변경은 데스크톱과 모바일 폭 모두에서 직접 확인합니다. 특히 좌측 하단 출처 라벨, 하단 인코딩 패널, 우측 하단 컨트롤이 서로 겹치지 않아야 합니다.
---
@@ -147,3 +148,9 @@ PR 제출 전 위 4개를 모두 통과시킵니다.
- **좌표 정확도**: GCS(Geographic Coordinate System) 기반이므로 부동소수 정밀도 손실에 주의 — high-zoom 영역의 인코딩은 fixed-point 또는 BigInt 처리.
- **외부 의존**: Google Maps SDK 변경에 추적이 필요합니다 (Maps Platform 의 deprecation 알림 모니터링).
- **행성 지도 품질**: 행성 기본 뷰는 고화질을 우선하며, 각 행성의 실제색에 가까운 자연스러운 색감을 선호합니다. 화질 향상을 위해 적외선·고도·relief 레이어를 사용할 때도 기본 색상이 과도하게 알록달록하거나 실제 행성색에서 벗어나지 않도록 합니다.
+- **행성 지도 출처**: 달·화성 3D 지도에서 USGS WMS imagery 를 쓰는 경우, 지도 출처 라벨은 좌측 하단에 유지합니다. Cesium ion asset 을 쓰지 않는 WMS-only 렌더에서는 Cesium ion credit/logo 가 행성 전환이나 카메라 조작 후 다시 노출되지 않아야 합니다.
+- **3D 지도 컨트롤**: 달·화성에는 내 위치 버튼을 표시하지 않습니다. 숨김 상태에서도 우측 하단 컨트롤 스택에 빈 슬롯을 남기지 않고, 그리드 버튼과 나침반 버튼이 자연스럽게 붙어 보여야 합니다.
+- **3D 나침반 동작**: 지구·달·화성 3D 뷰에서 나침반은 양방향 동기화되어야 합니다. 나침반 드래그는 3D 카메라를 회전해야 하고, 사용자가 구체를 직접 회전하면 나침반도 현재 화면 방향을 따라 회전해야 합니다. 나침반을 단순 클릭하면 북쪽이 화면 위로 오도록 `0deg` 로 정렬합니다.
+- **Google Maps 3D 지구**: Earth 3D 는 Google `Map3DElement.heading` 을 부모 나침반 상태와 동기화합니다. 직접 구체를 돌리는 경우 `steadychange` 이벤트와 짧은 polling 으로 heading 변화를 감지합니다.
+- **Cesium 달·화성 3D**: Cesium 구체는 `camera.heading` 또는 `camera.setView({ orientation.heading })` 만으로는 화면상 회전이 체감되지 않을 수 있습니다. 나침반 기반 회전은 현재 시야 축 기준 `camera.twistRight()` 에 최단 heading delta 를 적용합니다. 직접 구체 조작 후 나침반 동기화는 화면 중심점에서 실제 북쪽이 화면상 어느 방향을 향하는지 계산해 부모 나침반 상태로 올립니다.
+- **Cesium 초기화 주의**: 달·화성 3D 초기 카메라는 북쪽 기준으로 세팅한 뒤 필요한 나침반 heading 만 별도 twist 로 적용합니다. 초기 `setView` heading 과 `twistRight` 를 동시에 같은 heading 으로 적용하면 재마운트 시 회전이 중복될 수 있습니다.
diff --git a/apps/api-ground-codes/.env.example b/apps/api-ground-codes/.env.example
new file mode 100644
index 00000000..af5ca60a
--- /dev/null
+++ b/apps/api-ground-codes/.env.example
@@ -0,0 +1,3 @@
+PORT=3000
+CORS_ALLOWED_ORIGINS=https://ground.codes
+API_RATE_LIMIT_PER_MINUTE=600
diff --git a/apps/api-ground-codes/README.md b/apps/api-ground-codes/README.md
index 96597e88..21390478 100644
--- a/apps/api-ground-codes/README.md
+++ b/apps/api-ground-codes/README.md
@@ -37,8 +37,9 @@ The API uses these optimization techniques in several key endpoints:
- `/v1/region/around`: Finds regions near specified coordinates using GeoKDBush spatial indexing
- `/v1/region/info`: Retrieves region information using LevelDB's fast key-value lookups
-- `/encode`: Utilizes the optimized region search to find the nearest region for encoding coordinates
-- `/decode`: Uses efficient region data retrieval when decoding ground codes
+- `/v1/encode`: Utilizes the optimized region search to find the nearest region for encoding coordinates
+- `/v1/decode`: Uses efficient region data retrieval when decoding ground codes
+- `/v1/search`: Resolves encoded Ground Codes, `lat,lng` coordinate pairs, and region names/codes
The API uses the shared `ground-codes` package for encode/decode behavior and
loads `@ground-codes/geoint` embedded databases for region lookup endpoints.
@@ -117,14 +118,17 @@ changes from the same commit.
### 🧩 Core Endpoints
-- `POST /encode`: Encode geographic coordinates to a ground code
-- `POST /decode`: Decode a ground code to geographic coordinates
-- `GET /v1/region/around`: Get regions around specific coordinates
-- `GET /v1/region/info`: Get information about a specific region
+- `POST /v1/encode`: Encode geographic coordinates to a ground code
+- `POST /v1/decode`: Decode a ground code to geographic coordinates
+- `POST /v1/search`: Search by encoded ground code, coordinate pair, or region name
+- `POST /v1/region/around`: Get regions around specific coordinates
+- `POST /v1/region/info`: Get information about a specific region
### 🔧 Utility Endpoints
- `GET /healthz`: Health check endpoint
+- `GET /readyz`: Readiness endpoint for deploy/load-balancer checks
+- `GET /metrics`: Lightweight JSON operational counters
- `GET /swagger`: Swagger UI for API documentation
- `GET /`: Redirects to Swagger documentation
@@ -133,7 +137,7 @@ changes from the same commit.
### 🔄 Encode Coordinates
```bash
-curl -X POST http://localhost:3000/encode \
+curl -X POST http://localhost:3000/v1/encode \
-H "Content-Type: application/json" \
-d '{"lat": 37.422, "lng": 127.024, "regionLevel": 2, "language": "english"}'
```
@@ -147,7 +151,7 @@ Response:
### 🔍 Decode Ground Code
```bash
-curl -X POST http://localhost:3000/decode \
+curl -X POST http://localhost:3000/v1/decode \
-H "Content-Type: application/json" \
-d '{"code": "Seoul-Happy-Tiger", "regionLevel": 2, "language": "english"}'
```
@@ -164,7 +168,7 @@ Response:
### 🌕 Encode Moon or Mars Coordinates
```bash
-curl -X POST http://localhost:3000/encode \
+curl -X POST http://localhost:3000/v1/encode \
-H "Content-Type: application/json" \
-d '{"lat": 8.35, "lng": 30.84, "body": "moon", "regionLevel": 2}'
```
@@ -178,7 +182,7 @@ Response:
Japanese labels are also available:
```bash
-curl -X POST http://localhost:3000/encode \
+curl -X POST http://localhost:3000/v1/encode \
-H "Content-Type: application/json" \
-d '{"lat": 18.6528, "lng": 226.1975, "body": "mars", "regionLevel": 2, "language": "japanese"}'
```
@@ -190,7 +194,7 @@ Response:
```
```bash
-curl -X POST http://localhost:3000/decode \
+curl -X POST http://localhost:3000/v1/decode \
-H "Content-Type: application/json" \
-d '{"code": "Mare Tranquillitatis-...", "body": "moon", "regionLevel": 2}'
```
@@ -209,6 +213,11 @@ The API supports various configuration options:
- 🌐 **Language**: Select from supported languages (English, Korean, Chinese, Japanese)
- 📏 **Precision**: Adjust the precision of encoded locations in meters
- 🪐 **Body**: Select `earth`, `moon`, or `mars` for coordinate conversion and labels
+- 🔐 **CORS**: Set `CORS_ALLOWED_ORIGINS` as a comma-separated production allowlist
+- 🛡️ **Rate Limit**: Set `API_RATE_LIMIT_PER_MINUTE`; use `0` only to disable it intentionally
+
+Search responses include short-lived shared-cache headers so production edges can
+cache common lookups without caching mutable operational checks.
## 📚 Documentation
diff --git a/apps/api-ground-codes/package.json b/apps/api-ground-codes/package.json
index 4b9263f7..6bc8c885 100644
--- a/apps/api-ground-codes/package.json
+++ b/apps/api-ground-codes/package.json
@@ -6,7 +6,9 @@
"scripts": {
"dev": "bun run --watch src/index.ts",
"build": "node scripts/build.mjs",
- "start": "bun run src/index.ts"
+ "start": "bun run src/index.ts",
+ "check-types": "tsc --noEmit",
+ "test": "pnpm --filter ground-codes build && bun test src/*.test.ts"
},
"dependencies": {
"@elysiajs/cors": "^1.2.0",
diff --git a/apps/api-ground-codes/src/app.test.ts b/apps/api-ground-codes/src/app.test.ts
new file mode 100644
index 00000000..f9dfd997
--- /dev/null
+++ b/apps/api-ground-codes/src/app.test.ts
@@ -0,0 +1,281 @@
+import { describe, expect, test } from "bun:test";
+import { createApp } from "./app.js";
+
+const app = createApp();
+const rateLimitedApp = createApp({
+ rateLimit: {
+ max: 1,
+ windowMs: 60_000,
+ },
+});
+
+const postJson = (path: string, body: unknown) =>
+ app.handle(
+ new Request(`http://localhost${path}`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(body),
+ }),
+ );
+
+const get = (path: string) =>
+ app.handle(new Request(`http://localhost${path}`));
+
+describe("Ground Codes API contract", () => {
+ test("serves a readiness endpoint for deployment checks", async () => {
+ const response = await get("/readyz");
+
+ expect(response.status).toBe(200);
+ expect(await response.json()).toMatchObject({
+ status: "ready",
+ service: "api-ground-codes",
+ });
+ });
+
+ test("serves lightweight operational metrics", async () => {
+ await get("/healthz");
+
+ const response = await get("/metrics");
+
+ expect(response.status).toBe(200);
+ const body = await response.json();
+ expect(body.service).toBe("api-ground-codes");
+ expect(body.requests.total).toBeGreaterThan(0);
+ });
+
+ test("serves public API documentation without exposing legacy routes", async () => {
+ const firstPartyDocsResponse = await get("/docs");
+ expect(firstPartyDocsResponse.status).toBe(200);
+ expect(await firstPartyDocsResponse.text()).toContain(
+ "https://api.ground.codes/v1/encode",
+ );
+
+ const docsResponse = await get("/");
+ expect(docsResponse.status).toBe(200);
+ expect(await docsResponse.text()).toContain(
+ "Ground Codes API Documentation",
+ );
+
+ const schemaResponse = await get("/json");
+ expect(schemaResponse.status).toBe(200);
+ const schema = await schemaResponse.json();
+ expect(schema.paths["/v1/encode"]).toBeDefined();
+ expect(schema.paths["/v1/search"]).toBeDefined();
+ expect(schema.paths["/encode"]).toBeUndefined();
+ expect(schema.paths["/search"]).toBeUndefined();
+ expect(schema.paths["/docs"]).toBeUndefined();
+ expect(schema.paths["/{path}"]).toBeUndefined();
+ });
+
+ test("redirects legacy swagger documentation URLs to the public docs", async () => {
+ const docsResponse = await get("/swagger");
+ expect(docsResponse.status).toBe(302);
+ expect(docsResponse.headers.get("location")).toBe("/");
+
+ const schemaResponse = await get("/swagger/json");
+ expect(schemaResponse.status).toBe(302);
+ expect(schemaResponse.headers.get("location")).toBe("/json");
+ });
+
+ test(
+ "serves encode through the versioned v1 route",
+ async () => {
+ const response = await postJson("/v1/encode", {
+ lat: 37.566,
+ lng: 126.978,
+ language: "english",
+ regionLevel: 2,
+ });
+
+ expect(response.status).toBe(200);
+ expect(await response.text()).toMatch(/^Seoul-/);
+ },
+ 90_000,
+ );
+
+ test("search resolves an encoded ground code", async () => {
+ const encodedResponse = await postJson("/v1/encode", {
+ lat: 37.566,
+ lng: 126.978,
+ language: "english",
+ regionLevel: 2,
+ });
+ const code = await encodedResponse.text();
+
+ const response = await postJson("/v1/search", {
+ query: code,
+ language: "english",
+ regionLevel: 2,
+ });
+
+ expect(response.status).toBe(200);
+ const body = await response.json();
+ expect(body.results[0]).toMatchObject({
+ type: "ground-code",
+ label: code,
+ body: "earth",
+ regionLevel: 2,
+ });
+ expect(body.results[0].lat).toBeNumber();
+ expect(body.results[0].lng).toBeNumber();
+ });
+
+ test("search resolves a region name", async () => {
+ const response = await postJson("/v1/search", {
+ query: "Seoul",
+ language: "english",
+ regionLevel: 2,
+ });
+
+ expect(response.status).toBe(200);
+ const body = await response.json();
+ expect(body.results[0]).toMatchObject({
+ type: "region",
+ label: "Seoul",
+ body: "earth",
+ regionLevel: 2,
+ });
+ });
+
+ test("search returns multiple partial region matches with cache headers", async () => {
+ const response = await postJson("/v1/search", {
+ query: "Seo",
+ language: "english",
+ regionLevel: 2,
+ maxResults: 3,
+ });
+
+ expect(response.status).toBe(200);
+ expect(response.headers.get("cache-control")).toContain("s-maxage");
+ const body = await response.json();
+ expect(body.results.length).toBeGreaterThan(1);
+ expect(body.results.length).toBeLessThanOrEqual(3);
+ expect(body.results.some((result: any) => result.label === "Seoul")).toBe(
+ true,
+ );
+ });
+
+ test("search resolves common city aliases", async () => {
+ const response = await postJson("/v1/search", {
+ query: "nyc",
+ language: "english",
+ regionLevel: 2,
+ });
+
+ expect(response.status).toBe(200);
+ const body = await response.json();
+ expect(body.results[0]).toMatchObject({
+ type: "region",
+ label: "New York City",
+ });
+ });
+
+ test(
+ "search falls back to English region names from a localized request",
+ async () => {
+ const response = await postJson("/v1/search", {
+ query: "Seoul",
+ language: "korean",
+ regionLevel: 2,
+ });
+
+ expect(response.status).toBe(200);
+ const body = await response.json();
+ expect(body.results[0]).toMatchObject({
+ type: "region",
+ label: "Seoul",
+ body: "earth",
+ regionLevel: 2,
+ });
+ },
+ 90_000,
+ );
+
+ test("search detects an English encoded code from a localized request", async () => {
+ const encodedResponse = await postJson("/v1/encode", {
+ lat: 37.566,
+ lng: 126.978,
+ language: "english",
+ regionLevel: 2,
+ });
+ const code = await encodedResponse.text();
+
+ const response = await postJson("/v1/search", {
+ query: code,
+ language: "korean",
+ regionLevel: 2,
+ });
+
+ expect(response.status).toBe(200);
+ const body = await response.json();
+ expect(body.results[0]).toMatchObject({
+ type: "ground-code",
+ label: code,
+ body: "earth",
+ regionLevel: 2,
+ });
+ });
+
+ test("loads region lookup data on demand for region endpoints", async () => {
+ const response = await postJson("/v1/region/around", {
+ lat: 37.566,
+ lng: 126.978,
+ language: "english",
+ regionLevel: 2,
+ maxResults: 1,
+ });
+
+ expect(response.status).toBe(200);
+ const body = await response.json();
+ expect(body[0]).toMatchObject({
+ name: "Seoul",
+ });
+ });
+
+ test("returns a structured client error for invalid coordinates", async () => {
+ const response = await postJson("/v1/encode", {
+ lat: 120,
+ lng: 126.978,
+ language: "english",
+ regionLevel: 2,
+ });
+
+ expect(response.status).toBe(400);
+ expect(await response.json()).toMatchObject({
+ error: {
+ code: "INVALID_INPUT",
+ },
+ });
+ });
+
+ test("returns a structured rate-limit error when the limit is exceeded", async () => {
+ const requestBody = {
+ lat: 120,
+ lng: 126.978,
+ language: "english",
+ regionLevel: 2,
+ };
+ await rateLimitedApp.handle(
+ new Request("http://localhost/v1/encode", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(requestBody),
+ }),
+ );
+
+ const response = await rateLimitedApp.handle(
+ new Request("http://localhost/v1/encode", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(requestBody),
+ }),
+ );
+
+ expect(response.status).toBe(429);
+ expect(await response.json()).toMatchObject({
+ error: {
+ code: "RATE_LIMITED",
+ },
+ });
+ });
+});
diff --git a/apps/api-ground-codes/src/app.ts b/apps/api-ground-codes/src/app.ts
index 5fbcb42a..34bcf3dc 100644
--- a/apps/api-ground-codes/src/app.ts
+++ b/apps/api-ground-codes/src/app.ts
@@ -1,28 +1,59 @@
import { Elysia } from "elysia";
-import { healthz } from "./endpoints/healthz.js";
-import { swaggerEndpoint } from "./endpoints/swagger.js";
-import { corsEndpoint } from "./endpoints/cors.js";
+import { healthz, readyz } from "./endpoints/healthz.js";
+import {
+ swaggerEndpoint,
+ swaggerRedirectEndpoint,
+} from "./endpoints/swagger.js";
+import { createCorsEndpoint } from "./endpoints/cors.js";
import { staticPlugin } from "@elysiajs/static";
import { codeEndpoint, rootRedirectEndpoint } from "./endpoints/code.js";
-import { v1Endpoints } from "./endpoints/v1/v1-endpoints.js";
+import { docsEndpoint } from "./endpoints/docs.js";
+import { legacyEndpoints, v1Endpoints } from "./endpoints/v1/v1-endpoints.js";
+import { formatApiError } from "./endpoints/v1/api-error.js";
+import { metricsEndpoint } from "./endpoints/metrics.js";
+import {
+ createRateLimitEndpoint,
+ getDefaultRateLimit,
+ RateLimitOptions,
+} from "./endpoints/rate-limit.js";
+
+interface AppOptions {
+ port?: string | number;
+ rateLimit?: RateLimitOptions | null;
+ corsOrigins?: string[];
+}
/**
- * Create an Elysia application instance that listens on the specified port.
+ * Create an Elysia application instance.
*
- * @param port The port to listen on.
+ * @param portOrOptions Optional port or app options. Omit this in tests to use `app.handle()`.
* @returns An Elysia application instance.
*/
-export const createApp = (port: string | number) =>
- new Elysia()
+export const createApp = (portOrOptions?: string | number | AppOptions) => {
+ const options =
+ typeof portOrOptions === "object" ? portOrOptions : { port: portOrOptions };
+ const rateLimit =
+ "rateLimit" in options ? options.rateLimit : getDefaultRateLimit();
+
+ const app = new Elysia()
+ .onError(({ error, code, set }) => formatApiError(error, code, set))
+ .use(createCorsEndpoint(options.corsOrigins))
+ .use(createRateLimitEndpoint(rateLimit))
+ .use(metricsEndpoint)
+ .use(swaggerRedirectEndpoint)
+ .use(docsEndpoint)
+ .use(swaggerEndpoint)
.use(
staticPlugin({
prefix: "/",
})
)
- .use(corsEndpoint)
- .use(swaggerEndpoint)
.use(healthz)
- .use(codeEndpoint)
- .use(rootRedirectEndpoint)
+ .use(readyz)
.use(v1Endpoints)
- .listen(port);
+ .use(legacyEndpoints)
+ .use(codeEndpoint)
+ .use(rootRedirectEndpoint);
+
+ return options.port === undefined ? app : app.listen(options.port);
+};
diff --git a/apps/api-ground-codes/src/endpoints/cors.ts b/apps/api-ground-codes/src/endpoints/cors.ts
index 38e2a261..f50c5166 100644
--- a/apps/api-ground-codes/src/endpoints/cors.ts
+++ b/apps/api-ground-codes/src/endpoints/cors.ts
@@ -1,19 +1,31 @@
import cors from "@elysiajs/cors";
import Elysia from "elysia";
-export const corsEndpoint = new Elysia()
- .onAfterHandle(({ request, set }) => {
+export const getAllowedOriginsFromEnv = () =>
+ process.env.CORS_ALLOWED_ORIGINS?.split(",")
+ .map((origin) => origin.trim())
+ .filter(Boolean);
+
+export const createCorsEndpoint = (allowedOrigins = getAllowedOriginsFromEnv()) =>
+ new Elysia()
+ .onAfterHandle(({ request, set }) => {
// * Only process CORS requests
- if (request.method !== "OPTIONS") return;
+ if (request.method !== "OPTIONS") return;
- const allowHeader = set.headers["Access-Control-Allow-Headers"];
- if (allowHeader === "*") {
- set.headers["Access-Control-Allow-Headers"] =
- request.headers.get("Access-Control-Request-Headers") ?? "";
- }
- })
- .use(
- cors({
- origin: true,
+ const allowHeader = set.headers["Access-Control-Allow-Headers"];
+ if (allowHeader === "*") {
+ set.headers["Access-Control-Allow-Headers"] =
+ request.headers.get("Access-Control-Request-Headers") ?? "";
+ }
})
- );
+ .use(
+ cors({
+ origin:
+ allowedOrigins && allowedOrigins.length > 0
+ ? (request: Request) => {
+ const origin = request.headers.get("origin");
+ return origin ? allowedOrigins.includes(origin) : false;
+ }
+ : true,
+ }),
+ );
diff --git a/apps/api-ground-codes/src/endpoints/docs.ts b/apps/api-ground-codes/src/endpoints/docs.ts
new file mode 100644
index 00000000..19247bba
--- /dev/null
+++ b/apps/api-ground-codes/src/endpoints/docs.ts
@@ -0,0 +1,143 @@
+import Elysia from "elysia";
+
+const docsHtml = `
+
+
+
+
+ Ground Codes API
+
+
+
+
+
+ Ground Codes API
+ Encode coordinates into human-readable share codes, decode them back to coordinates, and search regions or codes through stable versioned endpoints.
+
+
+
+
+ Base URL
+ Production clients should call https://api.ground.codes and keep all new integrations on /v1/*.
+
+
+ Share URL Format
+
+ - Earth:
https://ground.codes/Seoul-word-word
+ - Moon:
https://ground.codes/moon/word-word-word
+ - Mars:
https://ground.codes/mars/word-word-word
+
+
+
+ Encode
+ POST /v1/encode with latitude, longitude, language, body, and optional precision controls.
+ curl -X POST https://api.ground.codes/v1/encode \\
+ -H "Content-Type: application/json" \\
+ -d '{"lat":37.566,"lng":126.978,"language":"english","body":"earth","regionLevel":2}'
+
+
+ Search
+ Search accepts encoded ground codes, partial region names, and common aliases such as nyc.
+ curl -X POST https://api.ground.codes/v1/search \\
+ -H "Content-Type: application/json" \\
+ -d '{"query":"Seoul-happy-river","language":"english","body":"earth","maxResults":5}'
+
+
+ Operational Endpoints
+
+ /healthz liveness
+ /readyz deployment readiness
+ /metrics request counts and latency
+
+
+
+ Error Shape
+ Client and server errors return a structured {"error":{"code","message","details"}} payload.
+
+
+
+
+`;
+
+export const docsEndpoint = new Elysia().get(
+ "/docs",
+ ({ set }) => {
+ set.headers["cache-control"] = "public, max-age=300";
+ set.headers["content-type"] = "text/html; charset=utf-8";
+ return docsHtml;
+ },
+ {
+ detail: {
+ hide: true,
+ },
+ },
+);
diff --git a/apps/api-ground-codes/src/endpoints/healthz.ts b/apps/api-ground-codes/src/endpoints/healthz.ts
index 27fc271a..c1e50c7d 100644
--- a/apps/api-ground-codes/src/endpoints/healthz.ts
+++ b/apps/api-ground-codes/src/endpoints/healthz.ts
@@ -17,3 +17,30 @@ export const healthz = new Elysia().get(
}),
}
);
+
+export const readyz = new Elysia().get(
+ "/readyz",
+ async ({ set }) => {
+ set.headers["cache-control"] = "no-store";
+
+ return {
+ status: "ready",
+ service: "api-ground-codes",
+ };
+ },
+ {
+ detail: {
+ tags: ["Health"],
+ summary: "Readiness Check",
+ description: "Deployment readiness endpoint",
+ },
+ response: t.Object({
+ status: t.String({
+ example: "ready",
+ }),
+ service: t.String({
+ example: "api-ground-codes",
+ }),
+ }),
+ }
+);
diff --git a/apps/api-ground-codes/src/endpoints/metrics.ts b/apps/api-ground-codes/src/endpoints/metrics.ts
new file mode 100644
index 00000000..4924cdf8
--- /dev/null
+++ b/apps/api-ground-codes/src/endpoints/metrics.ts
@@ -0,0 +1,123 @@
+import Elysia from "elysia";
+import { getRegionLoadMetrics } from "./v1/region/load-region.js";
+
+interface PathMetrics {
+ count: number;
+ totalMs: number;
+ minMs: number;
+ maxMs: number;
+ byStatus: Record;
+}
+
+interface RequestMetricsSnapshot {
+ startedAt: string;
+ total: number;
+ totalMs: number;
+ byPath: Record;
+ routes: Record;
+}
+
+const requestMetrics: RequestMetricsSnapshot = {
+ startedAt: new Date().toISOString(),
+ total: 0,
+ totalMs: 0,
+ byPath: {},
+ routes: {},
+};
+
+const requestStartTimes = new WeakMap();
+
+const getStatusCode = (status: unknown): string => {
+ if (typeof status === "number") return String(status);
+ if (typeof status === "string" && status.length > 0) return status;
+ return "200";
+};
+
+const recordRequest = (
+ request: Request,
+ status: unknown,
+ durationMs: number,
+) => {
+ const pathname = new URL(request.url).pathname;
+ if (pathname === "/metrics") return;
+
+ const roundedDurationMs = Math.max(0, Math.round(durationMs * 100) / 100);
+ const statusCode = getStatusCode(status);
+
+ requestMetrics.total += 1;
+ requestMetrics.totalMs += roundedDurationMs;
+ requestMetrics.byPath[pathname] = (requestMetrics.byPath[pathname] ?? 0) + 1;
+
+ const routeMetrics =
+ requestMetrics.routes[pathname] ??
+ (requestMetrics.routes[pathname] = {
+ count: 0,
+ totalMs: 0,
+ minMs: Number.POSITIVE_INFINITY,
+ maxMs: 0,
+ byStatus: {},
+ });
+
+ routeMetrics.count += 1;
+ routeMetrics.totalMs += roundedDurationMs;
+ routeMetrics.minMs = Math.min(routeMetrics.minMs, roundedDurationMs);
+ routeMetrics.maxMs = Math.max(routeMetrics.maxMs, roundedDurationMs);
+ routeMetrics.byStatus[statusCode] =
+ (routeMetrics.byStatus[statusCode] ?? 0) + 1;
+};
+
+const serializeRoutes = () =>
+ Object.fromEntries(
+ Object.entries(requestMetrics.routes).map(([path, routeMetrics]) => [
+ path,
+ {
+ count: routeMetrics.count,
+ avgMs:
+ routeMetrics.count === 0
+ ? 0
+ : Math.round((routeMetrics.totalMs / routeMetrics.count) * 100) /
+ 100,
+ minMs:
+ routeMetrics.minMs === Number.POSITIVE_INFINITY
+ ? 0
+ : routeMetrics.minMs,
+ maxMs: routeMetrics.maxMs,
+ byStatus: routeMetrics.byStatus,
+ },
+ ]),
+ );
+
+export const metricsEndpoint = new Elysia()
+ .onRequest(({ request }) => {
+ requestStartTimes.set(request, performance.now());
+ })
+ .onAfterHandle({ as: "global" }, ({ request, set }) => {
+ const startedAt = requestStartTimes.get(request) ?? performance.now();
+ recordRequest(request, set.status, performance.now() - startedAt);
+ })
+ .onError({ as: "global" }, ({ request, set, code }) => {
+ const startedAt = requestStartTimes.get(request) ?? performance.now();
+ recordRequest(request, set.status ?? code, performance.now() - startedAt);
+ })
+ .get("/metrics", ({ set }) => {
+ set.headers["cache-control"] = "no-store";
+
+ return {
+ service: "api-ground-codes",
+ startedAt: requestMetrics.startedAt,
+ uptimeSeconds: Math.round(
+ (Date.now() - Date.parse(requestMetrics.startedAt)) / 1000,
+ ),
+ requests: {
+ total: requestMetrics.total,
+ avgMs:
+ requestMetrics.total === 0
+ ? 0
+ : Math.round((requestMetrics.totalMs / requestMetrics.total) * 100) /
+ 100,
+ byPath: requestMetrics.byPath,
+ routes: serializeRoutes(),
+ },
+ regionLoads: getRegionLoadMetrics(),
+ };
+ });
diff --git a/apps/api-ground-codes/src/endpoints/rate-limit.ts b/apps/api-ground-codes/src/endpoints/rate-limit.ts
new file mode 100644
index 00000000..d5e094d6
--- /dev/null
+++ b/apps/api-ground-codes/src/endpoints/rate-limit.ts
@@ -0,0 +1,72 @@
+import Elysia from "elysia";
+
+export interface RateLimitOptions {
+ max: number;
+ windowMs: number;
+}
+
+interface Bucket {
+ count: number;
+ resetAt: number;
+}
+
+const getClientKey = (request: Request) =>
+ request.headers.get("cf-connecting-ip") ??
+ request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
+ "local";
+
+export const getDefaultRateLimit = (): RateLimitOptions | null => {
+ const rawLimit = process.env.API_RATE_LIMIT_PER_MINUTE;
+ const max = rawLimit === undefined ? 600 : Number(rawLimit);
+
+ if (!Number.isFinite(max) || max <= 0) return null;
+ return {
+ max,
+ windowMs: 60_000,
+ };
+};
+
+export const createRateLimitEndpoint = (
+ options: RateLimitOptions | null = getDefaultRateLimit(),
+) => {
+ const buckets = new Map();
+
+ return new Elysia().onBeforeHandle({ as: "global" }, ({ request, set }) => {
+ if (!options || request.method === "OPTIONS") return;
+
+ const pathname = new URL(request.url).pathname;
+ if (pathname === "/healthz") return;
+
+ const now = Date.now();
+ const key = getClientKey(request);
+ const current = buckets.get(key);
+ const bucket =
+ !current || current.resetAt <= now
+ ? { count: 0, resetAt: now + options.windowMs }
+ : current;
+
+ bucket.count += 1;
+ buckets.set(key, bucket);
+
+ const retryAfterSeconds = Math.max(
+ 1,
+ Math.ceil((bucket.resetAt - now) / 1000),
+ );
+ set.headers["X-RateLimit-Limit"] = String(options.max);
+ set.headers["X-RateLimit-Remaining"] = String(
+ Math.max(0, options.max - bucket.count),
+ );
+ set.headers["X-RateLimit-Reset"] = String(bucket.resetAt);
+
+ if (bucket.count > options.max) {
+ set.status = 429;
+ set.headers["Retry-After"] = String(retryAfterSeconds);
+ return {
+ error: {
+ code: "RATE_LIMITED",
+ message: "Too many requests. Try again later.",
+ },
+ };
+ }
+ });
+};
diff --git a/apps/api-ground-codes/src/endpoints/swagger.ts b/apps/api-ground-codes/src/endpoints/swagger.ts
index f8f3b24e..ddd1cea7 100644
--- a/apps/api-ground-codes/src/endpoints/swagger.ts
+++ b/apps/api-ground-codes/src/endpoints/swagger.ts
@@ -1,18 +1,58 @@
import swagger from "@elysiajs/swagger";
+import Elysia, { redirect } from "elysia";
+
+const publicDocPathsToHide = [
+ "/json",
+ "/encode",
+ "/decode",
+ "/search",
+ "/docs",
+ "/region/around",
+ "/region/info",
+ "/{path}",
+];
+
+const scalarReferenceConfig = {
+ theme: "mars" as const,
+ darkMode: true,
+ forceDarkModeState: "dark" as const,
+ hideClientButton: true,
+ hideDarkModeToggle: true,
+ hideModels: true,
+ hiddenClients: true as const,
+ defaultHttpClient: {
+ targetKey: "shell" as const,
+ clientKey: "curl",
+ },
+ customCss: `
+ .scalar-mcp-layer,
+ .scalar-mcp-layer-link,
+ .gitbook-show.scalar-version-number,
+ .references-developer-tools,
+ .agent-button-container,
+ button[id^="headlessui-popover-button-scalar-refs-"],
+ button.bg-sidebar-b-search.whitespace-nowrap,
+ a[href*="scalar.com"],
+ [aria-label="Deploy"],
+ [aria-label="Share"],
+ [aria-label="Configure"],
+ [aria-label="Developer Tools"],
+ [aria-label="Ask AI"] {
+ display: none !important;
+ }
+ `,
+ favicon: "/favicon.ico",
+};
export const swaggerEndpoint = swagger({
path: "/",
- exclude: ["/json"],
- scalarConfig: {
- theme: "mars",
- darkMode: true,
- customCss: ``,
- favicon: "/favicon.ico",
- },
+ exclude: publicDocPathsToHide,
+ scalarConfig: scalarReferenceConfig,
documentation: {
info: {
title: "Ground Codes API Documentation",
- description: "API documentation for Ground Codes SaaS",
+ description:
+ "Production API documentation for Ground Codes. Use the versioned `/v1/*` endpoints for new integrations. Quick start: POST `/v1/encode` with `{ \"lat\": 37.566, \"lng\": 126.978, \"language\": \"english\", \"regionLevel\": 2 }`, then POST `/v1/search` with the returned code or share it as `https://ground.codes/{encoded-code}`. Earth share URLs are code-only; Moon and Mars use `/moon/{encoded-code}` and `/mars/{encoded-code}`.",
version: "1.0.0",
},
tags: [
@@ -34,3 +74,32 @@ export const swaggerEndpoint = swagger({
],
},
});
+
+export const swaggerRedirectEndpoint = new Elysia()
+ .get(
+ "/swagger",
+ () => redirect("/"),
+ {
+ detail: {
+ hide: true,
+ },
+ },
+ )
+ .get(
+ "/swagger/",
+ () => redirect("/"),
+ {
+ detail: {
+ hide: true,
+ },
+ },
+ )
+ .get(
+ "/swagger/json",
+ () => redirect("/json"),
+ {
+ detail: {
+ hide: true,
+ },
+ },
+ );
diff --git a/apps/api-ground-codes/src/endpoints/v1/api-error.ts b/apps/api-ground-codes/src/endpoints/v1/api-error.ts
new file mode 100644
index 00000000..e8dbbfff
--- /dev/null
+++ b/apps/api-ground-codes/src/endpoints/v1/api-error.ts
@@ -0,0 +1,43 @@
+export class ApiInputError extends Error {
+ code = "INVALID_INPUT";
+ status = 400;
+
+ constructor(message: string) {
+ super(message);
+ this.name = "ApiInputError";
+ }
+}
+
+export const formatApiError = (
+ error: unknown,
+ code: string | number,
+ set: { status?: number | string },
+) => {
+ if (error instanceof ApiInputError) {
+ set.status = error.status;
+ return {
+ error: {
+ code: error.code,
+ message: error.message,
+ },
+ };
+ }
+
+ if (code === "VALIDATION") {
+ set.status = 400;
+ return {
+ error: {
+ code: "INVALID_INPUT",
+ message: "Request body does not match the API schema.",
+ },
+ };
+ }
+
+ set.status = 500;
+ return {
+ error: {
+ code: "INTERNAL_ERROR",
+ message: "Unexpected API error.",
+ },
+ };
+};
diff --git a/apps/api-ground-codes/src/endpoints/v1/decode.ts b/apps/api-ground-codes/src/endpoints/v1/decode.ts
index a2a132e2..12ebda36 100644
--- a/apps/api-ground-codes/src/endpoints/v1/decode.ts
+++ b/apps/api-ground-codes/src/endpoints/v1/decode.ts
@@ -1,6 +1,12 @@
import Elysia, { t } from "elysia";
import { CelestialBody, decode, SupportedLanguage } from "ground-codes";
import { supportedLanguages } from "./language.js";
+import {
+ validateBody,
+ validateLanguage,
+ validateRegionLevel,
+ validateSearchQuery,
+} from "./validation.js";
export const v1Decode = new Elysia().post(
"/decode",
@@ -12,10 +18,15 @@ export const v1Decode = new Elysia().post(
body: celestialBody = "earth",
},
}) => {
+ const query = validateSearchQuery(code);
+ const validatedLanguage = validateLanguage(language);
+ const validatedBody = validateBody(celestialBody);
+ validateRegionLevel({ body: validatedBody, regionLevel });
+
return await decode(code, {
regionLevel,
- language: language as SupportedLanguage,
- body: celestialBody as CelestialBody,
+ language: validatedLanguage as SupportedLanguage,
+ body: validatedBody as CelestialBody,
});
},
{
diff --git a/apps/api-ground-codes/src/endpoints/v1/encode.ts b/apps/api-ground-codes/src/endpoints/v1/encode.ts
index c2e4c6e1..f86e239c 100644
--- a/apps/api-ground-codes/src/endpoints/v1/encode.ts
+++ b/apps/api-ground-codes/src/endpoints/v1/encode.ts
@@ -1,6 +1,13 @@
import Elysia, { t } from "elysia";
import { CelestialBody, encode, SupportedLanguage } from "ground-codes";
import { supportedLanguages } from "./language.js";
+import {
+ validateBody,
+ validateCoordinates,
+ validateLanguage,
+ validatePrecisionMeters,
+ validateRegionLevel,
+} from "./validation.js";
export const v1Encode = new Elysia().post(
"/encode",
@@ -14,6 +21,12 @@ export const v1Encode = new Elysia().post(
body: celestialBody = "earth",
},
}) => {
+ validateCoordinates({ lat, lng });
+ const validatedLanguage = validateLanguage(language);
+ const validatedBody = validateBody(celestialBody);
+ validateRegionLevel({ body: validatedBody, regionLevel });
+ validatePrecisionMeters(precisionMeters);
+
const encodeOptions: {
regionLevel: number;
language: SupportedLanguage;
@@ -21,8 +34,8 @@ export const v1Encode = new Elysia().post(
body: CelestialBody;
} = {
regionLevel,
- language: language as SupportedLanguage,
- body: celestialBody as CelestialBody,
+ language: validatedLanguage as SupportedLanguage,
+ body: validatedBody as CelestialBody,
};
if (precisionMeters !== undefined) {
diff --git a/apps/api-ground-codes/src/endpoints/v1/region/around.ts b/apps/api-ground-codes/src/endpoints/v1/region/around.ts
index b8ee73a3..7a23e893 100644
--- a/apps/api-ground-codes/src/endpoints/v1/region/around.ts
+++ b/apps/api-ground-codes/src/endpoints/v1/region/around.ts
@@ -1,6 +1,14 @@
import Elysia, { t } from "elysia";
import { around } from "@ground-codes/geoint";
import { getRegionDatasetName, supportedLanguages } from "../language.js";
+import {
+ validateBody,
+ validateCoordinates,
+ validateLanguage,
+ validateMaxResults,
+ validateRegionLevel,
+} from "../validation.js";
+import { loadRegionDataset } from "./load-region.js";
export const v1RegionAround = new Elysia().post(
"/region/around",
@@ -15,11 +23,18 @@ export const v1RegionAround = new Elysia().post(
body = "earth",
},
}) => {
+ validateCoordinates({ lat, lng });
+ const validatedLanguage = validateLanguage(language);
+ const validatedBody = validateBody(body);
+ validateRegionLevel({ body: validatedBody, regionLevel });
+ validateMaxResults(maxResults);
+
const regionName = getRegionDatasetName({
- body,
- language,
+ body: validatedBody,
+ language: validatedLanguage,
regionLevel,
});
+ await loadRegionDataset(regionName);
return (
await around({
diff --git a/apps/api-ground-codes/src/endpoints/v1/region/info.ts b/apps/api-ground-codes/src/endpoints/v1/region/info.ts
index d09010bd..f3ad244a 100644
--- a/apps/api-ground-codes/src/endpoints/v1/region/info.ts
+++ b/apps/api-ground-codes/src/endpoints/v1/region/info.ts
@@ -1,20 +1,33 @@
import Elysia, { t } from "elysia";
import { info } from "@ground-codes/geoint";
import { getRegionDatasetName, supportedLanguages } from "../language.js";
+import {
+ validateBody,
+ validateLanguage,
+ validateRegionLevel,
+ validateSearchQuery,
+} from "../validation.js";
+import { loadRegionDataset } from "./load-region.js";
export const v1RegionInfo = new Elysia().post(
"/region/info",
async ({
body: { name, language = "english", regionLevel = 2, body = "earth" },
}) => {
+ const query = validateSearchQuery(name);
+ const validatedLanguage = validateLanguage(language);
+ const validatedBody = validateBody(body);
+ validateRegionLevel({ body: validatedBody, regionLevel });
+
const regionName = getRegionDatasetName({
- body,
- language,
+ body: validatedBody,
+ language: validatedLanguage,
regionLevel,
});
+ await loadRegionDataset(regionName);
const data = await info({
- name,
+ name: query,
regionName,
});
diff --git a/apps/api-ground-codes/src/endpoints/v1/region/load-region.ts b/apps/api-ground-codes/src/endpoints/v1/region/load-region.ts
new file mode 100644
index 00000000..40195fd7
--- /dev/null
+++ b/apps/api-ground-codes/src/endpoints/v1/region/load-region.ts
@@ -0,0 +1,73 @@
+import { load } from "@ground-codes/geoint";
+
+const pendingLoads = new Map>();
+const regionLoadMetrics = {
+ started: 0,
+ completed: 0,
+ failed: 0,
+ deduped: 0,
+ lastLoadedAt: null as string | null,
+ byRegion: {} as Record<
+ string,
+ {
+ started: number;
+ completed: number;
+ failed: number;
+ deduped: number;
+ lastDurationMs: number | null;
+ lastLoadedAt: string | null;
+ }
+ >,
+};
+
+const getRegionMetrics = (regionName: string) =>
+ (regionLoadMetrics.byRegion[regionName] ??= {
+ started: 0,
+ completed: 0,
+ failed: 0,
+ deduped: 0,
+ lastDurationMs: null,
+ lastLoadedAt: null,
+ });
+
+export const loadRegionDataset = async (regionName: string) => {
+ let pendingLoad = pendingLoads.get(regionName);
+
+ if (!pendingLoad) {
+ const startedAt = performance.now();
+ const regionMetrics = getRegionMetrics(regionName);
+
+ regionLoadMetrics.started += 1;
+ regionMetrics.started += 1;
+
+ pendingLoad = load([regionName])
+ .then(() => {
+ const loadedAt = new Date().toISOString();
+ const durationMs = Math.round((performance.now() - startedAt) * 100) / 100;
+
+ regionLoadMetrics.completed += 1;
+ regionLoadMetrics.lastLoadedAt = loadedAt;
+ regionMetrics.completed += 1;
+ regionMetrics.lastDurationMs = durationMs;
+ regionMetrics.lastLoadedAt = loadedAt;
+ })
+ .catch((error) => {
+ regionLoadMetrics.failed += 1;
+ regionMetrics.failed += 1;
+ throw error;
+ })
+ .finally(() => pendingLoads.delete(regionName));
+ pendingLoads.set(regionName, pendingLoad);
+ } else {
+ regionLoadMetrics.deduped += 1;
+ getRegionMetrics(regionName).deduped += 1;
+ }
+
+ await pendingLoad;
+};
+
+export const getRegionLoadMetrics = () => ({
+ ...regionLoadMetrics,
+ inFlight: pendingLoads.size,
+ byRegion: { ...regionLoadMetrics.byRegion },
+});
diff --git a/apps/api-ground-codes/src/endpoints/v1/search.ts b/apps/api-ground-codes/src/endpoints/v1/search.ts
new file mode 100644
index 00000000..c9bc81c1
--- /dev/null
+++ b/apps/api-ground-codes/src/endpoints/v1/search.ts
@@ -0,0 +1,214 @@
+import Elysia, { t } from "elysia";
+import {
+ CelestialBody,
+ decode,
+ encode,
+ findRegionsByQuery,
+ SupportedLanguage,
+} from "ground-codes";
+import { supportedLanguages } from "./language.js";
+import {
+ validateBody,
+ validateCoordinates,
+ validateLanguage,
+ validateMaxResults,
+ validateRegionLevel,
+ validateSearchQuery,
+} from "./validation.js";
+
+const coordinatePattern =
+ /^\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*$/;
+
+const parseCoordinates = (query: string) => {
+ const match = query.match(coordinatePattern);
+ if (!match) return null;
+ const lat = Number(match[1]);
+ const lng = Number(match[2]);
+ validateCoordinates({ lat, lng });
+ return { lat, lng };
+};
+
+const getDecodeLanguages = (language: string) => [
+ language,
+ ...supportedLanguages.filter((candidate) => candidate !== language),
+];
+
+const englishLikePattern = /^[\x00-\x7F]+$/;
+const searchAliases: Record = {
+ la: "Los Angeles",
+ lax: "Los Angeles",
+ nyc: "New York City",
+ sf: "San Francisco",
+ sfo: "San Francisco",
+};
+
+const resolveSearchAlias = (query: string) =>
+ searchAliases[query.toLowerCase()] ?? query;
+
+const getRegionSearchLanguages = (language: string, query: string) => {
+ if (language === "english") return ["english"];
+ if (englishLikePattern.test(query)) return ["english", language];
+ return [language, "english"];
+};
+
+export const v1Search = new Elysia().post(
+ "/search",
+ async ({
+ body: {
+ query,
+ regionLevel = 2,
+ language = "english",
+ body: celestialBody = "earth",
+ maxResults = 5,
+ },
+ set,
+ }) => {
+ const normalizedQuery = validateSearchQuery(query);
+ const validatedLanguage = validateLanguage(language);
+ const validatedBody = validateBody(celestialBody);
+ validateRegionLevel({ body: validatedBody, regionLevel });
+ validateMaxResults(maxResults);
+ set.headers["cache-control"] = "public, max-age=60, s-maxage=600";
+
+ const baseResult = {
+ body: validatedBody,
+ regionLevel,
+ };
+
+ const resolvedQuery = resolveSearchAlias(normalizedQuery);
+ const coordinates = parseCoordinates(resolvedQuery);
+ if (coordinates) {
+ const encoded = await encode(coordinates, {
+ regionLevel,
+ language: validatedLanguage as SupportedLanguage,
+ body: validatedBody as CelestialBody,
+ });
+
+ return {
+ query: normalizedQuery,
+ results: [
+ {
+ type: "coordinates",
+ label: encoded,
+ lat: coordinates.lat,
+ lng: coordinates.lng,
+ code: encoded,
+ ...baseResult,
+ },
+ ],
+ };
+ }
+
+ if (resolvedQuery.includes("-")) {
+ for (const candidateLanguage of getDecodeLanguages(validatedLanguage)) {
+ try {
+ const decoded = await decode(resolvedQuery, {
+ regionLevel,
+ language: candidateLanguage as SupportedLanguage,
+ body: validatedBody as CelestialBody,
+ });
+
+ return {
+ query: normalizedQuery,
+ results: [
+ {
+ type: "ground-code",
+ label: resolvedQuery,
+ lat: decoded.lat,
+ lng: decoded.lng,
+ code: resolvedQuery,
+ ...baseResult,
+ },
+ ],
+ };
+ } catch {
+ // Fall through to other languages and then region-name search.
+ }
+ }
+ }
+
+ const regions: Awaited> = [];
+ for (const candidateLanguage of getRegionSearchLanguages(
+ validatedLanguage,
+ resolvedQuery,
+ )) {
+ const candidateRegions = await findRegionsByQuery(resolvedQuery, {
+ regionLevel,
+ language: candidateLanguage as SupportedLanguage,
+ body: validatedBody as CelestialBody,
+ maxResults: maxResults - regions.length,
+ });
+ regions.push(...candidateRegions);
+ if (regions.length >= maxResults) break;
+ }
+
+ return {
+ query: normalizedQuery,
+ results: regions.map((region) => ({
+ type: "region",
+ label: region.name ?? normalizedQuery,
+ lat: region.lat,
+ lng: region.lng,
+ code: region.code,
+ body: region.body ?? validatedBody,
+ regionLevel: region.regionLevel ?? regionLevel,
+ })),
+ };
+ },
+ {
+ detail: {
+ tags: ["Code"],
+ summary: "Search ground codes, coordinates, or region names",
+ description:
+ "Search an encoded Ground Code, a `lat,lng` coordinate pair, or a known region name/code.",
+ },
+ body: t.Object({
+ query: t.String({
+ example: "Seoul-Happy-Tiger",
+ description: "Ground Code, coordinate pair, region name, or region code",
+ }),
+ regionLevel: t.Optional(
+ t.Number({
+ default: 2,
+ example: 2,
+ }),
+ ),
+ language: t.Optional(
+ t.String({
+ default: "english",
+ example: "english",
+ enum: supportedLanguages,
+ }),
+ ),
+ body: t.Optional(
+ t.String({
+ default: "earth",
+ example: "earth",
+ enum: ["earth", "moon", "mars"],
+ }),
+ ),
+ maxResults: t.Optional(
+ t.Number({
+ default: 5,
+ minimum: 1,
+ maximum: 25,
+ example: 5,
+ }),
+ ),
+ }),
+ response: t.Object({
+ query: t.String(),
+ results: t.Array(
+ t.Object({
+ type: t.String(),
+ label: t.String(),
+ lat: t.Number(),
+ lng: t.Number(),
+ code: t.Optional(t.String()),
+ body: t.String(),
+ regionLevel: t.Number(),
+ }),
+ ),
+ }),
+ },
+);
diff --git a/apps/api-ground-codes/src/endpoints/v1/v1-endpoints.ts b/apps/api-ground-codes/src/endpoints/v1/v1-endpoints.ts
index f6bcdc13..e273b222 100644
--- a/apps/api-ground-codes/src/endpoints/v1/v1-endpoints.ts
+++ b/apps/api-ground-codes/src/endpoints/v1/v1-endpoints.ts
@@ -3,9 +3,16 @@ import { v1Encode } from "./encode";
import { v1Decode } from "./decode";
import { v1RegionAround } from "./region/around";
import { v1RegionInfo } from "./region/info";
+import { v1Search } from "./search";
-export const v1Endpoints = new Elysia()
+const endpointGroup = () =>
+ new Elysia()
.use(v1Encode)
.use(v1Decode)
+ .use(v1Search)
.use(v1RegionAround)
.use(v1RegionInfo);
+
+export const v1Endpoints = new Elysia({ prefix: "/v1" }).use(endpointGroup());
+
+export const legacyEndpoints = endpointGroup();
diff --git a/apps/api-ground-codes/src/endpoints/v1/validation.ts b/apps/api-ground-codes/src/endpoints/v1/validation.ts
new file mode 100644
index 00000000..54d9c718
--- /dev/null
+++ b/apps/api-ground-codes/src/endpoints/v1/validation.ts
@@ -0,0 +1,83 @@
+import { ApiInputError } from "./api-error.js";
+import { supportedLanguages } from "./language.js";
+
+const supportedBodies = ["earth", "moon", "mars"] as const;
+
+export type ApiLanguage = (typeof supportedLanguages)[number];
+export type ApiBody = (typeof supportedBodies)[number];
+
+const isFiniteNumber = (value: number) => Number.isFinite(value);
+
+export const validateLanguage = (language: string): ApiLanguage => {
+ if (supportedLanguages.includes(language as ApiLanguage)) {
+ return language as ApiLanguage;
+ }
+
+ throw new ApiInputError(
+ `Unsupported language "${language}". Use one of: ${supportedLanguages.join(", ")}.`,
+ );
+};
+
+export const validateBody = (body: string): ApiBody => {
+ if (supportedBodies.includes(body as ApiBody)) return body as ApiBody;
+
+ throw new ApiInputError(
+ `Unsupported body "${body}". Use one of: ${supportedBodies.join(", ")}.`,
+ );
+};
+
+export const validateCoordinates = ({
+ lat,
+ lng,
+}: {
+ lat: number;
+ lng: number;
+}) => {
+ if (!isFiniteNumber(lat) || lat < -90 || lat > 90) {
+ throw new ApiInputError("Latitude must be a finite number between -90 and 90.");
+ }
+
+ if (!isFiniteNumber(lng) || lng < -360 || lng > 360) {
+ throw new ApiInputError(
+ "Longitude must be a finite number between -360 and 360.",
+ );
+ }
+};
+
+export const validatePrecisionMeters = (precisionMeters?: number) => {
+ if (precisionMeters === undefined) return;
+ if (!isFiniteNumber(precisionMeters) || precisionMeters <= 0) {
+ throw new ApiInputError("precisionMeters must be a positive number.");
+ }
+};
+
+export const validateRegionLevel = ({
+ body,
+ regionLevel,
+}: {
+ body: ApiBody;
+ regionLevel: number;
+}) => {
+ const allowed =
+ body === "moon" ? [2] : body === "mars" ? [2, 3] : [1, 2, 3];
+ if (!Number.isInteger(regionLevel) || !allowed.includes(regionLevel)) {
+ throw new ApiInputError(
+ `${body} supports regionLevel values: ${allowed.join(", ")}.`,
+ );
+ }
+};
+
+export const validateMaxResults = (maxResults: number) => {
+ if (!Number.isInteger(maxResults) || maxResults < 1 || maxResults > 100) {
+ throw new ApiInputError("maxResults must be an integer between 1 and 100.");
+ }
+};
+
+export const validateSearchQuery = (query: string) => {
+ const trimmed = query.trim();
+ if (!trimmed) throw new ApiInputError("query is required.");
+ if (trimmed.length > 160) {
+ throw new ApiInputError("query must be 160 characters or fewer.");
+ }
+ return trimmed;
+};
diff --git a/apps/api-ground-codes/src/index.ts b/apps/api-ground-codes/src/index.ts
index 6063a919..552d9b4f 100644
--- a/apps/api-ground-codes/src/index.ts
+++ b/apps/api-ground-codes/src/index.ts
@@ -1,5 +1,4 @@
import { createApp } from "./app.js";
-import { load } from "@ground-codes/geoint";
void (async function () {
console.clear();
@@ -7,9 +6,6 @@ void (async function () {
// * Print initialization message
console.log("🚀 Initializing Ground Codes API server...");
- // * Load region data
- await load();
-
// * Create the app
const app = createApp(process.env.PORT ?? 3000);
diff --git a/apps/web/.env.example b/apps/web/.env.example
new file mode 100644
index 00000000..e555631e
--- /dev/null
+++ b/apps/web/.env.example
@@ -0,0 +1,8 @@
+NEXT_PUBLIC_GROUND_CODES_API_URL=https://api.ground.codes
+NEXT_PUBLIC_GOOGLE_MAPS_API_KEY=
+NEXT_PUBLIC_GOOGLE_MAPS_ROADMAP_ID=
+GOOGLE_MAPS_NODEJS_API_KEY=
+OPENWEATHER_API_KEY=
+NEXT_PUBLIC_CESIUM_ION_TOKEN=
+NEXT_PUBLIC_CESIUM_MOON_ASSET_ID=
+NEXT_PUBLIC_CESIUM_MARS_ASSET_ID=
diff --git a/apps/web/.gitignore b/apps/web/.gitignore
index 5ef6a520..7b8da95f 100644
--- a/apps/web/.gitignore
+++ b/apps/web/.gitignore
@@ -32,6 +32,7 @@ yarn-error.log*
# env files (can opt-in for committing if needed)
.env*
+!.env.example
# vercel
.vercel
diff --git a/apps/web/README.md b/apps/web/README.md
index 331c6d10..b713a576 100644
--- a/apps/web/README.md
+++ b/apps/web/README.md
@@ -64,7 +64,10 @@ Ground.codes web application is an interactive map service utilizing the Google
- 🌓 Improved theme to fix visibility issues of buildings in dark theme
- 🌐 Implemented multilingual display of place types
- 🌕 Added Earth/Moon/Mars map switching with direct URL support
-- 🗺️ Added Moon/Mars default planetary basemaps
+ - 🗺️ Added Moon/Mars default planetary basemaps
+ - 🔎 Added Ground Code and region-name search through the API
+ - 🔗 Added copy/share actions for selected Ground Codes
+ - 🧭 Added a no-key fallback surface so local/dev builds do not show Google Maps error modals
### 🪐 Planetary Map Mode
@@ -94,6 +97,15 @@ Mars, with grid degree spacing adjusted for each body's radius. Ground code
region prefixes and word payloads are available in English, Korean, Chinese,
and Japanese.
+Share URLs use encoded Ground Codes as the canonical address:
+
+- Earth: `/서울-안방`
+- Moon: `/moon/Mare%20Tranquillitatis-...`
+- Mars: `/mars/Olympus%20Mons-...`
+
+Query-string URLs remain useful for development and deep map state, but the app
+share action emits the canonical code-only URL.
+
Moon and Mars also include an experimental Cesium 3D globe mode. Without Cesium
ion configuration it falls back to a 3D ellipsoid with the current USGS imagery.
For real Moon/Mars terrain tiles, configure a Cesium ion token and the relevant
@@ -119,6 +131,7 @@ The project requires the following environment variables to be set in a `.env.lo
```
# Required
+NEXT_PUBLIC_GROUND_CODES_API_URL=https://api.ground.codes
NEXT_PUBLIC_GOOGLE_MAPS_API_KEY=
# Optional
@@ -130,6 +143,11 @@ GOOGLE_MAPS_NODEJS_API_KEY=
OPENWEATHER_API_KEY=
```
+The map still supports Ground Code search when Google Maps credentials are not
+configured. Weather and air-quality widgets are optional; if their server-side
+keys are missing, the widget is hidden instead of surfacing a user-facing API
+error.
+
### 🔐 How to obtain API keys:
- **NEXT_PUBLIC_GOOGLE_MAPS_API_KEY** (Required):
diff --git a/apps/web/app/[...share]/page.tsx b/apps/web/app/[...share]/page.tsx
new file mode 100644
index 00000000..6fc90fd0
--- /dev/null
+++ b/apps/web/app/[...share]/page.tsx
@@ -0,0 +1,11 @@
+"use client";
+
+import GoogleMap from "@/components/google-map";
+
+export default function SharedCodePage() {
+ return (
+
+
+
+ );
+}
diff --git a/apps/web/app/api/weather-data/route.test.ts b/apps/web/app/api/weather-data/route.test.ts
new file mode 100644
index 00000000..8ca1d131
--- /dev/null
+++ b/apps/web/app/api/weather-data/route.test.ts
@@ -0,0 +1,68 @@
+import { afterEach, describe, expect, test } from "bun:test";
+import { NextRequest } from "next/server";
+import { POST } from "./route";
+
+const originalGoogleKey = process.env.GOOGLE_MAPS_NODEJS_API_KEY;
+const originalOpenWeatherKey = process.env.OPENWEATHER_API_KEY;
+
+afterEach(() => {
+ if (originalGoogleKey === undefined) {
+ delete process.env.GOOGLE_MAPS_NODEJS_API_KEY;
+ } else {
+ process.env.GOOGLE_MAPS_NODEJS_API_KEY = originalGoogleKey;
+ }
+
+ if (originalOpenWeatherKey === undefined) {
+ delete process.env.OPENWEATHER_API_KEY;
+ } else {
+ process.env.OPENWEATHER_API_KEY = originalOpenWeatherKey;
+ }
+});
+
+describe("weather data API", () => {
+ test("reports missing optional API keys without a server error", async () => {
+ delete process.env.GOOGLE_MAPS_NODEJS_API_KEY;
+ delete process.env.OPENWEATHER_API_KEY;
+
+ const response = await POST(
+ new NextRequest("http://localhost/api/weather-data", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({
+ lat: 37.566,
+ lng: 126.978,
+ language: "ko",
+ }),
+ }),
+ );
+
+ expect(response.status).toBe(200);
+ expect(await response.json()).toEqual({
+ airQuality: null,
+ weather: null,
+ unavailable: true,
+ });
+ });
+
+ test("accepts zero latitude and longitude as valid coordinates", async () => {
+ delete process.env.GOOGLE_MAPS_NODEJS_API_KEY;
+ delete process.env.OPENWEATHER_API_KEY;
+
+ const response = await POST(
+ new NextRequest("http://localhost/api/weather-data", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({
+ lat: 0,
+ lng: 0,
+ language: "en",
+ }),
+ }),
+ );
+
+ expect(response.status).toBe(200);
+ expect(await response.json()).toMatchObject({
+ unavailable: true,
+ });
+ });
+});
diff --git a/apps/web/app/api/weather-data/route.ts b/apps/web/app/api/weather-data/route.ts
index 7ca42f78..d1df9a05 100644
--- a/apps/web/app/api/weather-data/route.ts
+++ b/apps/web/app/api/weather-data/route.ts
@@ -62,8 +62,14 @@ export interface CombinedWeatherData {
airQuality: AirQualityData | null;
weather: WeatherData | null;
error?: string;
+ unavailable?: boolean;
}
+type WeatherEnvKey = "GOOGLE_MAPS_NODEJS_API_KEY" | "OPENWEATHER_API_KEY";
+
+const getOptionalWeatherEnv = (key: WeatherEnvKey): string | undefined =>
+ process.env[key];
+
/**
* API route to fetch both air quality and weather data
* This keeps the API keys secure on the server side
@@ -73,7 +79,7 @@ export async function POST(request: NextRequest) {
const body = await request.json();
const { lat, lng, language = "en" } = body;
- if (!lat || !lng) {
+ if (!Number.isFinite(lat) || !Number.isFinite(lng)) {
return NextResponse.json(
{ error: "Latitude and longitude are required" },
{ status: 400 }
@@ -81,15 +87,15 @@ export async function POST(request: NextRequest) {
}
// Access API keys securely from server environment
- const googleMapsApiKey = process.env.GOOGLE_MAPS_NODEJS_API_KEY;
- const openWeatherApiKey = process.env.OPENWEATHER_API_KEY;
+ const googleMapsApiKey = getOptionalWeatherEnv("GOOGLE_MAPS_NODEJS_API_KEY");
+ const openWeatherApiKey = getOptionalWeatherEnv("OPENWEATHER_API_KEY");
if (!googleMapsApiKey || !openWeatherApiKey) {
- console.error("API keys are missing");
- return NextResponse.json(
- { error: "API configuration error" },
- { status: 500 }
- );
+ return NextResponse.json({
+ airQuality: null,
+ weather: null,
+ unavailable: true,
+ });
}
// Map the language code to supported languages for each API
diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx
index 1601bbf4..7cef1cac 100644
--- a/apps/web/app/layout.tsx
+++ b/apps/web/app/layout.tsx
@@ -15,7 +15,26 @@ const spaceMono = Space_Mono({
export const metadata: Metadata = {
title: "Ground Codes",
- description: "Ground code come to exact earth (+mars) address",
+ description:
+ "Share precise Earth, Moon, and Mars locations with short memorable Ground Codes.",
+ metadataBase: new URL("https://ground.codes"),
+ openGraph: {
+ title: "Ground Codes",
+ description:
+ "Search, copy, and share precise locations as short memorable codes.",
+ url: "https://ground.codes",
+ siteName: "Ground Codes",
+ type: "website",
+ },
+ twitter: {
+ card: "summary",
+ title: "Ground Codes",
+ description:
+ "Search, copy, and share precise locations as short memorable codes.",
+ },
+ alternates: {
+ canonical: "https://ground.codes",
+ },
};
export const viewport: Viewport = {
diff --git a/apps/web/app/robots.ts b/apps/web/app/robots.ts
new file mode 100644
index 00000000..b2e85d0e
--- /dev/null
+++ b/apps/web/app/robots.ts
@@ -0,0 +1,12 @@
+import type { MetadataRoute } from "next";
+
+export default function robots(): MetadataRoute.Robots {
+ return {
+ rules: {
+ userAgent: "*",
+ allow: "/",
+ disallow: ["/api/"],
+ },
+ sitemap: "https://ground.codes/sitemap.xml",
+ };
+}
diff --git a/apps/web/app/sitemap.ts b/apps/web/app/sitemap.ts
new file mode 100644
index 00000000..08af0e14
--- /dev/null
+++ b/apps/web/app/sitemap.ts
@@ -0,0 +1,12 @@
+import type { MetadataRoute } from "next";
+
+export default function sitemap(): MetadataRoute.Sitemap {
+ return [
+ {
+ url: "https://ground.codes",
+ lastModified: new Date(),
+ changeFrequency: "weekly",
+ priority: 1,
+ },
+ ];
+}
diff --git a/apps/web/components/google-map/context/use-map-context.ts b/apps/web/components/google-map/context/use-map-context.ts
index 33b0904d..483c521f 100644
--- a/apps/web/components/google-map/context/use-map-context.ts
+++ b/apps/web/components/google-map/context/use-map-context.ts
@@ -2,7 +2,6 @@ import { useState, useCallback, useEffect } from "react";
import { useGridSystem } from "@/lib/grid-system";
import { useMapCoordinates } from "../hooks/use-map-coordinates";
import { useGeolocation } from "../hooks/use-geolocation";
-import { googleMapDarkTheme } from "@/lib/map/google-map-theme";
import { Coordinates, MapContextType, defaultCenter } from "./types";
export const useMapContextState = (): MapContextType => {
diff --git a/apps/web/components/google-map/hooks/use-air-quality.ts b/apps/web/components/google-map/hooks/use-air-quality.ts
index eb1da23e..5cb7d78d 100644
--- a/apps/web/components/google-map/hooks/use-air-quality.ts
+++ b/apps/web/components/google-map/hooks/use-air-quality.ts
@@ -42,6 +42,7 @@ export const useAirQuality = (
const [weatherData, setWeatherData] = useState(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
+ const [isUnavailable, setIsUnavailable] = useState(false);
// Debounce coordinates to avoid excessive API calls
const debouncedCoordinates = useDebounce(coordinates, debounceMs);
@@ -66,6 +67,7 @@ export const useAirQuality = (
setIsLoading(true);
setError(null);
+ setIsUnavailable(false);
try {
// Fetch both air quality and weather data using the combined API endpoint
@@ -86,6 +88,13 @@ export const useAirQuality = (
const data: CombinedWeatherData = await response.json();
+ if (data.unavailable) {
+ setAirQuality(null);
+ setWeatherData(null);
+ setIsUnavailable(true);
+ return;
+ }
+
// Handle air quality data
if (data.airQuality) {
setAirQuality(data.airQuality);
@@ -127,6 +136,7 @@ export const useAirQuality = (
weatherIcon,
isLoading,
error,
+ isUnavailable,
weatherData,
};
};
diff --git a/apps/web/components/google-map/hooks/use-device-orientation.ts b/apps/web/components/google-map/hooks/use-device-orientation.ts
index 42f738ca..72effd6e 100644
--- a/apps/web/components/google-map/hooks/use-device-orientation.ts
+++ b/apps/web/components/google-map/hooks/use-device-orientation.ts
@@ -11,6 +11,17 @@ interface UseDeviceOrientationReturn extends DeviceOrientationState {
requestPermission: () => Promise;
}
+type DeviceOrientationPermission = "granted" | "denied" | "prompt";
+
+interface DeviceOrientationEventWithCompass extends DeviceOrientationEvent {
+ compassHeading?: number;
+ webkitCompassHeading?: number;
+}
+
+interface DeviceOrientationEventConstructorWithPermission {
+ requestPermission?: () => Promise;
+}
+
export const useDeviceOrientation = (): UseDeviceOrientationReturn => {
const [orientationState, setOrientationState] =
useState({
@@ -61,16 +72,16 @@ export const useDeviceOrientation = (): UseDeviceOrientationReturn => {
// Handle device orientation event with debouncing and threshold
const handleOrientation = useCallback(
- (event: DeviceOrientationEvent) => {
+ (event: DeviceOrientationEventWithCompass) => {
let newHeading: number | null = null;
// For devices that support deviceorientationabsolute
- if ("compassHeading" in event) {
- newHeading = (event as any).compassHeading;
+ if (typeof event.compassHeading === "number") {
+ newHeading = event.compassHeading;
}
// For devices that support deviceorientation with webkitCompassHeading
- else if ("webkitCompassHeading" in event) {
- newHeading = (event as any).webkitCompassHeading;
+ else if (typeof event.webkitCompassHeading === "number") {
+ newHeading = event.webkitCompassHeading;
}
// For devices that only provide alpha value (relative to initial position)
else if (event.alpha !== null) {
@@ -147,13 +158,12 @@ export const useDeviceOrientation = (): UseDeviceOrientationReturn => {
}
// For iOS devices
- if (
- typeof (DeviceOrientationEvent as any).requestPermission === "function"
- ) {
+ const orientationEvent =
+ window.DeviceOrientationEvent as unknown as DeviceOrientationEventConstructorWithPermission;
+
+ if (typeof orientationEvent.requestPermission === "function") {
try {
- const permission = await (
- DeviceOrientationEvent as any
- ).requestPermission();
+ const permission = await orientationEvent.requestPermission();
const granted = permission === "granted";
if (granted) {
@@ -224,8 +234,10 @@ export const useDeviceOrientation = (): UseDeviceOrientationReturn => {
useEffect(() => {
const checkSupport = () => {
const supported = window.DeviceOrientationEvent !== undefined;
+ const orientationEvent =
+ window.DeviceOrientationEvent as unknown as DeviceOrientationEventConstructorWithPermission;
const permissionAPI =
- typeof (DeviceOrientationEvent as any).requestPermission === "function";
+ typeof orientationEvent.requestPermission === "function";
setOrientationState((prev) => ({
...prev,
diff --git a/apps/web/components/google-map/hooks/use-geolocation.ts b/apps/web/components/google-map/hooks/use-geolocation.ts
index 6d5f9844..cc18d607 100644
--- a/apps/web/components/google-map/hooks/use-geolocation.ts
+++ b/apps/web/components/google-map/hooks/use-geolocation.ts
@@ -105,6 +105,26 @@ export const useGeolocation = (
}
}, []);
+ // Update location function
+ const updateUserLocation = useCallback(
+ (position: GeolocationPosition) => {
+ const newLocation = {
+ lat: position.coords.latitude,
+ lng: position.coords.longitude,
+ accuracy: position.coords.accuracy,
+ heading:
+ position.coords.heading !== null &&
+ position.coords.heading !== undefined
+ ? position.coords.heading
+ : userLocation?.heading || null,
+ };
+
+ setUserLocation(newLocation);
+ return newLocation;
+ },
+ [userLocation],
+ );
+
// Start watching position
const startWatchingPosition = useCallback(() => {
if (navigator.geolocation) {
@@ -113,9 +133,6 @@ export const useGeolocation = (
// Update loading ref directly instead of state
isLoadingRef.current = true;
- // Maintain previous location info (include heading)
- const prevLocation = userLocation;
-
const watchId = navigator.geolocation.watchPosition(
(position) => {
// Update loading ref directly
@@ -135,44 +152,27 @@ export const useGeolocation = (
// Update location
setUserLocation(newLocation);
},
- (error) => {
+ () => {
// Update loading ref directly
isLoadingRef.current = false;
// setError(error.message);
},
{
- enableHighAccuracy: true,
- timeout: 10000,
- maximumAge: 1000,
+ enableHighAccuracy,
+ timeout,
+ maximumAge,
},
);
geolocationRequestIdRef.current = watchId;
}
- }, [cancelGeolocationRequest, userLocation]);
-
- // Update location function
- const updateUserLocation = useCallback(
- (position: GeolocationPosition) => {
- // Create new location info
- const newLocation = {
- lat: position.coords.latitude,
- lng: position.coords.longitude,
- accuracy: position.coords.accuracy,
- // Heading processing (update if new heading exists, otherwise maintain previous heading)
- heading:
- position.coords.heading !== null &&
- position.coords.heading !== undefined
- ? position.coords.heading
- : userLocation?.heading || null,
- };
-
- // Update location
- setUserLocation(newLocation);
- return newLocation;
- },
- [userLocation],
- );
+ }, [
+ cancelGeolocationRequest,
+ enableHighAccuracy,
+ maximumAge,
+ timeout,
+ updateUserLocation,
+ ]);
const getUserLocation = useCallback(() => {
if (navigator.geolocation) {
@@ -222,19 +222,22 @@ export const useGeolocation = (
isLoadingRef.current = false;
},
{
- enableHighAccuracy: true,
- timeout: 5000,
- maximumAge: 0,
+ enableHighAccuracy,
+ timeout,
+ maximumAge,
},
);
}
}, [
cancelGeolocationRequest,
+ enableHighAccuracy,
isTrackingMode,
map,
+ maximumAge,
onPositionUpdate,
setCenter,
setSelectedArea,
+ timeout,
updateUserLocation,
userLocationLoaded,
]);
diff --git a/apps/web/components/google-map/hooks/use-location-tracking.ts b/apps/web/components/google-map/hooks/use-location-tracking.ts
index 440c5fda..8df4fe7b 100644
--- a/apps/web/components/google-map/hooks/use-location-tracking.ts
+++ b/apps/web/components/google-map/hooks/use-location-tracking.ts
@@ -5,7 +5,6 @@ import { useDeviceOrientation } from "./use-device-orientation";
interface UseLocationTrackingProps {
map: google.maps.Map | null;
onLocationUpdate: (location: Coordinates) => void;
- isTrackingMode?: boolean;
}
/**
@@ -15,7 +14,6 @@ interface UseLocationTrackingProps {
export const useLocationTracking = ({
map,
onLocationUpdate,
- isTrackingMode = false,
}: UseLocationTrackingProps) => {
// Default value is OFF
const [locationMode, setLocationMode] = useState(
@@ -34,11 +32,6 @@ export const useLocationTracking = ({
[]
);
- // Loading state setter function
- const setIsLoadingLocationRef = useCallback((loading: boolean) => {
- isLoadingLocationRef.current = loading;
- }, []);
-
// Watch position ID reference
const watchPositionIdRef = useRef(null);
@@ -221,7 +214,14 @@ export const useLocationTracking = ({
// Release loading state (direct ref update)
isLoadingLocationRef.current = false;
- }, [locationMode]);
+ }, []);
+
+ /**
+ * Set location mode directly
+ */
+ const setLocationModeDirectly = useCallback((mode: LocationMode) => {
+ setLocationMode(mode);
+ }, []);
/**
* Toggle location mode function
@@ -242,17 +242,7 @@ export const useLocationTracking = ({
setLocationModeDirectly(LocationMode.OFF);
break;
}
- }, [locationMode]);
-
- /**
- * Set location mode directly
- */
- const setLocationModeDirectly = useCallback(
- (mode: LocationMode) => {
- setLocationMode(mode);
- },
- [locationMode]
- );
+ }, [locationMode, setLocationModeDirectly]);
/**
* Update tracking state when location mode changes
diff --git a/apps/web/components/google-map/hooks/use-map-container.ts b/apps/web/components/google-map/hooks/use-map-container.ts
index 4e473fce..c863f0a6 100644
--- a/apps/web/components/google-map/hooks/use-map-container.ts
+++ b/apps/web/components/google-map/hooks/use-map-container.ts
@@ -8,6 +8,12 @@ import { useLanguage } from "./use-language";
import { useI18n } from "@/lib/i18n/i18n-context";
import { useLocationTracking } from "./use-location-tracking";
import { Coordinates, LocationMode } from "../types";
+import {
+ GroundCodeSearchResult,
+ searchGroundCodes,
+} from "@/lib/code/ground-codes";
+import { parseGroundCodeSharePath } from "@/lib/code/share-url";
+import { getGroundCodeLanguage } from "@/lib/i18n/ground-code-language";
import {
CelestialBody,
createPlanetaryMapType,
@@ -90,6 +96,9 @@ const getInitialMapType = (body: CelestialBody): EarthMapType => {
const getInitialBody = (): CelestialBody => {
if (typeof window === "undefined") return "earth";
+ const sharedCode = parseGroundCodeSharePath(window.location.pathname);
+ if (sharedCode) return sharedCode.body;
+
return parseCelestialBody(
new URLSearchParams(window.location.search).get("body"),
);
@@ -128,7 +137,7 @@ const getInitialZoom = (body: CelestialBody): number => {
export const useMapContainer = () => {
const { getUserLanguage } = useLanguage();
- const { isChangingLanguage } = useI18n();
+ const { isChangingLanguage, locale } = useI18n();
const [body, setBody] = useState(getInitialBody);
const isEarth = body === "earth";
const [planetaryLayerId, setPlanetaryLayerId] = useState(() =>
@@ -137,14 +146,18 @@ export const useMapContainer = () => {
// Get language from cookie for Google Maps API
const mapLanguage = isChangingLanguage ? "en" : getUserLanguage();
+ const googleMapsApiKey =
+ process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY?.trim() ?? "";
+ const hasGoogleMapsApiKey = googleMapsApiKey.length > 0;
- // Load Google Maps API
- const { isLoaded } = useJsApiLoader({
+ // Load Google Maps API. The hook must be called unconditionally.
+ const { isLoaded: isGoogleMapsLoaded } = useJsApiLoader({
id: "google-map-script",
- googleMapsApiKey: process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY!,
- language: mapLanguage, // Language code for Google Maps API
- libraries, // Use the constant libraries array
+ googleMapsApiKey: googleMapsApiKey || "missing-google-maps-api-key",
+ language: mapLanguage,
+ libraries,
});
+ const isLoaded = hasGoogleMapsApiKey ? isGoogleMapsLoaded : true;
// Map state
const [map, setMap] = useState(null);
@@ -182,6 +195,15 @@ export const useMapContainer = () => {
// Search result state
const [searchedPlace, setSearchedPlace] =
useState(null);
+ const [isGroundSearchLoading, setIsGroundSearchLoading] = useState(false);
+ const [groundSearchError, setGroundSearchError] = useState(
+ null,
+ );
+ const [groundSearchResults, setGroundSearchResults] = useState<
+ GroundCodeSearchResult[]
+ >([]);
+ const [initialGroundSearchQuery, setInitialGroundSearchQuery] = useState("");
+ const restoredSharePathRef = useRef(false);
const [searchMarker, setSearchMarker] = useState(
null,
);
@@ -197,13 +219,10 @@ export const useMapContainer = () => {
locationMode,
setLocationMode,
isLoadingLocation,
- isTrackingLocation,
startLocationTracking,
stopLocationTracking,
- toggleLocationMode,
} = useLocationTracking({
map,
- isTrackingMode: true,
onLocationUpdate: useCallback((location) => {
if (location) {
// Explicitly include heading information in state update
@@ -225,15 +244,12 @@ export const useMapContainer = () => {
if (isLoadingLocation !== undefined) {
setIsLoading(isLoadingLocation);
}
- // Empty dependency array to run only once on mount
- }, []);
+ }, [isLoadingLocation]);
// Get user location using the hook
const {
- userLocation: geoLocation,
getUserLocation: getGeoLocation,
cancelGeolocationRequest,
- requestOrientationPermission,
} = useGeolocation(map, setCenter, setSelectedArea, {
autoGetLocation: isEarth,
initialFetch: isEarth,
@@ -295,6 +311,7 @@ export const useMapContainer = () => {
userLocation,
getGeoLocation,
isEarth,
+ setLocationMode,
]);
const selectMapType = useCallback(
@@ -458,7 +475,7 @@ export const useMapContainer = () => {
removeMapEventHandlers,
} = useGridSystem(showGrid, selectedArea, setSelectedArea, {
locationMode,
- setLocationMode,
+ setLocationMode: (mode) => setLocationMode(mode as LocationMode),
placeDetailsVisible,
setPlaceDetailsVisible,
setSelectedPlaceId,
@@ -559,6 +576,7 @@ export const useMapContainer = () => {
useEffect(() => {
if (typeof window === "undefined") return;
+ if (parseGroundCodeSharePath(window.location.pathname)) return;
const params = new URLSearchParams(window.location.search);
if (body === "earth") {
@@ -658,10 +676,10 @@ export const useMapContainer = () => {
// Initialize InfoWindow
useEffect(() => {
- if (isLoaded && !infoWindow) {
+ if (hasGoogleMapsApiKey && isGoogleMapsLoaded && !infoWindow) {
setInfoWindow(new google.maps.InfoWindow());
}
- }, [isLoaded, infoWindow]);
+ }, [hasGoogleMapsApiKey, isGoogleMapsLoaded, infoWindow]);
// Handle place selection from search
const handlePlaceSelect = useCallback(
@@ -719,6 +737,82 @@ export const useMapContainer = () => {
[map, searchMarker, infoWindow, locationMode, setLocationMode],
);
+ const applyGroundSearchResult = useCallback(
+ (result: GroundCodeSearchResult) => {
+ const resultBody = parseCelestialBody(String(result.body));
+ const nextLocation = { lat: result.lat, lng: result.lng };
+ const nextZoom = resultBody === "earth" ? 14 : 5;
+
+ if (resultBody !== body) {
+ selectBody(resultBody);
+ }
+
+ if (locationMode === LocationMode.TRACKING) {
+ setLocationMode(LocationMode.OFF);
+ }
+
+ setPlaceDetailsVisible(false);
+ setSelectedPlaceId(null);
+ setSelectedLocation(null);
+ setCenter(nextLocation);
+ setZoom(nextZoom);
+ userZoomRef.current = nextZoom;
+ setSelectedArea(nextLocation);
+ setShowInfoWindow(true);
+
+ if (map && resultBody === body) {
+ map.setCenter(nextLocation);
+ map.setZoom(nextZoom);
+ }
+ },
+ [body, locationMode, map, selectBody, setLocationMode],
+ );
+
+ const handleGroundSearch = useCallback(
+ async (query: string, options?: { body?: CelestialBody }) => {
+ const trimmedQuery = query.trim();
+ if (!trimmedQuery) return;
+ const searchBody = options?.body ?? body;
+
+ try {
+ setIsGroundSearchLoading(true);
+ setGroundSearchError(null);
+ const response = await searchGroundCodes({
+ query: trimmedQuery,
+ language: getGroundCodeLanguage(locale),
+ body: searchBody,
+ maxResults: 5,
+ });
+ setGroundSearchResults(response.results);
+ const result = response.results[0];
+
+ if (!result) {
+ setGroundSearchError("map.search.noResults");
+ return;
+ }
+
+ applyGroundSearchResult(result);
+ } catch (error) {
+ console.error("Ground code search failed:", error);
+ setGroundSearchError("map.search.error");
+ } finally {
+ setIsGroundSearchLoading(false);
+ }
+ },
+ [applyGroundSearchResult, body, locale],
+ );
+
+ useEffect(() => {
+ if (restoredSharePathRef.current || typeof window === "undefined") return;
+
+ const sharedCode = parseGroundCodeSharePath(window.location.pathname);
+ if (!sharedCode) return;
+
+ restoredSharePathRef.current = true;
+ setInitialGroundSearchQuery(sharedCode.code);
+ void handleGroundSearch(sharedCode.code, { body: sharedCode.body });
+ }, [handleGroundSearch]);
+
// Map load handler
const onLoad = useCallback(
(mapInstance: google.maps.Map) => {
@@ -762,7 +856,7 @@ export const useMapContainer = () => {
// Intercept POI click event to prevent default InfoWindow display
mapInstance.addListener("click", (e: google.maps.MapMouseEvent) => {
- if ((e as any).placeId) {
+ if ((e as google.maps.IconMouseEvent).placeId) {
// Stop default POI click action
(e as google.maps.IconMouseEvent).stop();
}
@@ -837,6 +931,7 @@ export const useMapContainer = () => {
return {
// Map state
isLoaded,
+ hasGoogleMapsApiKey,
map,
center,
zoom,
@@ -883,6 +978,12 @@ export const useMapContainer = () => {
// Search state
searchedPlace,
handlePlaceSelect,
+ handleGroundSearch,
+ handleGroundSearchResultSelect: applyGroundSearchResult,
+ isGroundSearchLoading,
+ groundSearchError,
+ groundSearchResults,
+ initialGroundSearchQuery,
// Place Details state
placeDetailsVisible,
diff --git a/apps/web/components/google-map/hooks/use-map-coordinates.ts b/apps/web/components/google-map/hooks/use-map-coordinates.ts
index 6f9455c6..6908dcaf 100644
--- a/apps/web/components/google-map/hooks/use-map-coordinates.ts
+++ b/apps/web/components/google-map/hooks/use-map-coordinates.ts
@@ -40,7 +40,7 @@ export const useMapCoordinates = (
} finally {
setIsEncoding(false);
}
- }, [selectedArea, body]); // Locale dependency removed
+ }, [selectedArea, body, locale]);
return {
encodedCoordinates,
diff --git a/apps/web/components/google-map/index.tsx b/apps/web/components/google-map/index.tsx
index 7fff0447..9ec08dac 100644
--- a/apps/web/components/google-map/index.tsx
+++ b/apps/web/components/google-map/index.tsx
@@ -1,4 +1,5 @@
-import React from "react";
+import React, { useState } from "react";
+import { FaCopy, FaShareAlt } from "react-icons/fa";
import { GoogleMap } from "@react-google-maps/api";
import { useMapContainer } from "./hooks/use-map-container";
import MapMarkers from "./map-markers";
@@ -9,11 +10,14 @@ import WeatherInfo from "./weather-info";
import { useI18n } from "@/lib/i18n/i18n-context";
import Earth3DMap from "./earth-3d-map";
import Planetary3DMap from "./planetary-3d-map";
+import { buildGroundCodeSharePath } from "@/lib/code/share-url";
function GoogleMapComponent() {
const { t, isChangingLanguage } = useI18n();
+ const [feedbackMessage, setFeedbackMessage] = useState(null);
const {
isLoaded,
+ hasGoogleMapsApiKey,
center,
zoom,
map,
@@ -26,10 +30,15 @@ function GoogleMapComponent() {
toggleGrid,
getUserLocation,
isLoading,
- isTrackingLocation,
encodedCoordinates,
isEncoding,
handlePlaceSelect,
+ handleGroundSearch,
+ handleGroundSearchResultSelect,
+ isGroundSearchLoading,
+ groundSearchError,
+ groundSearchResults,
+ initialGroundSearchQuery,
mapType,
selectMapType,
body,
@@ -52,14 +61,132 @@ function GoogleMapComponent() {
} = useMapContainer();
const isEarth3D = isEarth && mapType === "earth3d";
const isPlanetary3D = !isEarth && mapType === "planetary3d";
- const showGoogleMap = !isEarth3D && !isPlanetary3D;
+ const showGoogleMap = hasGoogleMapsApiKey && !isEarth3D && !isPlanetary3D;
+ const search = (
+
+ );
+ const copyGroundCode = async () => {
+ if (!encodedCoordinates) return;
+ await navigator.clipboard?.writeText(encodedCoordinates);
+ setFeedbackMessage(t("map.coordinates.copied"));
+ window.setTimeout(() => setFeedbackMessage(null), 1800);
+ };
+ const shareSelectedArea = async () => {
+ if (!selectedArea) return;
+
+ const url = `${window.location.origin}${buildGroundCodeSharePath({
+ code: encodedCoordinates,
+ body,
+ })}`;
+
+ if (navigator.share) {
+ await navigator.share({
+ title: encodedCoordinates,
+ text: encodedCoordinates,
+ url,
+ });
+ return;
+ }
+
+ await navigator.clipboard?.writeText(url);
+ setFeedbackMessage(t("map.coordinates.shareCopied"));
+ window.setTimeout(() => setFeedbackMessage(null), 1800);
+ };
+ const selectedAreaPanel = selectedArea ? (
+
+
{t("map.coordinates.title")}
+
+ {selectedArea.lat.toFixed(6)}, {selectedArea.lng.toFixed(6)}
+
+
+ {isEncoding ? t("map.encoding") : encodedCoordinates}
+
+ {!isEncoding && encodedCoordinates && (
+
+
+
+
+ )}
+ {feedbackMessage && (
+
{feedbackMessage}
+ )}
+
+ ) : null;
// Language change in progress, do not render map component
if (isChangingLanguage) {
return ;
}
- return isLoaded ? (
+ if (!isLoaded) {
+ return (
+
+ {search}
+
{t("map.loading")}
+ {selectedAreaPanel}
+
+ );
+ }
+
+ if (!hasGoogleMapsApiKey) {
+ return (
+
+
+
+
+ {search}
+
+ {selectedAreaPanel}
+
+ );
+ }
+
+ return (
{showGoogleMap ? (
)}
- {isEarth && !isEarth3D && (
-
- )}
+ {search}
@@ -163,8 +288,6 @@ function GoogleMapComponent() {
/>
)}
- ) : (
- {t("map.loading")}
);
}
diff --git a/apps/web/components/google-map/map-controls.tsx b/apps/web/components/google-map/map-controls.tsx
index f4c54144..0d084f9c 100644
--- a/apps/web/components/google-map/map-controls.tsx
+++ b/apps/web/components/google-map/map-controls.tsx
@@ -23,13 +23,11 @@ interface MapControlsProps {
resetMapHeading: () => void;
setMapHeading?: (heading: number) => void;
isLoadingLocation?: boolean;
- isTrackingLocation?: boolean;
locationMode?: LocationMode;
- isFullscreen?: boolean;
- toggleFullscreen?: () => void;
body?: CelestialBody;
selectBody?: (body: CelestialBody) => void;
isEarth?: boolean;
+ hasSelectedArea?: boolean;
}
const MapControls: React.FC = ({
@@ -42,13 +40,11 @@ const MapControls: React.FC = ({
resetMapHeading,
setMapHeading,
isLoadingLocation = false,
- isTrackingLocation = false,
locationMode = 0,
- isFullscreen = false,
- toggleFullscreen = () => {},
body = "earth",
selectBody = () => {},
isEarth = true,
+ hasSelectedArea = false,
}) => {
const { t, locale, setLocale } = useI18n();
const [showBodyOptions, setShowBodyOptions] = useState(false);
@@ -213,7 +209,14 @@ const MapControls: React.FC = ({
return (
<>
{/* Map Type Control and Language Selector (Top Right) */}
-
+
@@ -373,7 +376,14 @@ const MapControls: React.FC = ({
{/* Grid, Location, and Compass Controls (Bottom Right) */}
-
+
void;
+ onPlaceSelect?: (place: google.maps.places.PlaceResult) => void;
+ onGroundSearch: (query: string) => Promise | void;
+ onGroundSearchResultSelect: (result: GroundCodeSearchResult) => void;
+ isGroundSearchLoading: boolean;
+ groundSearchError: string | null;
+ groundSearchResults: GroundCodeSearchResult[];
+ initialQuery?: string | null;
}
-const MapSearch: React.FC = ({ map, onPlaceSelect }) => {
+const MapSearch: React.FC = ({
+ map,
+ onPlaceSelect,
+ onGroundSearch,
+ onGroundSearchResultSelect,
+ isGroundSearchLoading,
+ groundSearchError,
+ groundSearchResults,
+ initialQuery = null,
+}) => {
const { t } = useI18n();
+ const [query, setQuery] = useState("");
const searchInputRef = useRef(null);
const autocompleteRef = useRef(null);
const infoWindowRef = useRef(null);
useEffect(() => {
- if (!map || !searchInputRef.current) return;
+ if (initialQuery && !query) {
+ setQuery(initialQuery);
+ }
+ }, [initialQuery, query]);
+
+ useEffect(() => {
+ if (!map || !searchInputRef.current || !onPlaceSelect) return;
+ if (typeof google === "undefined" || !google.maps?.places) return;
// Initialize the InfoWindow
infoWindowRef.current = new google.maps.InfoWindow();
@@ -41,6 +65,7 @@ const MapSearch: React.FC = ({ map, onPlaceSelect }) => {
return;
}
+ setQuery(place.name ?? searchInputRef.current?.value ?? "");
onPlaceSelect(place);
}
);
@@ -53,16 +78,35 @@ const MapSearch: React.FC = ({ map, onPlaceSelect }) => {
};
}, [map, onPlaceSelect]);
+ const submitSearch = async (event: React.FormEvent) => {
+ event.preventDefault();
+ const trimmedQuery = (searchInputRef.current?.value ?? query).trim();
+ if (!trimmedQuery || isGroundSearchLoading) return;
+ setQuery(trimmedQuery);
+ await onGroundSearch(trimmedQuery);
+ };
+
+ const translatedGroundSearchError = groundSearchError?.startsWith("map.")
+ ? t(groundSearchError)
+ : groundSearchError;
+
return (
-
-
+
);
};
diff --git a/apps/web/components/google-map/place-details/helpers.ts b/apps/web/components/google-map/place-details/helpers.ts
index cca2f86f..30646c59 100644
--- a/apps/web/components/google-map/place-details/helpers.ts
+++ b/apps/web/components/google-map/place-details/helpers.ts
@@ -33,7 +33,9 @@ export const getDayIndexFromString = (dayString: string | undefined): number =>
* Returns true when the place is open at the current moment.
* Works with both `periods` and `weekday_text` data from the Places API.
*/
-export const isOpenNow = (placeDetails: any): boolean => {
+export const isOpenNow = (
+ placeDetails: google.maps.places.PlaceResult | null
+): boolean => {
try {
if (placeDetails?.opening_hours?.periods) {
const now = new Date();
@@ -41,16 +43,17 @@ export const isOpenNow = (placeDetails: any): boolean => {
const currentTime = now.getHours() * 60 + now.getMinutes();
// 24-hour operation: single period with no close time
+ const firstPeriod = placeDetails.opening_hours.periods[0];
if (
placeDetails.opening_hours.periods.length === 1 &&
- placeDetails.opening_hours.periods[0].open &&
- !placeDetails.opening_hours.periods[0].close
+ firstPeriod?.open &&
+ !firstPeriod.close
) {
return true;
}
const todayPeriods = placeDetails.opening_hours.periods.filter(
- (period: any) => period.open && period.open.day === day
+ (period) => period.open && period.open.day === day
);
for (const period of todayPeriods) {
diff --git a/apps/web/components/google-map/place-details/index.tsx b/apps/web/components/google-map/place-details/index.tsx
index 48b5c243..63457cd8 100644
--- a/apps/web/components/google-map/place-details/index.tsx
+++ b/apps/web/components/google-map/place-details/index.tsx
@@ -94,7 +94,7 @@ const PlaceDetails: React.FC
= ({
@@ -118,7 +118,7 @@ const PlaceDetails: React.FC = ({
{Array.from({ length: 5 }).map((_, i) => (
- {i < Math.floor(placeDetails.rating) ? (
+ {i < Math.floor(placeDetails.rating ?? 0) ? (
) : (
diff --git a/apps/web/components/google-map/place-details/photo-gallery.tsx b/apps/web/components/google-map/place-details/photo-gallery.tsx
index ca21a87e..b7d69d90 100644
--- a/apps/web/components/google-map/place-details/photo-gallery.tsx
+++ b/apps/web/components/google-map/place-details/photo-gallery.tsx
@@ -1,9 +1,10 @@
import React from "react";
+import Image from "next/image";
import { FaImages, FaExpand } from "react-icons/fa";
import { useI18n } from "@/lib/i18n/i18n-context";
interface PhotoGalleryProps {
- photos: any[];
+ photos: google.maps.places.PlacePhoto[];
photoErrors: Record;
placeName: string;
onPhotoError: (index: number) => void;
@@ -32,7 +33,7 @@ const PhotoGallery: React.FC = ({
- {photos.slice(0, 4).map((photo: any, index: number) => {
+ {photos.slice(0, 4).map((photo, index) => {
if (photoErrors[index]) return null;
const photoUrl = photo.getUrl({ maxWidth: 300, maxHeight: 300 });
@@ -45,9 +46,12 @@ const PhotoGallery: React.FC
= ({
onClick={() => onSelectPhoto(fullPhotoUrl)}
title={t("common.viewFullImage")}
>
-
onPhotoError(index)}
/>
diff --git a/apps/web/components/google-map/place-details/photo-modal.tsx b/apps/web/components/google-map/place-details/photo-modal.tsx
index 669dd41a..dfb47445 100644
--- a/apps/web/components/google-map/place-details/photo-modal.tsx
+++ b/apps/web/components/google-map/place-details/photo-modal.tsx
@@ -1,5 +1,6 @@
import React from "react";
import { createPortal } from "react-dom";
+import Image from "next/image";
import { FaTimes } from "react-icons/fa";
import { useI18n } from "@/lib/i18n/i18n-context";
@@ -23,9 +24,12 @@ const PhotoModal: React.FC = ({ photoUrl, onClose }) => {
-

e.stopPropagation()}
style={{ maxWidth: "95vw" }}
diff --git a/apps/web/components/google-map/place-details/use-place-details.ts b/apps/web/components/google-map/place-details/use-place-details.ts
index e5543bf6..3a970093 100644
--- a/apps/web/components/google-map/place-details/use-place-details.ts
+++ b/apps/web/components/google-map/place-details/use-place-details.ts
@@ -14,7 +14,8 @@ export const usePlaceDetails = (
const { t } = useI18n();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState
(null);
- const [placeDetails, setPlaceDetails] = useState(null);
+ const [placeDetails, setPlaceDetails] =
+ useState(null);
// Reset everything when the panel is hidden
useEffect(() => {
@@ -73,7 +74,7 @@ export const usePlaceDetails = (
};
fetchPlaceDetails();
- }, [map, placeId, visible]);
+ }, [map, onResetPhotoState, placeId, t, visible]);
return { isLoading, error, placeDetails };
};
diff --git a/apps/web/components/google-map/weather-detail-modal.tsx b/apps/web/components/google-map/weather-detail-modal.tsx
index e1343b6f..627e6d56 100644
--- a/apps/web/components/google-map/weather-detail-modal.tsx
+++ b/apps/web/components/google-map/weather-detail-modal.tsx
@@ -1,4 +1,5 @@
import React, { useRef, useEffect } from "react";
+import Image from "next/image";
import { useI18n } from "@/lib/i18n/i18n-context";
import { AirQualityData, WeatherData } from "@/app/api/weather-data/route";
@@ -146,20 +147,20 @@ const WeatherDetailModal: React.FC = ({
};
// Convert RGB object to CSS color string
- const rgbToColorString = (colorObj: any) => {
- if (!colorObj || typeof colorObj !== "object") return "#808080"; // Default gray
-
- // Check if it's already a hex color string
+ const rgbToColorString = (colorObj: unknown) => {
if (typeof colorObj === "string" && colorObj.startsWith("#")) {
return colorObj;
}
+ if (!colorObj || typeof colorObj !== "object") return "#808080"; // Default gray
+
// Check if it has RGB components
if ("red" in colorObj && "green" in colorObj && "blue" in colorObj) {
+ const rgb = colorObj as { red: number; green: number; blue: number };
// Convert 0-1 range to 0-255 range
- const r = Math.round(colorObj.red * 255);
- const g = Math.round(colorObj.green * 255);
- const b = Math.round(colorObj.blue * 255);
+ const r = Math.round(rgb.red * 255);
+ const g = Math.round(rgb.green * 255);
+ const b = Math.round(rgb.blue * 255);
return `rgb(${r}, ${g}, ${b})`;
}
@@ -206,9 +207,12 @@ const WeatherDetailModal: React.FC = ({
{weatherData.name || t("weather.currentLocation")}
{weatherData.weather && weatherData.weather[0] && (
-
)}
diff --git a/apps/web/components/google-map/weather-info.tsx b/apps/web/components/google-map/weather-info.tsx
index 1789f547..30b648bd 100644
--- a/apps/web/components/google-map/weather-info.tsx
+++ b/apps/web/components/google-map/weather-info.tsx
@@ -50,30 +50,35 @@ const WeatherInfo: React.FC = ({
airQuality,
isLoading,
error,
+ isUnavailable,
weatherData,
} = useAirQuality(mapCenter, {
debounceMs: 1500, // Debounce map center changes to prevent excessive API calls
});
+ if (isUnavailable || (!isLoading && !temperature && !airQuality && !error)) {
+ return null;
+ }
+
// Get the air quality index from the response
const airQualityIndex = airQuality?.indexes?.[0]?.aqi || 0;
const airQualityCategory = airQuality?.indexes?.[0]?.category || "";
// Convert RGB object to CSS color string
- const rgbToColorString = (colorObj: any) => {
- if (!colorObj || typeof colorObj !== "object") return "#808080"; // Default gray
-
- // Check if it's already a hex color string
+ const rgbToColorString = (colorObj: unknown) => {
if (typeof colorObj === "string" && colorObj.startsWith("#")) {
return colorObj;
}
+ if (!colorObj || typeof colorObj !== "object") return "#808080"; // Default gray
+
// Check if it has RGB components
if ("red" in colorObj && "green" in colorObj && "blue" in colorObj) {
+ const rgb = colorObj as { red: number; green: number; blue: number };
// Convert 0-1 range to 0-255 range
- const r = Math.round(colorObj.red * 255);
- const g = Math.round(colorObj.green * 255);
- const b = Math.round(colorObj.blue * 255);
+ const r = Math.round(rgb.red * 255);
+ const g = Math.round(rgb.green * 255);
+ const b = Math.round(rgb.blue * 255);
return `rgb(${r}, ${g}, ${b})`;
}
diff --git a/apps/web/e2e/ground-code-share.spec.ts b/apps/web/e2e/ground-code-share.spec.ts
new file mode 100644
index 00000000..08637c0b
--- /dev/null
+++ b/apps/web/e2e/ground-code-share.spec.ts
@@ -0,0 +1,101 @@
+import { expect, test } from "@playwright/test";
+
+const boxesOverlap = (
+ a: { x: number; y: number; width: number; height: number },
+ b: { x: number; y: number; width: number; height: number },
+) =>
+ a.x < b.x + b.width &&
+ a.x + a.width > b.x &&
+ a.y < b.y + b.height &&
+ a.y + a.height > b.y;
+
+test("opens a canonical Earth Ground Code share URL", async ({ page }) => {
+ test.setTimeout(90_000);
+ const searchResponse = page.waitForResponse((response) =>
+ response.url().includes("/v1/search"),
+ );
+ const encodeResponse = page.waitForResponse((response) =>
+ response.url().includes("/v1/encode"),
+ );
+ await page.goto("/%EC%84%9C%EC%9A%B8-%EC%95%88%EB%B0%A9");
+ await expect((await searchResponse).ok()).toBe(true);
+ await expect((await encodeResponse).ok()).toBe(true);
+
+ await expect(page.getByText(/37\.566000/).first()).toBeVisible();
+ await expect(page.getByRole("button", { name: /Copy|복사/ })).toBeVisible();
+ await expect(page.getByRole("button", { name: /Share|공유/ })).toBeVisible();
+});
+
+test("searches partial region names and shows selectable results", async ({
+ page,
+}) => {
+ test.setTimeout(90_000);
+ await page.goto("/?map=roadmap");
+
+ const search = page.getByRole("textbox", {
+ name: /그라운드 코드|Ground Code/,
+ });
+ await search.fill("Seo");
+ const searchResponse = page.waitForResponse((response) =>
+ response.url().includes("/v1/search"),
+ );
+ await search.press("Enter");
+ await expect((await searchResponse).ok()).toBe(true);
+
+ await expect(page.getByText("Seoul").first()).toBeVisible();
+ await expect(page.getByText("Seongnamsi").first()).toBeVisible();
+});
+
+test("keeps mobile map controls clear of search and selected area panel", async ({
+ page,
+}) => {
+ test.setTimeout(90_000);
+ await page.setViewportSize({ width: 390, height: 844 });
+
+ const searchResponse = page.waitForResponse((response) =>
+ response.url().includes("/v1/search"),
+ );
+ const encodeResponse = page.waitForResponse((response) =>
+ response.url().includes("/v1/encode"),
+ );
+ await page.goto("/%EC%84%9C%EC%9A%B8-%EC%95%88%EB%B0%A9");
+ await expect((await searchResponse).ok()).toBe(true);
+ await expect((await encodeResponse).ok()).toBe(true);
+ await expect(page.getByText(/37\.566000/).first()).toBeVisible();
+
+ const searchBox = await page
+ .getByRole("textbox", { name: /그라운드 코드|Ground Code/ })
+ .boundingBox();
+ const settingsBox = await page
+ .getByTestId("map-settings-controls")
+ .boundingBox();
+ const actionBox = await page.getByTestId("map-action-controls").boundingBox();
+ const panelBox = await page.getByTestId("selected-area-panel").boundingBox();
+
+ expect(searchBox).not.toBeNull();
+ expect(settingsBox).not.toBeNull();
+ expect(actionBox).not.toBeNull();
+ expect(panelBox).not.toBeNull();
+
+ expect(boxesOverlap(settingsBox!, searchBox!)).toBe(false);
+ expect(boxesOverlap(settingsBox!, panelBox!)).toBe(false);
+ expect(boxesOverlap(actionBox!, panelBox!)).toBe(false);
+});
+
+test("shows a visible error for an invalid code-shaped share URL", async ({
+ page,
+}) => {
+ test.setTimeout(90_000);
+ const searchResponse = page.waitForResponse((response) =>
+ response.url().includes("/v1/search"),
+ );
+
+ await page.goto("/Seoul-notrealcode");
+ await expect((await searchResponse).ok()).toBe(true);
+ await expect(
+ page.getByText(/No matching Ground Code|일치하는 그라운드 코드/),
+ ).toBeVisible();
+ await expect(
+ page.getByRole("textbox", { name: /그라운드 코드|Ground Code/ }),
+ ).toHaveValue("Seoul-notrealcode");
+});
diff --git a/apps/web/e2e/visual-qa.spec.ts b/apps/web/e2e/visual-qa.spec.ts
new file mode 100644
index 00000000..5fb4cd63
--- /dev/null
+++ b/apps/web/e2e/visual-qa.spec.ts
@@ -0,0 +1,45 @@
+import { expect, test } from "@playwright/test";
+
+const waitForShareUrlToResolve = async (page: import("@playwright/test").Page) => {
+ const searchResponse = page.waitForResponse((response) =>
+ response.url().includes("/v1/search"),
+ );
+ const encodeResponse = page.waitForResponse((response) =>
+ response.url().includes("/v1/encode"),
+ );
+
+ await page.goto("/%EC%84%9C%EC%9A%B8-%EC%95%88%EB%B0%A9");
+ await expect((await searchResponse).ok()).toBe(true);
+ await expect((await encodeResponse).ok()).toBe(true);
+ await expect(page.getByTestId("selected-area-panel")).toBeVisible();
+};
+
+test.describe("visual QA capture", () => {
+ test("captures desktop, mobile, and API docs screenshots", async ({
+ page,
+ }, testInfo) => {
+ test.setTimeout(120_000);
+
+ await page.setViewportSize({ width: 1440, height: 1000 });
+ await waitForShareUrlToResolve(page);
+ await page.screenshot({
+ path: testInfo.outputPath("desktop-map.png"),
+ fullPage: true,
+ });
+
+ await page.setViewportSize({ width: 390, height: 844 });
+ await waitForShareUrlToResolve(page);
+ await page.screenshot({
+ path: testInfo.outputPath("mobile-map.png"),
+ fullPage: true,
+ });
+
+ await page.goto("http://127.0.0.1:3000/docs");
+ await expect(page.getByRole("heading", { name: "Ground Codes API" }))
+ .toBeVisible();
+ await page.screenshot({
+ path: testInfo.outputPath("api-docs.png"),
+ fullPage: true,
+ });
+ });
+});
diff --git a/apps/web/lib/code/ground-codes.test.ts b/apps/web/lib/code/ground-codes.test.ts
new file mode 100644
index 00000000..96941225
--- /dev/null
+++ b/apps/web/lib/code/ground-codes.test.ts
@@ -0,0 +1,90 @@
+import { afterEach, describe, expect, test } from "bun:test";
+import { encode, searchGroundCodes } from "./ground-codes";
+
+const originalFetch = globalThis.fetch;
+const originalApiUrl = process.env.NEXT_PUBLIC_GROUND_CODES_API_URL;
+
+afterEach(() => {
+ globalThis.fetch = originalFetch;
+ if (originalApiUrl === undefined) {
+ delete process.env.NEXT_PUBLIC_GROUND_CODES_API_URL;
+ } else {
+ process.env.NEXT_PUBLIC_GROUND_CODES_API_URL = originalApiUrl;
+ }
+});
+
+describe("ground-codes API client", () => {
+ test("encodes through the versioned API and configurable base URL", async () => {
+ process.env.NEXT_PUBLIC_GROUND_CODES_API_URL = "https://api.example.test/";
+ const requests: Array<{ url: string; body: unknown }> = [];
+ globalThis.fetch = (async (url, init) => {
+ requests.push({
+ url: String(url),
+ body: JSON.parse(String(init?.body)),
+ });
+ return new Response("Seoul-Example-Code", { status: 200 });
+ }) as typeof fetch;
+
+ const code = await encode({
+ lat: 37.566,
+ lng: 126.978,
+ language: "english",
+ body: "earth",
+ });
+
+ expect(code).toBe("Seoul-Example-Code");
+ expect(requests[0]).toEqual({
+ url: "https://api.example.test/v1/encode",
+ body: {
+ lat: 37.566,
+ lng: 126.978,
+ regionLevel: 2,
+ language: "english",
+ precisionMeters: 3,
+ body: "earth",
+ },
+ });
+ });
+
+ test("searches ground codes and region names through the versioned API", async () => {
+ const requests: Array<{ url: string; body: unknown }> = [];
+ globalThis.fetch = (async (url, init) => {
+ requests.push({
+ url: String(url),
+ body: JSON.parse(String(init?.body)),
+ });
+ return Response.json({
+ query: "Seoul-Happy-Tiger",
+ results: [
+ {
+ type: "ground-code",
+ label: "Seoul-Happy-Tiger",
+ lat: 37.566,
+ lng: 126.978,
+ body: "earth",
+ regionLevel: 2,
+ },
+ ],
+ });
+ }) as typeof fetch;
+
+ const result = await searchGroundCodes({
+ query: "Seoul-Happy-Tiger",
+ language: "english",
+ body: "earth",
+ maxResults: 5,
+ });
+
+ expect(result.results[0]?.type).toBe("ground-code");
+ expect(requests[0]).toEqual({
+ url: "https://api.ground.codes/v1/search",
+ body: {
+ query: "Seoul-Happy-Tiger",
+ regionLevel: 2,
+ language: "english",
+ body: "earth",
+ maxResults: 5,
+ },
+ });
+ });
+});
diff --git a/apps/web/lib/code/ground-codes.ts b/apps/web/lib/code/ground-codes.ts
index ab9d6d89..fcd5662b 100644
--- a/apps/web/lib/code/ground-codes.ts
+++ b/apps/web/lib/code/ground-codes.ts
@@ -1,3 +1,37 @@
+type CelestialBody = "earth" | "moon" | "mars";
+
+export interface GroundCodeSearchResult {
+ type: "ground-code" | "region" | "coordinates" | string;
+ label: string;
+ lat: number;
+ lng: number;
+ code?: string;
+ body: CelestialBody | string;
+ regionLevel: number;
+}
+
+export interface GroundCodeSearchResponse {
+ query: string;
+ results: GroundCodeSearchResult[];
+}
+
+const getApiBaseUrl = () =>
+ (process.env.NEXT_PUBLIC_GROUND_CODES_API_URL ?? "https://api.ground.codes")
+ .replace(/\/+$/, "");
+
+const postApi = async (path: string, body: unknown) => {
+ const response = await fetch(`${getApiBaseUrl()}${path}`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(body),
+ });
+
+ if (!response.ok) throw new Error("Ground Codes API request failed");
+ return response;
+};
+
/**
* Encodes a set of coordinates into a ground code.
*/
@@ -12,23 +46,43 @@ export const encode = async ({
lng: number;
language?: string;
precisionMeters?: number;
- body?: "earth" | "moon" | "mars";
+ body?: CelestialBody;
}) => {
- const response = await fetch("https://api.ground.codes/encode", {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- },
- body: JSON.stringify({
+ const response = await postApi(
+ "/v1/encode",
+ {
lat,
lng,
regionLevel: 2,
language,
precisionMeters,
body,
- }),
- });
+ },
+ );
- if (!response.ok) throw new Error("Failed to encode coordinates");
return await response.text();
};
+
+export const searchGroundCodes = async ({
+ query,
+ language = "english",
+ body = "earth",
+ regionLevel = 2,
+ maxResults = 5,
+}: {
+ query: string;
+ language?: string;
+ body?: CelestialBody;
+ regionLevel?: number;
+ maxResults?: number;
+}): Promise => {
+ const response = await postApi("/v1/search", {
+ query,
+ regionLevel,
+ language,
+ body,
+ maxResults,
+ });
+
+ return await response.json();
+};
diff --git a/apps/web/lib/code/share-url.test.ts b/apps/web/lib/code/share-url.test.ts
new file mode 100644
index 00000000..47c789d9
--- /dev/null
+++ b/apps/web/lib/code/share-url.test.ts
@@ -0,0 +1,58 @@
+import { describe, expect, test } from "bun:test";
+import {
+ buildGroundCodeSharePath,
+ parseGroundCodeSharePath,
+} from "./share-url";
+
+describe("Ground Code share URLs", () => {
+ test("uses a root path for Earth codes", () => {
+ expect(
+ buildGroundCodeSharePath({
+ code: "서울-안방",
+ body: "earth",
+ }),
+ ).toBe("/%EC%84%9C%EC%9A%B8-%EC%95%88%EB%B0%A9");
+ });
+
+ test("uses explicit body prefixes for Moon and Mars codes", () => {
+ expect(
+ buildGroundCodeSharePath({
+ code: "Olympus Mons-Happy-Tiger",
+ body: "mars",
+ }),
+ ).toBe("/mars/Olympus%20Mons-Happy-Tiger");
+
+ expect(
+ buildGroundCodeSharePath({
+ code: "Mare Tranquillitatis-Happy-Tiger",
+ body: "moon",
+ }),
+ ).toBe("/moon/Mare%20Tranquillitatis-Happy-Tiger");
+ });
+
+ test("parses Earth, Moon, and Mars share paths", () => {
+ expect(parseGroundCodeSharePath("/서울-안방")).toEqual({
+ body: "earth",
+ code: "서울-안방",
+ });
+ expect(parseGroundCodeSharePath("/mars/Olympus%20Mons-Happy-Tiger")).toEqual({
+ body: "mars",
+ code: "Olympus Mons-Happy-Tiger",
+ });
+ expect(
+ parseGroundCodeSharePath("/moon/Mare%20Tranquillitatis-Happy-Tiger/"),
+ ).toEqual({
+ body: "moon",
+ code: "Mare Tranquillitatis-Happy-Tiger",
+ });
+ });
+
+ test("ignores app routes that are not share codes", () => {
+ expect(parseGroundCodeSharePath("/api/weather-data")).toBeNull();
+ expect(parseGroundCodeSharePath("/docs")).toBeNull();
+ expect(parseGroundCodeSharePath("/about")).toBeNull();
+ expect(parseGroundCodeSharePath("/Seoul-not-a-real-code")).toBeNull();
+ expect(parseGroundCodeSharePath("/moon")).toBeNull();
+ expect(parseGroundCodeSharePath("/moon/about")).toBeNull();
+ });
+});
diff --git a/apps/web/lib/code/share-url.ts b/apps/web/lib/code/share-url.ts
new file mode 100644
index 00000000..1143ff19
--- /dev/null
+++ b/apps/web/lib/code/share-url.ts
@@ -0,0 +1,64 @@
+type CelestialBody = "earth" | "moon" | "mars";
+
+const bodyPrefixes = new Set(["moon", "mars"]);
+const reservedRootSegments = new Set([
+ "api",
+ "_next",
+ "docs",
+ "favicon.ico",
+ "robots.txt",
+ "sitemap.xml",
+]);
+
+export interface GroundCodeSharePath {
+ body: CelestialBody;
+ code: string;
+}
+
+export const buildGroundCodeSharePath = ({
+ code,
+ body,
+}: GroundCodeSharePath) => {
+ const encodedCode = encodeURIComponent(code);
+ return body === "earth" ? `/${encodedCode}` : `/${body}/${encodedCode}`;
+};
+
+const isCodeLikeShareSegment = (code: string) => {
+ const wordCount = code.split("-").filter(Boolean).length;
+ return wordCount >= 2 && wordCount <= 3;
+};
+
+export const parseGroundCodeSharePath = (
+ pathname: string,
+): GroundCodeSharePath | null => {
+ const segments = pathname
+ .split("/")
+ .map((segment) => segment.trim())
+ .filter(Boolean);
+
+ if (segments.length === 1) {
+ const [rawCode] = segments;
+ if (!rawCode) return null;
+ if (reservedRootSegments.has(rawCode) || bodyPrefixes.has(rawCode)) {
+ return null;
+ }
+ const code = decodeURIComponent(rawCode);
+ if (!isCodeLikeShareSegment(code)) return null;
+ return {
+ body: "earth",
+ code,
+ };
+ }
+
+ const [rawBody, rawCode] = segments;
+ if (segments.length === 2 && rawBody && rawCode && bodyPrefixes.has(rawBody)) {
+ const code = decodeURIComponent(rawCode);
+ if (!isCodeLikeShareSegment(code)) return null;
+ return {
+ body: rawBody as CelestialBody,
+ code,
+ };
+ }
+
+ return null;
+};
diff --git a/apps/web/lib/grid-system/types.ts b/apps/web/lib/grid-system/types.ts
index 57c93816..1afd09e1 100644
--- a/apps/web/lib/grid-system/types.ts
+++ b/apps/web/lib/grid-system/types.ts
@@ -1,5 +1,3 @@
-import { Dispatch, SetStateAction } from "react";
-
export interface Coordinates {
lat: number;
lng: number;
diff --git a/apps/web/lib/grid-system/use-grid-system.ts b/apps/web/lib/grid-system/use-grid-system.ts
index 792d7fa5..a9a3da35 100644
--- a/apps/web/lib/grid-system/use-grid-system.ts
+++ b/apps/web/lib/grid-system/use-grid-system.ts
@@ -18,8 +18,8 @@ export function useGridSystem(
selectedArea: Coordinates | null,
setSelectedArea: Dispatch>,
mapOptions?: {
- locationMode?: any;
- setLocationMode?: (mode: any) => void;
+ locationMode?: string | number;
+ setLocationMode?: (mode: string | number) => void;
placeDetailsVisible?: boolean;
setPlaceDetailsVisible?: (visible: boolean) => void;
setSelectedPlaceId?: (placeId: string | null) => void;
@@ -33,7 +33,6 @@ export function useGridSystem(
metersPerDegree !== undefined && metersPerDegree < 100000;
const {
gridLinesRef,
- selectedRectangleRef,
mapInstanceRef,
clearAllGridLines,
drawSelectedAreaRectangle,
@@ -155,12 +154,15 @@ export function useGridSystem(
isGridVisibleAtZoom,
metersPerDegree,
isPlanetaryGrid,
+ currentZoomRef,
+ gridLinesRef,
+ gridVisibleRef,
+ mapInstanceRef,
],
);
// Create the grid cell click handler
const handleGridCellClick = useGridCellClickHandler(
- showGrid,
isGridVisibleAtZoom,
mapInstanceRef,
setSelectedArea,
diff --git a/apps/web/lib/grid-system/use-map-handlers.ts b/apps/web/lib/grid-system/use-map-handlers.ts
index 6cc01eac..8aab753f 100644
--- a/apps/web/lib/grid-system/use-map-handlers.ts
+++ b/apps/web/lib/grid-system/use-map-handlers.ts
@@ -10,8 +10,8 @@ export function useMapEventHandlers(
drawGrid: (mapInstance: google.maps.Map) => void,
handleGridCellClick: (e: google.maps.MapMouseEvent) => void,
options?: {
- locationMode?: any;
- setLocationMode?: (mode: any) => void;
+ locationMode?: string | number;
+ setLocationMode?: (mode: string | number) => void;
placeDetailsVisible?: boolean;
setPlaceDetailsVisible?: (visible: boolean) => void;
setSelectedPlaceId?: (placeId: string | null) => void;
@@ -22,9 +22,6 @@ export function useMapEventHandlers(
) {
return useCallback(
(mapInstance: google.maps.Map) => {
- // Store initial zoom level
- const currentZoomRef = { current: mapInstance.getZoom() || null };
-
// Draw grid when map movement is complete
mapInstance.addListener("idle", () => {
// Only draw grid when showGrid is true
@@ -41,13 +38,14 @@ export function useMapEventHandlers(
}
// Check if a POI was clicked
- if ((e as any).placeId) {
+ const iconEvent = e as google.maps.IconMouseEvent;
+ if (iconEvent.placeId) {
// Immediately stop the default POI click behavior
- (e as google.maps.IconMouseEvent).stop();
+ iconEvent.stop();
// A POI was clicked
if (options?.setSelectedPlaceId) {
- options.setSelectedPlaceId((e as any).placeId);
+ options.setSelectedPlaceId(iconEvent.placeId);
}
if (options?.setSelectedLocation) {
options.setSelectedLocation(e.latLng || null);
@@ -95,7 +93,6 @@ export function useMapEventHandlers(
* Hook for grid cell click handler
*/
export function useGridCellClickHandler(
- showGrid: boolean,
isGridVisibleAtZoom: (zoom: number | undefined) => boolean,
mapInstanceRef: React.MutableRefObject,
setSelectedArea: React.Dispatch>,
@@ -106,14 +103,9 @@ export function useGridCellClickHandler(
(e: google.maps.MapMouseEvent) => {
if (!e.latLng) return;
- // Get map instance from event or from ref
- let mapInstance = (e.latLng as any).map;
+ const mapInstance = mapInstanceRef.current;
if (!mapInstance) {
- if (mapInstanceRef.current) {
- mapInstance = mapInstanceRef.current;
- } else {
- return;
- }
+ return;
}
// Grid drawing is zoom-gated for performance, but address selection should
@@ -153,7 +145,6 @@ export function useGridCellClickHandler(
}
},
[
- showGrid,
isGridVisibleAtZoom,
mapInstanceRef,
setSelectedArea,
diff --git a/apps/web/lib/i18n/i18n-context.tsx b/apps/web/lib/i18n/i18n-context.tsx
index 73e40fad..ca40134e 100644
--- a/apps/web/lib/i18n/i18n-context.tsx
+++ b/apps/web/lib/i18n/i18n-context.tsx
@@ -4,14 +4,14 @@ import React, { createContext, useContext, useState, useEffect } from "react";
import { Locale, defaultLocale, locales } from "@/i18n";
// Message type
-type Messages = Record;
+type Messages = Record;
// Context type
interface I18nContextType {
locale: Locale;
messages: Messages;
setLocale: (locale: Locale) => void;
- t: (key: string, params?: Record) => string;
+ t: (key: string, params?: Record) => string;
isChangingLanguage: boolean; // Add flag to indicate language is changing
}
@@ -31,14 +31,16 @@ const I18nContext = createContext(defaultContext);
export const useI18n = () => useContext(I18nContext);
// Get nested value from message
-const getNestedValue = (obj: any, path: string): string => {
+const getNestedValue = (obj: unknown, path: string): string => {
const keys = path.split(".");
- return keys.reduce((acc, key) => {
+ const value = keys.reduce((acc, key) => {
if (acc && typeof acc === "object" && key in acc) {
- return acc[key];
+ return (acc as Record)[key];
}
return path; // If key not found, return original path
}, obj);
+
+ return typeof value === "string" ? value : path;
};
// Check if value is a valid locale
@@ -172,7 +174,10 @@ export const I18nProvider: React.FC<{
};
// Translation function
- const t = (key: string, params?: Record): string => {
+ const t = (
+ key: string,
+ params?: Record
+ ): string => {
let value = getNestedValue(messages, key);
// Parameter replacement
@@ -222,7 +227,7 @@ export const I18nProvider: React.FC<{
// Load messages
const loadedMessages = (
await import(`@/messages/${localeToUse}/index.json`)
- ).default;
+ ).default as Messages;
setMessages(loadedMessages);
setLocaleState(localeToUse);
diff --git a/apps/web/messages/cn/index.json b/apps/web/messages/cn/index.json
index 4add2043..6b34526d 100644
--- a/apps/web/messages/cn/index.json
+++ b/apps/web/messages/cn/index.json
@@ -30,10 +30,16 @@
"loading": "加载中...",
"coordinates": {
"title": "选定区域坐标",
- "copy": "复制"
+ "copy": "复制",
+ "share": "分享",
+ "copied": "Ground Code 已复制",
+ "shareCopied": "分享 URL 已复制"
},
"search": {
- "placeholder": "搜索位置"
+ "placeholder": "搜索 Ground Code、区域或地点",
+ "submit": "搜索",
+ "noResults": "未找到匹配的 Ground Code 或区域",
+ "error": "搜索失败"
},
"groundCode": "地面代码",
"encoding": "编码中",
diff --git a/apps/web/messages/en/index.json b/apps/web/messages/en/index.json
index d85425e6..c0471cb1 100644
--- a/apps/web/messages/en/index.json
+++ b/apps/web/messages/en/index.json
@@ -30,10 +30,16 @@
"loading": "Loading...",
"coordinates": {
"title": "Selected Area Coordinates",
- "copy": "Copy"
+ "copy": "Copy",
+ "share": "Share",
+ "copied": "Ground Code copied",
+ "shareCopied": "Share URL copied"
},
"search": {
- "placeholder": "Search for a location"
+ "placeholder": "Search Ground Code, region, or place",
+ "submit": "Search",
+ "noResults": "No matching Ground Code or region found",
+ "error": "Search failed"
},
"groundCode": "Ground Code",
"encoding": "Encoding",
diff --git a/apps/web/messages/ja/index.json b/apps/web/messages/ja/index.json
index a8b2b5c5..1c3ebed8 100644
--- a/apps/web/messages/ja/index.json
+++ b/apps/web/messages/ja/index.json
@@ -30,10 +30,16 @@
"loading": "読み込み中...",
"coordinates": {
"title": "選択範囲の座標",
- "copy": "コピー"
+ "copy": "コピー",
+ "share": "共有",
+ "copied": "Ground Codeをコピーしました",
+ "shareCopied": "共有URLをコピーしました"
},
"search": {
- "placeholder": "場所を検索"
+ "placeholder": "Ground Code、地域、場所を検索",
+ "submit": "検索",
+ "noResults": "一致する Ground Code または地域が見つかりません",
+ "error": "検索に失敗しました"
},
"groundCode": "グラウンドコード",
"encoding": "エンコード中",
diff --git a/apps/web/messages/ko/index.json b/apps/web/messages/ko/index.json
index 56a33590..a85ea98b 100644
--- a/apps/web/messages/ko/index.json
+++ b/apps/web/messages/ko/index.json
@@ -30,10 +30,16 @@
"loading": "로딩 중...",
"coordinates": {
"title": "선택된 영역 좌표",
- "copy": "복사"
+ "copy": "복사",
+ "share": "공유",
+ "copied": "그라운드 코드가 복사되었습니다",
+ "shareCopied": "공유 URL이 복사되었습니다"
},
"search": {
- "placeholder": "위치 검색"
+ "placeholder": "그라운드 코드, 지역, 장소 검색",
+ "submit": "검색",
+ "noResults": "일치하는 그라운드 코드나 지역을 찾지 못했습니다",
+ "error": "검색에 실패했습니다"
},
"groundCode": "그라운드 코드",
"encoding": "인코딩 중",
diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts
index 8dd427b5..d2e50504 100644
--- a/apps/web/next.config.ts
+++ b/apps/web/next.config.ts
@@ -18,6 +18,8 @@ const nextConfig: NextConfig = {
process.env.NEXT_PUBLIC_CESIUM_MOON_ASSET_ID,
NEXT_PUBLIC_CESIUM_MARS_ASSET_ID:
process.env.NEXT_PUBLIC_CESIUM_MARS_ASSET_ID,
+ NEXT_PUBLIC_GROUND_CODES_API_URL:
+ process.env.NEXT_PUBLIC_GROUND_CODES_API_URL,
},
eslint: {
ignoreDuringBuilds: true,
diff --git a/apps/web/package.json b/apps/web/package.json
index 3f9d0113..73f02156 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -9,6 +9,10 @@
"start": "next start",
"lint": "next lint",
"check-types": "tsc --noEmit",
+ "test": "bun test ./lib ./app",
+ "test:e2e": "playwright test",
+ "test:e2e:prod": "playwright test --config=playwright.production.config.ts",
+ "qa:visual": "playwright test e2e/visual-qa.spec.ts --config=playwright.production.config.ts",
"pages:build": "npx @cloudflare/next-on-pages",
"preview": "pnpm run pages:build && wrangler pages dev",
"deploy": "pnpm run pages:build && wrangler pages deploy"
@@ -27,6 +31,7 @@
"devDependencies": {
"@cloudflare/next-on-pages": "^1.13.9",
"@eslint/eslintrc": "^3",
+ "@playwright/test": "^1.56.1",
"@repo/eslint-config": "workspace:*",
"@repo/typescript-config": "workspace:*",
"@tailwindcss/postcss": "^4",
diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts
new file mode 100644
index 00000000..30a1dc71
--- /dev/null
+++ b/apps/web/playwright.config.ts
@@ -0,0 +1,36 @@
+import { defineConfig, devices } from "@playwright/test";
+
+export default defineConfig({
+ testDir: "./e2e",
+ timeout: 30_000,
+ expect: {
+ timeout: 10_000,
+ },
+ use: {
+ baseURL: "http://127.0.0.1:3001",
+ trace: "on-first-retry",
+ },
+ webServer: [
+ {
+ command: "pnpm --filter api-ground-codes start",
+ cwd: "../..",
+ url: "http://127.0.0.1:3000/readyz",
+ reuseExistingServer: !process.env.CI,
+ timeout: 60_000,
+ },
+ {
+ command:
+ "NEXT_PUBLIC_GROUND_CODES_API_URL=http://127.0.0.1:3000 pnpm --filter web exec next dev --turbopack -p 3001",
+ cwd: "../..",
+ url: "http://127.0.0.1:3001",
+ reuseExistingServer: !process.env.CI,
+ timeout: 60_000,
+ },
+ ],
+ projects: [
+ {
+ name: "chromium",
+ use: { ...devices["Desktop Chrome"] },
+ },
+ ],
+});
diff --git a/apps/web/playwright.production.config.ts b/apps/web/playwright.production.config.ts
new file mode 100644
index 00000000..88b58168
--- /dev/null
+++ b/apps/web/playwright.production.config.ts
@@ -0,0 +1,40 @@
+import { defineConfig, devices } from "@playwright/test";
+
+export default defineConfig({
+ testDir: "./e2e",
+ timeout: 90_000,
+ expect: {
+ timeout: 15_000,
+ },
+ use: {
+ baseURL: "http://127.0.0.1:3001",
+ trace: "on-first-retry",
+ },
+ webServer: [
+ {
+ command: "pnpm --filter api-ground-codes start",
+ cwd: "../..",
+ url: "http://127.0.0.1:3000/readyz",
+ reuseExistingServer: !process.env.CI,
+ timeout: 60_000,
+ },
+ {
+ command:
+ "NEXT_PUBLIC_GROUND_CODES_API_URL=http://127.0.0.1:3000 pnpm --filter web build && NEXT_PUBLIC_GROUND_CODES_API_URL=http://127.0.0.1:3000 pnpm --filter web exec next start -p 3001",
+ cwd: "../..",
+ url: "http://127.0.0.1:3001",
+ reuseExistingServer: false,
+ timeout: 120_000,
+ },
+ ],
+ projects: [
+ {
+ name: "desktop-chromium",
+ use: { ...devices["Desktop Chrome"] },
+ },
+ {
+ name: "mobile-chromium",
+ use: { ...devices["Pixel 5"] },
+ },
+ ],
+});
diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json
index b0cacf41..daca4a3e 100644
--- a/apps/web/tsconfig.json
+++ b/apps/web/tsconfig.json
@@ -19,5 +19,5 @@
"next.config.js",
".next/types/**/*.ts"
],
- "exclude": ["node_modules"]
+ "exclude": ["node_modules", "**/*.test.ts"]
}
diff --git a/packages/geoint/src/index.ts b/packages/geoint/src/index.ts
index b44ee337..af089694 100644
--- a/packages/geoint/src/index.ts
+++ b/packages/geoint/src/index.ts
@@ -30,6 +30,7 @@ export const load = async (loadRegions: string[] = []) => {
for (const file of files) {
const regionName = file.replace(".index", "");
if (loadRegions.length > 0 && !loadRegions.includes(regionName)) continue;
+ if (regionIndexes[regionName] && regionLevels[regionName]) continue;
const filePath = path.join(dbPath, file);
const index = KDBush.from(fs.readFileSync(filePath).buffer);
diff --git a/packages/ground-codes/src/index.ts b/packages/ground-codes/src/index.ts
index 438ed832..f4bac949 100644
--- a/packages/ground-codes/src/index.ts
+++ b/packages/ground-codes/src/index.ts
@@ -1,4 +1,8 @@
-import { findClosestRegion, findRegionByCodeOrName } from "./region.js";
+import {
+ findClosestRegion,
+ findRegionByCodeOrName,
+ findRegionsByQuery,
+} from "./region.js";
import {
CelestialBody,
calculateCoordinateDiff,
@@ -248,6 +252,7 @@ export {
setSpiralCacheEnabled,
findClosestRegion,
findRegionByCodeOrName,
+ findRegionsByQuery,
encodeByWordSet,
decodeByWordSet,
calculateCoordinateDiff,
diff --git a/packages/ground-codes/src/region.ts b/packages/ground-codes/src/region.ts
index 988a1c8b..0b0a67c5 100644
--- a/packages/ground-codes/src/region.ts
+++ b/packages/ground-codes/src/region.ts
@@ -13,10 +13,12 @@ export interface Region {
body?: CelestialBody;
regionLevel?: number;
distanceKm?: number;
+ population?: number;
}
const DEFAULT_REGION_2_FALLBACK_DISTANCE_KM = 100;
const DEFAULT_MARS_REGION_2_FALLBACK_DISTANCE_KM = 100;
+const regionDataCache = new Map>();
const loadRegions = async (
regionLevel: number,
@@ -24,46 +26,81 @@ const loadRegions = async (
body: CelestialBody = "earth",
): Promise => {
const normalizedLanguage = language?.toLowerCase();
+ const cacheKey = `${body}:${regionLevel}:${normalizedLanguage ?? "english"}`;
+ const cached = regionDataCache.get(cacheKey);
+ if (cached) return cached;
- if (body === "moon") {
- if (regionLevel !== 2) throw new Error("Moon supports region level 2");
- if (normalizedLanguage === "korean") {
- const module =
- // @ts-ignore
- await import(
- "@ground-codes/geoint/region-dist/region-2-moon-korean.json"
- );
- return module.default as Region[];
- }
- if (normalizedLanguage === "chinese") {
- const module =
- // @ts-ignore
- await import(
- "@ground-codes/geoint/region-dist/region-2-moon-chinese.json"
- );
- return module.default as Region[];
- }
- if (normalizedLanguage === "japanese") {
+ const load = async () => {
+ if (body === "moon") {
+ if (regionLevel !== 2) throw new Error("Moon supports region level 2");
+ if (normalizedLanguage === "korean") {
+ const module =
+ // @ts-ignore
+ await import(
+ "@ground-codes/geoint/region-dist/region-2-moon-korean.json"
+ );
+ return module.default as Region[];
+ }
+ if (normalizedLanguage === "chinese") {
+ const module =
+ // @ts-ignore
+ await import(
+ "@ground-codes/geoint/region-dist/region-2-moon-chinese.json"
+ );
+ return module.default as Region[];
+ }
+ if (normalizedLanguage === "japanese") {
+ const module =
+ // @ts-ignore
+ await import(
+ "@ground-codes/geoint/region-dist/region-2-moon-japanese.json"
+ );
+ return module.default as Region[];
+ }
const module =
// @ts-ignore
- await import(
- "@ground-codes/geoint/region-dist/region-2-moon-japanese.json"
- );
+ await import("@ground-codes/geoint/region-dist/region-2-moon.json");
return module.default as Region[];
}
- const module =
- // @ts-ignore
- await import("@ground-codes/geoint/region-dist/region-2-moon.json");
- return module.default as Region[];
- }
- if (body === "mars") {
- if (regionLevel === 3) {
+ if (body === "mars") {
+ if (regionLevel === 3) {
+ if (normalizedLanguage === "korean") {
+ const module =
+ // @ts-ignore
+ await import(
+ "@ground-codes/geoint/region-dist/region-3-mars-korean.json"
+ );
+ return module.default as Region[];
+ }
+ if (normalizedLanguage === "chinese") {
+ const module =
+ // @ts-ignore
+ await import(
+ "@ground-codes/geoint/region-dist/region-3-mars-chinese.json"
+ );
+ return module.default as Region[];
+ }
+ if (normalizedLanguage === "japanese") {
+ const module =
+ // @ts-ignore
+ await import(
+ "@ground-codes/geoint/region-dist/region-3-mars-japanese.json"
+ );
+ return module.default as Region[];
+ }
+ const module =
+ // @ts-ignore
+ await import("@ground-codes/geoint/region-dist/region-3-mars.json");
+ return module.default as Region[];
+ }
+ if (regionLevel !== 2)
+ throw new Error("Mars supports region levels 2 and 3");
if (normalizedLanguage === "korean") {
const module =
// @ts-ignore
await import(
- "@ground-codes/geoint/region-dist/region-3-mars-korean.json"
+ "@ground-codes/geoint/region-dist/region-2-mars-korean.json"
);
return module.default as Region[];
}
@@ -71,7 +108,7 @@ const loadRegions = async (
const module =
// @ts-ignore
await import(
- "@ground-codes/geoint/region-dist/region-3-mars-chinese.json"
+ "@ground-codes/geoint/region-dist/region-2-mars-chinese.json"
);
return module.default as Region[];
}
@@ -79,115 +116,96 @@ const loadRegions = async (
const module =
// @ts-ignore
await import(
- "@ground-codes/geoint/region-dist/region-3-mars-japanese.json"
+ "@ground-codes/geoint/region-dist/region-2-mars-japanese.json"
);
return module.default as Region[];
}
const module =
// @ts-ignore
- await import("@ground-codes/geoint/region-dist/region-3-mars.json");
- return module.default as Region[];
- }
- if (regionLevel !== 2)
- throw new Error("Mars supports region levels 2 and 3");
- if (normalizedLanguage === "korean") {
- const module =
- // @ts-ignore
- await import(
- "@ground-codes/geoint/region-dist/region-2-mars-korean.json"
- );
- return module.default as Region[];
- }
- if (normalizedLanguage === "chinese") {
- const module =
- // @ts-ignore
- await import(
- "@ground-codes/geoint/region-dist/region-2-mars-chinese.json"
- );
+ await import("@ground-codes/geoint/region-dist/region-2-mars.json");
return module.default as Region[];
}
- if (normalizedLanguage === "japanese") {
- const module =
- // @ts-ignore
- await import(
- "@ground-codes/geoint/region-dist/region-2-mars-japanese.json"
- );
- return module.default as Region[];
- }
- const module =
- // @ts-ignore
- await import("@ground-codes/geoint/region-dist/region-2-mars.json");
- return module.default as Region[];
- }
- if (regionLevel === 1) {
- const module =
- // @ts-ignore
- await import("@ground-codes/geoint/region-dist/region-1.json");
- return module.default as Region[];
- }
-
- if (regionLevel === 2) {
- if (!language || normalizedLanguage === "english") {
- const module =
- // @ts-ignore
- await import("@ground-codes/geoint/region-dist/region-2.json");
- return module.default as Region[];
- }
- if (normalizedLanguage === "korean") {
+ if (regionLevel === 1) {
const module =
// @ts-ignore
- await import("@ground-codes/geoint/region-dist/region-2-korean.json");
+ await import("@ground-codes/geoint/region-dist/region-1.json");
return module.default as Region[];
}
- if (normalizedLanguage === "chinese") {
- const module =
- // @ts-ignore
- await import("@ground-codes/geoint/region-dist/region-2-chinese.json");
- return module.default as Region[];
- }
- if (normalizedLanguage === "japanese") {
- const module =
- // @ts-ignore
- await import(
- "@ground-codes/geoint/region-dist/region-2-japanese.json"
- );
- return module.default as Region[];
- }
- throw new Error(`Invalid language: ${language}`);
- }
- if (regionLevel === 3) {
- if (!language || normalizedLanguage === "english") {
- const module =
- // @ts-ignore
- await import("@ground-codes/geoint/region-dist/region-3.json");
- return module.default as Region[];
- }
- if (normalizedLanguage === "korean") {
- const module =
- // @ts-ignore
- await import("@ground-codes/geoint/region-dist/region-3-korean.json");
- return module.default as Region[];
- }
- if (normalizedLanguage === "chinese") {
- const module =
- // @ts-ignore
- await import("@ground-codes/geoint/region-dist/region-3-chinese.json");
- return module.default as Region[];
+ if (regionLevel === 2) {
+ if (!language || normalizedLanguage === "english") {
+ const module =
+ // @ts-ignore
+ await import("@ground-codes/geoint/region-dist/region-2.json");
+ return module.default as Region[];
+ }
+ if (normalizedLanguage === "korean") {
+ const module =
+ // @ts-ignore
+ await import("@ground-codes/geoint/region-dist/region-2-korean.json");
+ return module.default as Region[];
+ }
+ if (normalizedLanguage === "chinese") {
+ const module =
+ // @ts-ignore
+ await import(
+ "@ground-codes/geoint/region-dist/region-2-chinese.json"
+ );
+ return module.default as Region[];
+ }
+ if (normalizedLanguage === "japanese") {
+ const module =
+ // @ts-ignore
+ await import(
+ "@ground-codes/geoint/region-dist/region-2-japanese.json"
+ );
+ return module.default as Region[];
+ }
+ throw new Error(`Invalid language: ${language}`);
}
- if (normalizedLanguage === "japanese") {
- const module =
- // @ts-ignore
- await import(
- "@ground-codes/geoint/region-dist/region-3-japanese.json"
- );
- return module.default as Region[];
+
+ if (regionLevel === 3) {
+ if (!language || normalizedLanguage === "english") {
+ const module =
+ // @ts-ignore
+ await import("@ground-codes/geoint/region-dist/region-3.json");
+ return module.default as Region[];
+ }
+ if (normalizedLanguage === "korean") {
+ const module =
+ // @ts-ignore
+ await import("@ground-codes/geoint/region-dist/region-3-korean.json");
+ return module.default as Region[];
+ }
+ if (normalizedLanguage === "chinese") {
+ const module =
+ // @ts-ignore
+ await import(
+ "@ground-codes/geoint/region-dist/region-3-chinese.json"
+ );
+ return module.default as Region[];
+ }
+ if (normalizedLanguage === "japanese") {
+ const module =
+ // @ts-ignore
+ await import(
+ "@ground-codes/geoint/region-dist/region-3-japanese.json"
+ );
+ return module.default as Region[];
+ }
+ throw new Error(`Invalid language: ${language}`);
}
- throw new Error(`Invalid language: ${language}`);
- }
- throw new Error(`Invalid region level: ${regionLevel}`);
+ throw new Error(`Invalid region level: ${regionLevel}`);
+ };
+
+ const promise = load().catch((error) => {
+ regionDataCache.delete(cacheKey);
+ throw error;
+ });
+ regionDataCache.set(cacheKey, promise);
+ return promise;
};
const findClosestInRegions = (
@@ -343,80 +361,123 @@ export const findRegionByCodeOrName = async (
name?: string;
code?: string;
} | null> => {
+ const matches = await findRegionsByQuery(codeOrName, {
+ ...options,
+ maxResults: 1,
+ });
+
+ return matches[0] ?? null;
+};
+
+export const findRegionsByQuery = async (
+ codeOrName: string,
+ options?: {
+ regionLevel?: number;
+ language?: SupportedLanguage;
+ body?: CelestialBody;
+ maxResults?: number;
+ },
+): Promise<
+ Array<{
+ lat: number;
+ lng: number;
+ regionLevel?: number;
+ body?: CelestialBody;
+ name?: string;
+ code?: string;
+ }>
+> => {
if (!codeOrName || codeOrName.trim() === "") {
- return null;
+ return [];
}
try {
- const { regionLevel = 2, language, body = "earth" } = options ?? {};
+ const {
+ regionLevel = 2,
+ language,
+ body = "earth",
+ maxResults = 5,
+ } = options ?? {};
- const regions = await loadRegions(regionLevel, language, body);
-
- // Normalize the search term for case-insensitive comparison
const normalizedSearch = codeOrName.toLowerCase().trim();
+ const results: Array<{
+ lat: number;
+ lng: number;
+ regionLevel?: number;
+ body?: CelestialBody;
+ name?: string;
+ code?: string;
+ }> = [];
+ const seen = new Set();
+
+ const addMatches = async (candidateRegionLevel: number) => {
+ const regions = await loadRegions(candidateRegionLevel, language, body);
+ const exactMatches = regions.filter(
+ (region) =>
+ region.code.toLowerCase() === normalizedSearch ||
+ region.name.toLowerCase() === normalizedSearch,
+ );
+ const partialMatches = regions
+ .filter(
+ (region) =>
+ !exactMatches.includes(region) &&
+ (region.code.toLowerCase().includes(normalizedSearch) ||
+ region.name.toLowerCase().includes(normalizedSearch)),
+ )
+ .sort((a, b) => {
+ const aName = a.name.toLowerCase();
+ const bName = b.name.toLowerCase();
+ const aCode = a.code.toLowerCase();
+ const bCode = b.code.toLowerCase();
+ const aRank = aName.startsWith(normalizedSearch)
+ ? 0
+ : aCode.startsWith(normalizedSearch)
+ ? 1
+ : 2;
+ const bRank = bName.startsWith(normalizedSearch)
+ ? 0
+ : bCode.startsWith(normalizedSearch)
+ ? 1
+ : 2;
+
+ return (
+ aRank - bRank ||
+ (b.population ?? 0) - (a.population ?? 0) ||
+ a.name.length - b.name.length
+ );
+ });
+
+ for (const region of [...exactMatches, ...partialMatches]) {
+ const key = `${body}:${candidateRegionLevel}:${region.code}:${region.name}`;
+ if (seen.has(key)) continue;
+ seen.add(key);
+ results.push({
+ name: region.name,
+ code: region.code,
+ lat: region.lat,
+ lng: region.long,
+ body,
+ regionLevel: candidateRegionLevel,
+ });
+ if (results.length >= maxResults) return;
+ }
+ };
- // Find the region that matches the code or name
- const matchedRegion = regions.find(
- (region) =>
- region.code.toLowerCase() === normalizedSearch ||
- region.name.toLowerCase() === normalizedSearch,
- );
-
- if (matchedRegion) {
- return {
- name: matchedRegion.name,
- code: matchedRegion.code,
- lat: matchedRegion.lat,
- lng: matchedRegion.long,
- body,
- regionLevel,
- };
- }
+ await addMatches(regionLevel);
+ if (results.length >= maxResults) return results;
if ((body === "earth" || body === "mars") && regionLevel === 2) {
const fallbackLevels = body === "mars" ? [3] : [1, 3];
for (const fallbackLevel of fallbackLevels) {
- const fallbackMatch = (
- await loadRegions(fallbackLevel, language, body)
- ).find(
- (region) =>
- region.code.toLowerCase() === normalizedSearch ||
- region.name.toLowerCase() === normalizedSearch,
- );
-
- if (fallbackMatch) {
- return {
- name: fallbackMatch.name,
- code: fallbackMatch.code,
- lat: fallbackMatch.lat,
- lng: fallbackMatch.long,
- body,
- regionLevel: fallbackLevel,
- };
- }
+ await addMatches(fallbackLevel);
+ if (results.length >= maxResults) return results;
}
}
- // If no exact match is found, try to find a region whose name contains the search term
- const partialMatch = regions.find((region) =>
- region.name.toLowerCase().includes(normalizedSearch),
- );
-
- if (partialMatch) {
- return {
- name: partialMatch.name,
- code: partialMatch.code,
- lat: partialMatch.lat,
- lng: partialMatch.long,
- body,
- regionLevel,
- };
- }
-
- return null;
+ return results;
} catch (e) {
console.error("Error finding region by code or name:", e);
- return null;
+ return [];
}
};
diff --git a/packages/ground-codes/tsup.config.ts b/packages/ground-codes/tsup.config.ts
index 083ba693..0069aea0 100644
--- a/packages/ground-codes/tsup.config.ts
+++ b/packages/ground-codes/tsup.config.ts
@@ -9,6 +9,7 @@ export default defineConfig({
},
},
splitting: true,
+ minify: true,
sourcemap: false,
clean: true,
});
diff --git a/packages/ui/src/components/grid-canvas.tsx b/packages/ui/src/components/grid-canvas.tsx
index 4ee7e6f0..ee88f0e9 100644
--- a/packages/ui/src/components/grid-canvas.tsx
+++ b/packages/ui/src/components/grid-canvas.tsx
@@ -1,6 +1,12 @@
"use client";
import { useEffect, useRef } from "react";
+const COLORS = {
+ background: "#000000",
+ label: "#FFFFFF",
+ gridLine: "#FFFFFF",
+};
+
export interface GridCanvasProps {
gridSize?: number;
canvasSize?: number;
@@ -33,13 +39,6 @@ export default function GridCanvas({
const canvasRef = useRef(null);
- // 색상 팔레트
- const COLORS = {
- background: "#000000",
- label: "#FFFFFF",
- gridLine: "#FFFFFF",
- };
-
const drawArrow = (
ctx: CanvasRenderingContext2D,
fromX: number,
diff --git a/packages/ui/src/components/spiral-viewer.tsx b/packages/ui/src/components/spiral-viewer.tsx
index c21b4823..b4514b5f 100644
--- a/packages/ui/src/components/spiral-viewer.tsx
+++ b/packages/ui/src/components/spiral-viewer.tsx
@@ -187,7 +187,7 @@ export default function SpiralViewer({
: "text-gray-400"
}`}
>
- N'=
+ N'=
{getCoordinates && getNFromCoordinates
? (() => {
const coord = getCoordinates(n);
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 4f5a4ffc..d8226a8b 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -59,7 +59,7 @@ importers:
version: link:../../packages/ui
next:
specifier: 15.5.18
- version: 15.5.18(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
+ version: 15.5.18(@opentelemetry/api@1.9.0)(@playwright/test@1.60.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
react:
specifier: ^19.0.0
version: 19.0.0
@@ -120,10 +120,10 @@ importers:
version: 1.141.0
next:
specifier: 15.5.18
- version: 15.5.18(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
+ version: 15.5.18(@opentelemetry/api@1.9.0)(@playwright/test@1.60.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
next-intl:
specifier: ^4.0.2
- version: 4.0.2(next@15.5.18(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)(typescript@5.8.2)
+ version: 4.0.2(next@15.5.18(@opentelemetry/api@1.9.0)(@playwright/test@1.60.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)(typescript@5.8.2)
react:
specifier: ^19.0.0
version: 19.0.0
@@ -140,6 +140,9 @@ importers:
'@eslint/eslintrc':
specifier: ^3
version: 3.3.0
+ '@playwright/test':
+ specifier: ^1.56.1
+ version: 1.60.0
'@repo/eslint-config':
specifier: workspace:*
version: link:../../packages/eslint-config
@@ -1684,6 +1687,11 @@ packages:
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'}
+ '@playwright/test@1.60.0':
+ resolution: {integrity: sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==}
+ engines: {node: '>=18'}
+ hasBin: true
+
'@radix-ui/number@1.1.0':
resolution: {integrity: sha512-V3gRzhVNU1ldS5XhAPTom1fOIo4ccrjjJgmE+LI2h/WaFpHmx0MQApT+KZHnx8abG6Avtfcz4WoEciMnpFT3HQ==}
@@ -3360,6 +3368,11 @@ packages:
fs.realpath@1.0.0:
resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==}
+ fsevents@2.3.2:
+ resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
+ engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
+ os: [darwin]
+
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -4368,6 +4381,16 @@ packages:
resolution: {integrity: sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==}
engines: {node: '>= 6'}
+ playwright-core@1.60.0:
+ resolution: {integrity: sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==}
+ engines: {node: '>=18'}
+ hasBin: true
+
+ playwright@1.60.0:
+ resolution: {integrity: sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==}
+ engines: {node: '>=18'}
+ hasBin: true
+
possible-typed-array-names@1.1.0:
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
engines: {node: '>= 0.4'}
@@ -6349,6 +6372,10 @@ snapshots:
'@pkgjs/parseargs@0.11.0':
optional: true
+ '@playwright/test@1.60.0':
+ dependencies:
+ playwright: 1.60.0
+
'@radix-ui/number@1.1.0': {}
'@radix-ui/primitive@1.1.1': {}
@@ -8128,6 +8155,9 @@ snapshots:
fs.realpath@1.0.0: {}
+ fsevents@2.3.2:
+ optional: true
+
fsevents@2.3.3:
optional: true
@@ -8828,17 +8858,17 @@ snapshots:
negotiator@1.0.0: {}
- next-intl@4.0.2(next@15.5.18(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)(typescript@5.8.2):
+ next-intl@4.0.2(next@15.5.18(@opentelemetry/api@1.9.0)(@playwright/test@1.60.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)(typescript@5.8.2):
dependencies:
'@formatjs/intl-localematcher': 0.5.10
negotiator: 1.0.0
- next: 15.5.18(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
+ next: 15.5.18(@opentelemetry/api@1.9.0)(@playwright/test@1.60.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
react: 19.0.0
use-intl: 4.0.2(react@19.0.0)
optionalDependencies:
typescript: 5.8.2
- next@15.5.18(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
+ next@15.5.18(@opentelemetry/api@1.9.0)(@playwright/test@1.60.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
dependencies:
'@next/env': 15.5.18
'@swc/helpers': 0.5.15
@@ -8857,6 +8887,7 @@ snapshots:
'@next/swc-win32-arm64-msvc': 15.5.18
'@next/swc-win32-x64-msvc': 15.5.18
'@opentelemetry/api': 1.9.0
+ '@playwright/test': 1.60.0
sharp: 0.34.5
transitivePeerDependencies:
- '@babel/core'
@@ -9078,6 +9109,14 @@ snapshots:
pirates@4.0.6: {}
+ playwright-core@1.60.0: {}
+
+ playwright@1.60.0:
+ dependencies:
+ playwright-core: 1.60.0
+ optionalDependencies:
+ fsevents: 2.3.2
+
possible-typed-array-names@1.1.0: {}
postcss-load-config@6.0.1(jiti@2.4.2)(postcss@8.5.3)(tsx@4.19.3)(yaml@2.7.0):
diff --git a/turbo.json b/turbo.json
index 1143ccee..49c60367 100644
--- a/turbo.json
+++ b/turbo.json
@@ -1,17 +1,35 @@
{
"$schema": "https://turbo.build/schema.json",
"ui": "tui",
+ "globalEnv": [
+ "GOOGLE_MAPS_NODEJS_API_KEY",
+ "OPENWEATHER_API_KEY",
+ "NEXT_PUBLIC_GOOGLE_MAPS_API_KEY",
+ "NEXT_PUBLIC_API_BASE_URL"
+ ],
"tasks": {
"build": {
"dependsOn": ["^build"],
+ "env": [
+ "GOOGLE_MAPS_NODEJS_API_KEY",
+ "OPENWEATHER_API_KEY",
+ "NEXT_PUBLIC_GOOGLE_MAPS_API_KEY",
+ "NEXT_PUBLIC_API_BASE_URL"
+ ],
"inputs": ["$TURBO_DEFAULT$", ".env*"],
"outputs": [".next/**", "!.next/cache/**", "dist/**"]
},
"lint": {
- "dependsOn": ["^lint"]
+ "dependsOn": ["^lint"],
+ "env": [
+ "GOOGLE_MAPS_NODEJS_API_KEY",
+ "OPENWEATHER_API_KEY",
+ "NEXT_PUBLIC_GOOGLE_MAPS_API_KEY",
+ "NEXT_PUBLIC_API_BASE_URL"
+ ]
},
"check-types": {
- "dependsOn": ["^check-types"]
+ "dependsOn": ["^build", "^check-types"]
},
"dev": {
"cache": false,