diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e9eaeac8..5244e469 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,6 +33,12 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Check formatting + run: pnpm format:check + + - name: Check maintained source size + run: pnpm code:size-check + - name: Check API runtime pins run: pnpm runtime:check-pins @@ -45,6 +51,9 @@ jobs: - name: Report URL label data run: pnpm data:report-labels + - name: Lint + run: pnpm lint + - name: Type check run: pnpm check-types @@ -60,6 +69,9 @@ jobs: - name: Test web client run: pnpm --filter web test + - name: Build + run: pnpm build + - name: Install Playwright browser run: pnpm --filter web exec playwright install --with-deps chromium diff --git a/README.md b/README.md index c4eed5ca..a215f75c 100644 --- a/README.md +++ b/README.md @@ -32,16 +32,16 @@ And Ground Codes is a **multi-planetary addressing system** 🪐 that provides a - 🧠 **Simple and Memorable**: Just three words to identify any location precisely - 🌎 **Global Coverage**: Works anywhere in the world with a unique address - 🔓 **Open Source**: MIT licensed and fully transparent implementation -- 🌐 **Multilingual Support**: Currently available in English, Korean, Chinese, Japanese, Spanish, French, German, Portuguese, Indonesian, Thai, and Vietnamese, with plans to expand to 60 languages +- 🌐 **Multilingual Support**: 180 language sets include codebooks, localized UI copy, and Earth, Moon, and Mars region labels. All 180 pass the automated structural, regression, and minimum-score gates; native-speaker review remains ongoing maintenance. - 🎯 **Variable Precision**: Offers three levels of precision (3m, 30cm, and 3cm) to suit different use cases ## 📊 Comparison with Similar Services -| Service | Format | License | Precision | Global Usage | Multilingual Support | -| ----------------------- | ----------------------- | --------------------- | ------------------------------------------- | ----------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | -| 🌍 **Ground Codes** | Yongsan-Happiness-Smile | ✅ MIT License (Free) | 1. 3 meters (standard)
2. 30cm
3. 3cm | ✅ Yes | 🌐 English, Korean, Chinese, Japanese, Spanish, French, German, Portuguese, Indonesian, Thai, Vietnamese (expanding to 60 languages) | -| 🔍 **Google Plus Code** | HX2F+J8 | ⚠️ No License (Free) | 3.5 meters | ⚠️ Limited (requires 4 additional characters for global use, e.g., **8Q94HX2F+J8**) | 🇬🇧 English only | -| 🔤 **What 3 Words** | ///teacher.awaken.days | 💰 Proprietary (Paid) | 3 meters | ✅ Yes | 🌐 60 languages | +| Service | Format | License | Precision | Global Usage | Multilingual Support | +| ----------------------- | ----------------------- | --------------------- | ------------------------------------------- | ----------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | +| 🌍 **Ground Codes** | Yongsan-Happiness-Smile | ✅ MIT License (Free) | 1. 3 meters (standard)
2. 30cm
3. 3cm | ✅ Yes | 🌐 180 automated-stable language sets; native-speaker review continues | +| 🔍 **Google Plus Code** | HX2F+J8 | ⚠️ No License (Free) | 3.5 meters | ⚠️ Limited (requires 4 additional characters for global use, e.g., **8Q94HX2F+J8**) | 🇬🇧 English only | +| 🔤 **What 3 Words** | ///teacher.awaken.days | 💰 Proprietary (Paid) | 3 meters | ✅ Yes | 🌐 60 languages | ## 💪 Advantages Over Existing Services @@ -78,7 +78,7 @@ API usage is limited to 600 requests per minute per IP. For higher volume needs, - 🇰🇷 Korean word set: 5,630 words (AI-generated dataset) - 🇨🇳 Chinese word set: 5,140 words (AI-generated dataset) - 🇯🇵 Japanese word set: 5,000 words (frequency-guided hiragana dataset) -- 🌐 Region labels: English, Korean, Chinese, Japanese, Spanish, French, German, Portuguese, Indonesian, Thai, and Vietnamese labels are available for Earth, Moon, and Mars datasets +- 🌐 Language coverage: 180 codebooks and localized UI/region-label sets are available for Earth, Moon, and Mars datasets - 🌎 Region names: 210,000 unique global locations with populations of 500+ (GeoNames data, commercially usable) - 🔐 Special solutions: - **Region 1**: ✈️ Optimized for airports/logistics with country codes and airport codes (e.g., NYC-491AD, SSN-TA14C) diff --git a/apps/api-ground-codes/README.md b/apps/api-ground-codes/README.md index 31a415f4..30e48d9c 100644 --- a/apps/api-ground-codes/README.md +++ b/apps/api-ground-codes/README.md @@ -14,7 +14,7 @@ API Ground.codes is a RESTful API service built with Elysia.js and Bun that prov - 🔄 **Encode Coordinates**: Convert latitude and longitude to memorable ground codes - 🔍 **Decode Ground Codes**: Convert ground codes back to geographic coordinates - 🌎 **Region Information**: Get information about specific regions -- 🌐 **Multilingual Support**: Support for multiple languages (English, Korean, Chinese, Japanese, Spanish, French, German, Portuguese, Indonesian, Thai) +- 🌐 **Multilingual Support**: 180 automated-stable codebook, UI, and Earth/Moon/Mars region-label sets; native-speaker review remains ongoing maintenance - 🌕 **Planetary Bodies**: Encode Earth, Moon, and Mars coordinates with body-specific labels - 🎯 **Customizable Precision**: Adjust the precision of encoded locations - 📝 **Swagger Documentation**: Interactive API documentation @@ -180,6 +180,12 @@ web app, and API route metrics. It records per-check response times for `/readyz writes the timing table to the GitHub run summary. Add a `MOSHI_WEBHOOK_TOKEN` repository secret to send a webhook alert when the smoke workflow fails. +`GET /metrics` exposes Worker-isolate metrics. The counters describe the +Cloudflare Worker isolate that handled the metrics request and are not a +globally aggregated request history. Production smoke validates the endpoint's +schema and numeric invariants without assuming earlier requests reached the same +isolate. + ## 🔌 API Endpoints ### 🧩 Core Endpoints @@ -315,7 +321,7 @@ Response: The API supports various configuration options: - 🏙️ **Region Level**: Choose between city names (level 2) or airport codes (level 1) -- 🌐 **Language**: Select from supported languages (English, Korean, Chinese, Japanese, Spanish, French, German, Portuguese, Indonesian, Thai) +- 🌐 **Language**: Select from 180 automated-stable language sets - 📏 **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 diff --git a/apps/api-ground-codes/src/app-part-2.test.ts b/apps/api-ground-codes/src/app-part-2.test.ts new file mode 100644 index 00000000..9a211c39 --- /dev/null +++ b/apps/api-ground-codes/src/app-part-2.test.ts @@ -0,0 +1,224 @@ +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("encodes and searches Indonesian ground codes and region labels", async () => { + const encodedResponse = await postJson("/v1/encode", { + lat: -6.1751, + lng: 106.865, + language: "indonesian", + regionLevel: 2, + }); + expect(encodedResponse.status).toBe(200); + const code = await encodedResponse.text(); + expect(code).toMatch(/^Jakarta-[A-Z][A-Za-z]+/); + + const codeSearchResponse = await postJson("/v1/search", { + query: code, + language: "english", + regionLevel: 2, + }); + expect(codeSearchResponse.status).toBe(200); + const codeSearch = await codeSearchResponse.json(); + expect(codeSearch.results[0]).toMatchObject({ + type: "ground-code", + label: code, + body: "earth", + regionLevel: 2, + }); + + const regionSearchResponse = await postJson("/v1/search", { + query: "Jakarta", + language: "indonesian", + regionLevel: 2, + }); + expect(regionSearchResponse.status).toBe(200); + const regionSearch = await regionSearchResponse.json(); + expect(regionSearch.results[0]).toMatchObject({ + type: "region", + label: "Jakarta", + body: "earth", + regionLevel: 2, + }); + }, 90_000); + + test("encodes and searches Thai ground codes and region labels", async () => { + const encodedResponse = await postJson("/v1/encode", { + lat: 13.7563, + lng: 100.5018, + language: "thai", + regionLevel: 2, + }); + expect(encodedResponse.status).toBe(200); + const code = await encodedResponse.text(); + expect(code).toMatch(/^กรุงเทพมหานคร-[\p{Script=Thai}]+/u); + + const codeSearchResponse = await postJson("/v1/search", { + query: code, + language: "english", + regionLevel: 2, + }); + expect(codeSearchResponse.status).toBe(200); + const codeSearch = await codeSearchResponse.json(); + expect(codeSearch.results[0]).toMatchObject({ + type: "ground-code", + label: code, + body: "earth", + regionLevel: 2, + }); + + const regionSearchResponse = await postJson("/v1/search", { + query: "กรุงเทพ", + language: "thai", + regionLevel: 2, + }); + expect(regionSearchResponse.status).toBe(200); + const regionSearch = await regionSearchResponse.json(); + expect(regionSearch.results[0]).toMatchObject({ + type: "region", + label: "กรุงเทพมหานคร", + body: "earth", + regionLevel: 2, + }); + }, 90_000); + + test("encodes and searches Vietnamese ground codes and region labels", async () => { + const encodedResponse = await postJson("/v1/encode", { + lat: 21.0278, + lng: 105.8342, + language: "vietnamese", + regionLevel: 2, + }); + expect(encodedResponse.status).toBe(200); + const code = await encodedResponse.text(); + expect(code).toMatch(/^Hà Nội-[\p{Script=Latin}\p{Mark}]+/u); + + const codeSearchResponse = await postJson("/v1/search", { + query: code, + language: "english", + regionLevel: 2, + }); + expect(codeSearchResponse.status).toBe(200); + const codeSearch = await codeSearchResponse.json(); + expect(codeSearch.results[0]).toMatchObject({ + type: "ground-code", + label: code, + body: "earth", + regionLevel: 2, + }); + + const regionSearchResponse = await postJson("/v1/search", { + query: "Hà Nội", + language: "vietnamese", + regionLevel: 2, + }); + expect(regionSearchResponse.status).toBe(200); + const regionSearch = await regionSearchResponse.json(); + expect(regionSearch.results[0]).toMatchObject({ + type: "region", + label: "Hà Nội", + body: "earth", + regionLevel: 2, + }); + }, 90_000); + + test("encodes and searches Hindi ground codes and region labels", async () => { + const encodedResponse = await postJson("/v1/encode", { + lat: 28.65195, + lng: 77.23149, + language: "hindi", + regionLevel: 2, + }); + expect(encodedResponse.status).toBe(200); + const code = await encodedResponse.text(); + expect(code).toMatch(/^दिल्ली-[\p{Script=Devanagari}\p{Mark}]+/u); + + const codeSearchResponse = await postJson("/v1/search", { + query: code, + language: "english", + regionLevel: 2, + }); + expect(codeSearchResponse.status).toBe(200); + const codeSearch = await codeSearchResponse.json(); + expect(codeSearch.results[0]).toMatchObject({ + type: "ground-code", + label: code, + body: "earth", + regionLevel: 2, + }); + + const regionSearchResponse = await postJson("/v1/search", { + query: "दिल्ली", + language: "hindi", + regionLevel: 2, + }); + expect(regionSearchResponse.status).toBe(200); + const regionSearch = await regionSearchResponse.json(); + expect(regionSearch.results[0]).toMatchObject({ + type: "region", + label: "दिल्ली", + body: "earth", + regionLevel: 2, + }); + }, 90_000); + + test("encodes and searches Arabic ground codes and region labels", async () => { + const encodedResponse = await postJson("/v1/encode", { + lat: 30.0444, + lng: 31.2357, + language: "arabic", + regionLevel: 2, + }); + expect(encodedResponse.status).toBe(200); + const code = await encodedResponse.text(); + expect(code).toMatch(/^القاهرة-[\p{Script=Arabic}\p{Mark}]+/u); + + const codeSearchResponse = await postJson("/v1/search", { + query: code, + language: "english", + regionLevel: 2, + }); + expect(codeSearchResponse.status).toBe(200); + const codeSearch = await codeSearchResponse.json(); + expect(codeSearch.results[0]).toMatchObject({ + type: "ground-code", + label: code, + body: "earth", + regionLevel: 2, + }); + + const regionSearchResponse = await postJson("/v1/search", { + query: "القاهرة", + language: "arabic", + regionLevel: 2, + }); + expect(regionSearchResponse.status).toBe(200); + const regionSearch = await regionSearchResponse.json(); + expect(regionSearch.results[0]).toMatchObject({ + type: "region", + label: "القاهرة", + body: "earth", + regionLevel: 2, + }); + }, 90_000); +}); diff --git a/apps/api-ground-codes/src/app-part-3.test.ts b/apps/api-ground-codes/src/app-part-3.test.ts new file mode 100644 index 00000000..311ca6f9 --- /dev/null +++ b/apps/api-ground-codes/src/app-part-3.test.ts @@ -0,0 +1,402 @@ +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("encodes and searches Russian ground codes and region labels", async () => { + const encodedResponse = await postJson("/v1/encode", { + lat: 55.7558, + lng: 37.6173, + language: "russian", + regionLevel: 2, + }); + expect(encodedResponse.status).toBe(200); + const code = await encodedResponse.text(); + expect(code).toMatch(/^Москва-[\p{Script=Cyrillic}\p{Mark}]+/u); + + const codeSearchResponse = await postJson("/v1/search", { + query: code, + language: "english", + regionLevel: 2, + }); + expect(codeSearchResponse.status).toBe(200); + const codeSearch = await codeSearchResponse.json(); + expect(codeSearch.results[0]).toMatchObject({ + type: "ground-code", + label: code, + body: "earth", + regionLevel: 2, + }); + + const regionSearchResponse = await postJson("/v1/search", { + query: "Москва", + language: "russian", + regionLevel: 2, + }); + expect(regionSearchResponse.status).toBe(200); + const regionSearch = await regionSearchResponse.json(); + expect(regionSearch.results[0]).toMatchObject({ + type: "region", + label: "Москва", + body: "earth", + regionLevel: 2, + }); + }, 90_000); + + test.each([ + { + name: "Swahili", + language: "swahili", + lat: -6.1751, + lng: 106.865, + region: "Jakarta", + codePattern: /^Jakarta-[A-Z][A-Za-z]+/u, + }, + { + name: "Filipino", + language: "filipino", + lat: -6.1751, + lng: 106.865, + region: "Jakarta", + codePattern: /^Jakarta-[A-Z][A-Za-z]+/u, + }, + { + name: "Hausa", + language: "hausa", + lat: 30.0444, + lng: 31.2357, + region: "Alkahira", + codePattern: /^Alkahira-[A-Z][A-Za-z]+/u, + }, + { + name: "Bengali", + language: "bengali", + lat: 28.65195, + lng: 77.23149, + region: "দিল্লি", + codePattern: /^দিল্লি-[\p{Script=Bengali}\p{Mark}]+/u, + }, + { + name: "Urdu", + language: "urdu", + lat: 28.65195, + lng: 77.23149, + region: "دہلی", + codePattern: /^دہلی-[\p{Script=Arabic}\p{Mark}]+/u, + }, + { + name: "Amharic", + language: "amharic", + lat: 30.0444, + lng: 31.2357, + region: "ካይሮ", + codePattern: /^ካይሮ-[\p{Script=Ethiopic}\p{Mark}]+/u, + }, + { + name: "Burmese", + language: "burmese", + lat: -6.1751, + lng: 106.865, + region: "ဂျာကာတာ", + codePattern: /^ဂျာကာတာ-[\p{Script=Myanmar}\p{Mark}]+/u, + }, + { + name: "Khmer", + language: "khmer", + lat: -6.1751, + lng: 106.865, + region: "ចាការតា", + codePattern: /^ចាការតា-[\p{Script=Khmer}\p{Mark}]+/u, + }, + { + name: "Nepali", + language: "nepali", + lat: 28.65195, + lng: 77.23149, + region: "दिल्ली", + codePattern: /^दिल्ली-[\p{Script=Devanagari}\p{Mark}]+/u, + }, + { + name: "Somali", + language: "somali", + lat: 30.0444, + lng: 31.2357, + region: "Qaahira", + codePattern: /^Qaahira-[A-Z][A-Za-z]+/u, + }, + { + name: "Pashto", + language: "pashto", + lat: 28.65195, + lng: 77.23149, + region: "ډیلي", + codePattern: /^ډیلي-[\p{Script=Arabic}\p{Mark}]+/u, + }, + { + name: "Lingala", + language: "lingala", + lat: -6.1751, + lng: 106.865, + region: "Jakarta", + codePattern: /^Jakarta-[A-Z][A-Za-z]+/u, + }, + ])( + "encodes and searches $name address-gap ground codes and region labels", + async ({ language, lat, lng, region, codePattern }) => { + const encodedResponse = await postJson("/v1/encode", { + lat, + lng, + language, + regionLevel: 2, + }); + expect(encodedResponse.status).toBe(200); + const code = await encodedResponse.text(); + expect(code).toMatch(codePattern); + + const codeSearchResponse = await postJson("/v1/search", { + query: code, + language: "english", + regionLevel: 2, + }); + expect(codeSearchResponse.status).toBe(200); + const codeSearch = await codeSearchResponse.json(); + expect(codeSearch.results[0]).toMatchObject({ + type: "ground-code", + label: code, + body: "earth", + regionLevel: 2, + }); + + const regionSearchResponse = await postJson("/v1/search", { + query: region, + language, + regionLevel: 2, + }); + expect(regionSearchResponse.status).toBe(200); + const regionSearch = await regionSearchResponse.json(); + expect(regionSearch.results[0]).toMatchObject({ + type: "region", + label: region, + body: "earth", + regionLevel: 2, + }); + }, + 90_000, + ); + + 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 ranks ambiguous region names by the supplied map-center bias", async () => { + const response = await postJson("/v1/search", { + query: "Springfield", + language: "english", + regionLevel: 2, + maxResults: 1, + biasLat: 42.1, + biasLng: -72.6, + }); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.results[0]).toMatchObject({ + type: "region", + label: "West Springfield", + body: "earth", + regionLevel: 2, + }); + }); + + 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("search resolves longer localized Moon and Mars ground codes", async () => { + for (const sample of [ + { + query: "고요의 바다-안방-구두솔가방", + body: "moon", + }, + { + query: "보하르 크레이터 2-달력장-메밀-카스테라", + body: "mars", + }, + ]) { + const response = await postJson("/v1/search", { + query: sample.query, + language: "korean", + body: sample.body, + regionLevel: 2, + maxResults: 1, + }); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.results[0]).toMatchObject({ + type: "ground-code", + label: sample.query, + body: sample.body, + regionLevel: 2, + }); + expect(body.results[0].lat).toBeNumber(); + expect(body.results[0].lng).toBeNumber(); + } + }, 90_000); + + 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("loads Spanish region lookup data on demand for region endpoints", async () => { + const aroundResponse = await postJson("/v1/region/around", { + lat: 37.566, + lng: 126.978, + language: "spanish", + regionLevel: 2, + maxResults: 1, + }); + + expect(aroundResponse.status).toBe(200); + const around = await aroundResponse.json(); + expect(around[0]).toMatchObject({ + name: "Seul", + }); + + const infoResponse = await postJson("/v1/region/info", { + name: "Seul", + language: "spanish", + regionLevel: 2, + body: "earth", + }); + expect(infoResponse.status).toBe(200); + expect(await infoResponse.json()).toMatchObject({ + name: "Seul", + }); + }); + + test("loads French region lookup data on demand for region endpoints", async () => { + const aroundResponse = await postJson("/v1/region/around", { + lat: 37.566, + lng: 126.978, + language: "french", + regionLevel: 2, + maxResults: 1, + }); + + expect(aroundResponse.status).toBe(200); + const around = await aroundResponse.json(); + expect(around[0]).toMatchObject({ + name: "Seoul", + }); + + const infoResponse = await postJson("/v1/region/info", { + name: "Seoul", + language: "french", + regionLevel: 2, + body: "earth", + }); + expect(infoResponse.status).toBe(200); + expect(await infoResponse.json()).toMatchObject({ + name: "Seoul", + }); + }); +}); diff --git a/apps/api-ground-codes/src/app-part-4.test.ts b/apps/api-ground-codes/src/app-part-4.test.ts new file mode 100644 index 00000000..9708dd0c --- /dev/null +++ b/apps/api-ground-codes/src/app-part-4.test.ts @@ -0,0 +1,353 @@ +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("loads Portuguese region lookup data on demand for region endpoints", async () => { + const aroundResponse = await postJson("/v1/region/around", { + lat: 37.566, + lng: 126.978, + language: "portuguese", + regionLevel: 2, + maxResults: 1, + }); + + expect(aroundResponse.status).toBe(200); + const around = await aroundResponse.json(); + expect(around[0]).toMatchObject({ + name: "Seul", + }); + + const infoResponse = await postJson("/v1/region/info", { + name: "Lisboa", + language: "portuguese", + regionLevel: 2, + body: "earth", + }); + expect(infoResponse.status).toBe(200); + expect(await infoResponse.json()).toMatchObject({ + name: "Lisboa", + }); + }); + + test("loads Indonesian region lookup data on demand for region endpoints", async () => { + const aroundResponse = await postJson("/v1/region/around", { + lat: -6.1751, + lng: 106.865, + language: "indonesian", + regionLevel: 2, + maxResults: 1, + }); + + expect(aroundResponse.status).toBe(200); + const around = await aroundResponse.json(); + expect(around[0]).toMatchObject({ + name: "Jakarta", + }); + + const infoResponse = await postJson("/v1/region/info", { + name: "Jakarta", + language: "indonesian", + regionLevel: 2, + body: "earth", + }); + expect(infoResponse.status).toBe(200); + expect(await infoResponse.json()).toMatchObject({ + name: "Jakarta", + }); + }); + + test("loads Thai region lookup data on demand for region endpoints", async () => { + const aroundResponse = await postJson("/v1/region/around", { + lat: 13.7563, + lng: 100.5018, + language: "thai", + regionLevel: 2, + maxResults: 1, + }); + + expect(aroundResponse.status).toBe(200); + const around = await aroundResponse.json(); + expect(around[0]).toMatchObject({ + name: "กรุงเทพมหานคร", + }); + + const infoResponse = await postJson("/v1/region/info", { + name: "กรุงเทพมหานคร", + language: "thai", + regionLevel: 2, + body: "earth", + }); + expect(infoResponse.status).toBe(200); + expect(await infoResponse.json()).toMatchObject({ + name: "กรุงเทพมหานคร", + }); + }); + + test("loads Vietnamese region lookup data on demand for region endpoints", async () => { + const aroundResponse = await postJson("/v1/region/around", { + lat: 21.0278, + lng: 105.8342, + language: "vietnamese", + regionLevel: 2, + maxResults: 1, + }); + + expect(aroundResponse.status).toBe(200); + const around = await aroundResponse.json(); + expect(around[0]).toMatchObject({ + name: "Hà Nội", + }); + + const infoResponse = await postJson("/v1/region/info", { + name: "Hà Nội", + language: "vietnamese", + regionLevel: 2, + body: "earth", + }); + expect(infoResponse.status).toBe(200); + expect(await infoResponse.json()).toMatchObject({ + name: "Hà Nội", + }); + }); + + test("loads Hindi region lookup data on demand for region endpoints", async () => { + const aroundResponse = await postJson("/v1/region/around", { + lat: 28.65195, + lng: 77.23149, + language: "hindi", + regionLevel: 2, + maxResults: 1, + }); + + expect(aroundResponse.status).toBe(200); + const around = await aroundResponse.json(); + expect(around[0]).toMatchObject({ + name: "दिल्ली", + }); + + const infoResponse = await postJson("/v1/region/info", { + name: "दिल्ली", + language: "hindi", + regionLevel: 2, + body: "earth", + }); + expect(infoResponse.status).toBe(200); + expect(await infoResponse.json()).toMatchObject({ + name: "दिल्ली", + }); + }); + + test("loads Arabic region lookup data on demand for region endpoints", async () => { + const aroundResponse = await postJson("/v1/region/around", { + lat: 30.0444, + lng: 31.2357, + language: "arabic", + regionLevel: 2, + maxResults: 1, + }); + + expect(aroundResponse.status).toBe(200); + const around = await aroundResponse.json(); + expect(around[0]).toMatchObject({ + name: "القاهرة", + }); + + const infoResponse = await postJson("/v1/region/info", { + name: "القاهرة", + language: "arabic", + regionLevel: 2, + body: "earth", + }); + expect(infoResponse.status).toBe(200); + expect(await infoResponse.json()).toMatchObject({ + name: "القاهرة", + }); + }); + + test.each([ + { + name: "Burmese", + language: "burmese", + lat: -6.1751, + lng: 106.865, + region: "ဂျာကာတာ", + }, + { + name: "Khmer", + language: "khmer", + lat: -6.1751, + lng: 106.865, + region: "ចាការតា", + }, + { + name: "Nepali", + language: "nepali", + lat: 28.65195, + lng: 77.23149, + region: "दिल्ली", + }, + { + name: "Somali", + language: "somali", + lat: 30.0444, + lng: 31.2357, + region: "Qaahira", + }, + { + name: "Pashto", + language: "pashto", + lat: 28.65195, + lng: 77.23149, + region: "ډیلي", + }, + { + name: "Lingala", + language: "lingala", + lat: -6.1751, + lng: 106.865, + region: "Jakarta", + }, + ])( + "loads $name region lookup data on demand for region endpoints", + async ({ language, lat, lng, region }) => { + const aroundResponse = await postJson("/v1/region/around", { + lat, + lng, + language, + regionLevel: 2, + maxResults: 1, + }); + + expect(aroundResponse.status).toBe(200); + const around = await aroundResponse.json(); + expect(around[0]).toMatchObject({ + name: region, + }); + + const infoResponse = await postJson("/v1/region/info", { + name: region, + language, + regionLevel: 2, + body: "earth", + }); + expect(infoResponse.status).toBe(200); + expect(await infoResponse.json()).toMatchObject({ + name: region, + }); + }, + ); + + test("returns a structured not found error for missing region info", async () => { + const response = await postJson("/v1/region/info", { + name: "not-a-real-region", + language: "english", + regionLevel: 2, + body: "earth", + }); + + expect(response.status).toBe(404); + expect(await response.json()).toMatchObject({ + error: { + code: "NOT_FOUND", + }, + }); + }); + + 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 client error for incomplete search bias", async () => { + const response = await postJson("/v1/search", { + query: "Springfield", + language: "english", + regionLevel: 2, + biasLat: 42.1, + }); + + expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({ + error: { + code: "INVALID_INPUT", + }, + }); + }); + + test("returns a structured client error for undecodable codes", async () => { + const response = await postJson("/v1/decode", { + code: "Seoul-notrealcode", + 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.test.ts b/apps/api-ground-codes/src/app.test.ts index ad5b851d..7b68c186 100644 --- a/apps/api-ground-codes/src/app.test.ts +++ b/apps/api-ground-codes/src/app.test.ts @@ -71,6 +71,7 @@ describe("Ground Codes API contract", () => { expect(response.status).toBe(200); const body = await response.json(); expect(body.service).toBe("api-ground-codes"); + expect(body.scope).toBe("worker-isolate"); expect(body.requests.total).toBeGreaterThan(0); }); @@ -92,6 +93,8 @@ describe("Ground Codes API contract", () => { expect(firstPartyDocs).toContain("https://api.ground.codes/v1/encode"); expect(firstPartyDocs).toContain("https://ground.codes/moon/"); expect(firstPartyDocs).toContain("curl https://api.ground.codes/metrics"); + expect(firstPartyDocs).toContain("Worker-isolate metrics"); + expect(firstPartyDocs).toContain("180 automated-stable language sets"); expect(firstPartyDocs).toContain("biasLat"); expect(firstPartyDocs).toContain("Copy-ready Examples"); expect(firstPartyDocs).toContain("https://api.ground.codes/v1/decode"); @@ -395,911 +398,4 @@ describe("Ground Codes API contract", () => { regionLevel: 2, }); }, 90_000); - - test("encodes and searches Indonesian ground codes and region labels", async () => { - const encodedResponse = await postJson("/v1/encode", { - lat: -6.1751, - lng: 106.865, - language: "indonesian", - regionLevel: 2, - }); - expect(encodedResponse.status).toBe(200); - const code = await encodedResponse.text(); - expect(code).toMatch(/^Jakarta-[A-Z][A-Za-z]+/); - - const codeSearchResponse = await postJson("/v1/search", { - query: code, - language: "english", - regionLevel: 2, - }); - expect(codeSearchResponse.status).toBe(200); - const codeSearch = await codeSearchResponse.json(); - expect(codeSearch.results[0]).toMatchObject({ - type: "ground-code", - label: code, - body: "earth", - regionLevel: 2, - }); - - const regionSearchResponse = await postJson("/v1/search", { - query: "Jakarta", - language: "indonesian", - regionLevel: 2, - }); - expect(regionSearchResponse.status).toBe(200); - const regionSearch = await regionSearchResponse.json(); - expect(regionSearch.results[0]).toMatchObject({ - type: "region", - label: "Jakarta", - body: "earth", - regionLevel: 2, - }); - }, 90_000); - - test("encodes and searches Thai ground codes and region labels", async () => { - const encodedResponse = await postJson("/v1/encode", { - lat: 13.7563, - lng: 100.5018, - language: "thai", - regionLevel: 2, - }); - expect(encodedResponse.status).toBe(200); - const code = await encodedResponse.text(); - expect(code).toMatch(/^กรุงเทพมหานคร-[\p{Script=Thai}]+/u); - - const codeSearchResponse = await postJson("/v1/search", { - query: code, - language: "english", - regionLevel: 2, - }); - expect(codeSearchResponse.status).toBe(200); - const codeSearch = await codeSearchResponse.json(); - expect(codeSearch.results[0]).toMatchObject({ - type: "ground-code", - label: code, - body: "earth", - regionLevel: 2, - }); - - const regionSearchResponse = await postJson("/v1/search", { - query: "กรุงเทพ", - language: "thai", - regionLevel: 2, - }); - expect(regionSearchResponse.status).toBe(200); - const regionSearch = await regionSearchResponse.json(); - expect(regionSearch.results[0]).toMatchObject({ - type: "region", - label: "กรุงเทพมหานคร", - body: "earth", - regionLevel: 2, - }); - }, 90_000); - - test("encodes and searches Vietnamese ground codes and region labels", async () => { - const encodedResponse = await postJson("/v1/encode", { - lat: 21.0278, - lng: 105.8342, - language: "vietnamese", - regionLevel: 2, - }); - expect(encodedResponse.status).toBe(200); - const code = await encodedResponse.text(); - expect(code).toMatch(/^Hà Nội-[\p{Script=Latin}\p{Mark}]+/u); - - const codeSearchResponse = await postJson("/v1/search", { - query: code, - language: "english", - regionLevel: 2, - }); - expect(codeSearchResponse.status).toBe(200); - const codeSearch = await codeSearchResponse.json(); - expect(codeSearch.results[0]).toMatchObject({ - type: "ground-code", - label: code, - body: "earth", - regionLevel: 2, - }); - - const regionSearchResponse = await postJson("/v1/search", { - query: "Hà Nội", - language: "vietnamese", - regionLevel: 2, - }); - expect(regionSearchResponse.status).toBe(200); - const regionSearch = await regionSearchResponse.json(); - expect(regionSearch.results[0]).toMatchObject({ - type: "region", - label: "Hà Nội", - body: "earth", - regionLevel: 2, - }); - }, 90_000); - - test("encodes and searches Hindi ground codes and region labels", async () => { - const encodedResponse = await postJson("/v1/encode", { - lat: 28.65195, - lng: 77.23149, - language: "hindi", - regionLevel: 2, - }); - expect(encodedResponse.status).toBe(200); - const code = await encodedResponse.text(); - expect(code).toMatch(/^दिल्ली-[\p{Script=Devanagari}\p{Mark}]+/u); - - const codeSearchResponse = await postJson("/v1/search", { - query: code, - language: "english", - regionLevel: 2, - }); - expect(codeSearchResponse.status).toBe(200); - const codeSearch = await codeSearchResponse.json(); - expect(codeSearch.results[0]).toMatchObject({ - type: "ground-code", - label: code, - body: "earth", - regionLevel: 2, - }); - - const regionSearchResponse = await postJson("/v1/search", { - query: "दिल्ली", - language: "hindi", - regionLevel: 2, - }); - expect(regionSearchResponse.status).toBe(200); - const regionSearch = await regionSearchResponse.json(); - expect(regionSearch.results[0]).toMatchObject({ - type: "region", - label: "दिल्ली", - body: "earth", - regionLevel: 2, - }); - }, 90_000); - - test("encodes and searches Arabic ground codes and region labels", async () => { - const encodedResponse = await postJson("/v1/encode", { - lat: 30.0444, - lng: 31.2357, - language: "arabic", - regionLevel: 2, - }); - expect(encodedResponse.status).toBe(200); - const code = await encodedResponse.text(); - expect(code).toMatch(/^القاهرة-[\p{Script=Arabic}\p{Mark}]+/u); - - const codeSearchResponse = await postJson("/v1/search", { - query: code, - language: "english", - regionLevel: 2, - }); - expect(codeSearchResponse.status).toBe(200); - const codeSearch = await codeSearchResponse.json(); - expect(codeSearch.results[0]).toMatchObject({ - type: "ground-code", - label: code, - body: "earth", - regionLevel: 2, - }); - - const regionSearchResponse = await postJson("/v1/search", { - query: "القاهرة", - language: "arabic", - regionLevel: 2, - }); - expect(regionSearchResponse.status).toBe(200); - const regionSearch = await regionSearchResponse.json(); - expect(regionSearch.results[0]).toMatchObject({ - type: "region", - label: "القاهرة", - body: "earth", - regionLevel: 2, - }); - }, 90_000); - - test("encodes and searches Russian ground codes and region labels", async () => { - const encodedResponse = await postJson("/v1/encode", { - lat: 55.7558, - lng: 37.6173, - language: "russian", - regionLevel: 2, - }); - expect(encodedResponse.status).toBe(200); - const code = await encodedResponse.text(); - expect(code).toMatch(/^Москва-[\p{Script=Cyrillic}\p{Mark}]+/u); - - const codeSearchResponse = await postJson("/v1/search", { - query: code, - language: "english", - regionLevel: 2, - }); - expect(codeSearchResponse.status).toBe(200); - const codeSearch = await codeSearchResponse.json(); - expect(codeSearch.results[0]).toMatchObject({ - type: "ground-code", - label: code, - body: "earth", - regionLevel: 2, - }); - - const regionSearchResponse = await postJson("/v1/search", { - query: "Москва", - language: "russian", - regionLevel: 2, - }); - expect(regionSearchResponse.status).toBe(200); - const regionSearch = await regionSearchResponse.json(); - expect(regionSearch.results[0]).toMatchObject({ - type: "region", - label: "Москва", - body: "earth", - regionLevel: 2, - }); - }, 90_000); - - test.each([ - { - name: "Swahili", - language: "swahili", - lat: -6.1751, - lng: 106.865, - region: "Jakarta", - codePattern: /^Jakarta-[A-Z][A-Za-z]+/u, - }, - { - name: "Filipino", - language: "filipino", - lat: -6.1751, - lng: 106.865, - region: "Jakarta", - codePattern: /^Jakarta-[A-Z][A-Za-z]+/u, - }, - { - name: "Hausa", - language: "hausa", - lat: 30.0444, - lng: 31.2357, - region: "Alkahira", - codePattern: /^Alkahira-[A-Z][A-Za-z]+/u, - }, - { - name: "Bengali", - language: "bengali", - lat: 28.65195, - lng: 77.23149, - region: "দিল্লি", - codePattern: /^দিল্লি-[\p{Script=Bengali}\p{Mark}]+/u, - }, - { - name: "Urdu", - language: "urdu", - lat: 28.65195, - lng: 77.23149, - region: "دہلی", - codePattern: /^دہلی-[\p{Script=Arabic}\p{Mark}]+/u, - }, - { - name: "Amharic", - language: "amharic", - lat: 30.0444, - lng: 31.2357, - region: "ካይሮ", - codePattern: /^ካይሮ-[\p{Script=Ethiopic}\p{Mark}]+/u, - }, - { - name: "Burmese", - language: "burmese", - lat: -6.1751, - lng: 106.865, - region: "ဂျာကာတာ", - codePattern: /^ဂျာကာတာ-[\p{Script=Myanmar}\p{Mark}]+/u, - }, - { - name: "Khmer", - language: "khmer", - lat: -6.1751, - lng: 106.865, - region: "ចាការតា", - codePattern: /^ចាការតា-[\p{Script=Khmer}\p{Mark}]+/u, - }, - { - name: "Nepali", - language: "nepali", - lat: 28.65195, - lng: 77.23149, - region: "दिल्ली", - codePattern: /^दिल्ली-[\p{Script=Devanagari}\p{Mark}]+/u, - }, - { - name: "Somali", - language: "somali", - lat: 30.0444, - lng: 31.2357, - region: "Qaahira", - codePattern: /^Qaahira-[A-Z][A-Za-z]+/u, - }, - { - name: "Pashto", - language: "pashto", - lat: 28.65195, - lng: 77.23149, - region: "ډیلي", - codePattern: /^ډیلي-[\p{Script=Arabic}\p{Mark}]+/u, - }, - { - name: "Lingala", - language: "lingala", - lat: -6.1751, - lng: 106.865, - region: "Jakarta", - codePattern: /^Jakarta-[A-Z][A-Za-z]+/u, - }, - ])( - "encodes and searches $name address-gap ground codes and region labels", - async ({ language, lat, lng, region, codePattern }) => { - const encodedResponse = await postJson("/v1/encode", { - lat, - lng, - language, - regionLevel: 2, - }); - expect(encodedResponse.status).toBe(200); - const code = await encodedResponse.text(); - expect(code).toMatch(codePattern); - - const codeSearchResponse = await postJson("/v1/search", { - query: code, - language: "english", - regionLevel: 2, - }); - expect(codeSearchResponse.status).toBe(200); - const codeSearch = await codeSearchResponse.json(); - expect(codeSearch.results[0]).toMatchObject({ - type: "ground-code", - label: code, - body: "earth", - regionLevel: 2, - }); - - const regionSearchResponse = await postJson("/v1/search", { - query: region, - language, - regionLevel: 2, - }); - expect(regionSearchResponse.status).toBe(200); - const regionSearch = await regionSearchResponse.json(); - expect(regionSearch.results[0]).toMatchObject({ - type: "region", - label: region, - body: "earth", - regionLevel: 2, - }); - }, - 90_000, - ); - - 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 ranks ambiguous region names by the supplied map-center bias", async () => { - const response = await postJson("/v1/search", { - query: "Springfield", - language: "english", - regionLevel: 2, - maxResults: 1, - biasLat: 42.1, - biasLng: -72.6, - }); - - expect(response.status).toBe(200); - const body = await response.json(); - expect(body.results[0]).toMatchObject({ - type: "region", - label: "West Springfield", - body: "earth", - regionLevel: 2, - }); - }); - - 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("search resolves longer localized Moon and Mars ground codes", async () => { - for (const sample of [ - { - query: "고요의 바다-안방-구두솔가방", - body: "moon", - }, - { - query: "보하르 크레이터 2-달력장-메밀-카스테라", - body: "mars", - }, - ]) { - const response = await postJson("/v1/search", { - query: sample.query, - language: "korean", - body: sample.body, - regionLevel: 2, - maxResults: 1, - }); - - expect(response.status).toBe(200); - const body = await response.json(); - expect(body.results[0]).toMatchObject({ - type: "ground-code", - label: sample.query, - body: sample.body, - regionLevel: 2, - }); - expect(body.results[0].lat).toBeNumber(); - expect(body.results[0].lng).toBeNumber(); - } - }, 90_000); - - 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("loads Spanish region lookup data on demand for region endpoints", async () => { - const aroundResponse = await postJson("/v1/region/around", { - lat: 37.566, - lng: 126.978, - language: "spanish", - regionLevel: 2, - maxResults: 1, - }); - - expect(aroundResponse.status).toBe(200); - const around = await aroundResponse.json(); - expect(around[0]).toMatchObject({ - name: "Seul", - }); - - const infoResponse = await postJson("/v1/region/info", { - name: "Seul", - language: "spanish", - regionLevel: 2, - body: "earth", - }); - expect(infoResponse.status).toBe(200); - expect(await infoResponse.json()).toMatchObject({ - name: "Seul", - }); - }); - - test("loads French region lookup data on demand for region endpoints", async () => { - const aroundResponse = await postJson("/v1/region/around", { - lat: 37.566, - lng: 126.978, - language: "french", - regionLevel: 2, - maxResults: 1, - }); - - expect(aroundResponse.status).toBe(200); - const around = await aroundResponse.json(); - expect(around[0]).toMatchObject({ - name: "Seoul", - }); - - const infoResponse = await postJson("/v1/region/info", { - name: "Seoul", - language: "french", - regionLevel: 2, - body: "earth", - }); - expect(infoResponse.status).toBe(200); - expect(await infoResponse.json()).toMatchObject({ - name: "Seoul", - }); - }); - - test("loads Portuguese region lookup data on demand for region endpoints", async () => { - const aroundResponse = await postJson("/v1/region/around", { - lat: 37.566, - lng: 126.978, - language: "portuguese", - regionLevel: 2, - maxResults: 1, - }); - - expect(aroundResponse.status).toBe(200); - const around = await aroundResponse.json(); - expect(around[0]).toMatchObject({ - name: "Seul", - }); - - const infoResponse = await postJson("/v1/region/info", { - name: "Lisboa", - language: "portuguese", - regionLevel: 2, - body: "earth", - }); - expect(infoResponse.status).toBe(200); - expect(await infoResponse.json()).toMatchObject({ - name: "Lisboa", - }); - }); - - test("loads Indonesian region lookup data on demand for region endpoints", async () => { - const aroundResponse = await postJson("/v1/region/around", { - lat: -6.1751, - lng: 106.865, - language: "indonesian", - regionLevel: 2, - maxResults: 1, - }); - - expect(aroundResponse.status).toBe(200); - const around = await aroundResponse.json(); - expect(around[0]).toMatchObject({ - name: "Jakarta", - }); - - const infoResponse = await postJson("/v1/region/info", { - name: "Jakarta", - language: "indonesian", - regionLevel: 2, - body: "earth", - }); - expect(infoResponse.status).toBe(200); - expect(await infoResponse.json()).toMatchObject({ - name: "Jakarta", - }); - }); - - test("loads Thai region lookup data on demand for region endpoints", async () => { - const aroundResponse = await postJson("/v1/region/around", { - lat: 13.7563, - lng: 100.5018, - language: "thai", - regionLevel: 2, - maxResults: 1, - }); - - expect(aroundResponse.status).toBe(200); - const around = await aroundResponse.json(); - expect(around[0]).toMatchObject({ - name: "กรุงเทพมหานคร", - }); - - const infoResponse = await postJson("/v1/region/info", { - name: "กรุงเทพมหานคร", - language: "thai", - regionLevel: 2, - body: "earth", - }); - expect(infoResponse.status).toBe(200); - expect(await infoResponse.json()).toMatchObject({ - name: "กรุงเทพมหานคร", - }); - }); - - test("loads Vietnamese region lookup data on demand for region endpoints", async () => { - const aroundResponse = await postJson("/v1/region/around", { - lat: 21.0278, - lng: 105.8342, - language: "vietnamese", - regionLevel: 2, - maxResults: 1, - }); - - expect(aroundResponse.status).toBe(200); - const around = await aroundResponse.json(); - expect(around[0]).toMatchObject({ - name: "Hà Nội", - }); - - const infoResponse = await postJson("/v1/region/info", { - name: "Hà Nội", - language: "vietnamese", - regionLevel: 2, - body: "earth", - }); - expect(infoResponse.status).toBe(200); - expect(await infoResponse.json()).toMatchObject({ - name: "Hà Nội", - }); - }); - - test("loads Hindi region lookup data on demand for region endpoints", async () => { - const aroundResponse = await postJson("/v1/region/around", { - lat: 28.65195, - lng: 77.23149, - language: "hindi", - regionLevel: 2, - maxResults: 1, - }); - - expect(aroundResponse.status).toBe(200); - const around = await aroundResponse.json(); - expect(around[0]).toMatchObject({ - name: "दिल्ली", - }); - - const infoResponse = await postJson("/v1/region/info", { - name: "दिल्ली", - language: "hindi", - regionLevel: 2, - body: "earth", - }); - expect(infoResponse.status).toBe(200); - expect(await infoResponse.json()).toMatchObject({ - name: "दिल्ली", - }); - }); - - test("loads Arabic region lookup data on demand for region endpoints", async () => { - const aroundResponse = await postJson("/v1/region/around", { - lat: 30.0444, - lng: 31.2357, - language: "arabic", - regionLevel: 2, - maxResults: 1, - }); - - expect(aroundResponse.status).toBe(200); - const around = await aroundResponse.json(); - expect(around[0]).toMatchObject({ - name: "القاهرة", - }); - - const infoResponse = await postJson("/v1/region/info", { - name: "القاهرة", - language: "arabic", - regionLevel: 2, - body: "earth", - }); - expect(infoResponse.status).toBe(200); - expect(await infoResponse.json()).toMatchObject({ - name: "القاهرة", - }); - }); - - test.each([ - { - name: "Burmese", - language: "burmese", - lat: -6.1751, - lng: 106.865, - region: "ဂျာကာတာ", - }, - { - name: "Khmer", - language: "khmer", - lat: -6.1751, - lng: 106.865, - region: "ចាការតា", - }, - { - name: "Nepali", - language: "nepali", - lat: 28.65195, - lng: 77.23149, - region: "दिल्ली", - }, - { - name: "Somali", - language: "somali", - lat: 30.0444, - lng: 31.2357, - region: "Qaahira", - }, - { - name: "Pashto", - language: "pashto", - lat: 28.65195, - lng: 77.23149, - region: "ډیلي", - }, - { - name: "Lingala", - language: "lingala", - lat: -6.1751, - lng: 106.865, - region: "Jakarta", - }, - ])( - "loads $name region lookup data on demand for region endpoints", - async ({ language, lat, lng, region }) => { - const aroundResponse = await postJson("/v1/region/around", { - lat, - lng, - language, - regionLevel: 2, - maxResults: 1, - }); - - expect(aroundResponse.status).toBe(200); - const around = await aroundResponse.json(); - expect(around[0]).toMatchObject({ - name: region, - }); - - const infoResponse = await postJson("/v1/region/info", { - name: region, - language, - regionLevel: 2, - body: "earth", - }); - expect(infoResponse.status).toBe(200); - expect(await infoResponse.json()).toMatchObject({ - name: region, - }); - }, - ); - - test("returns a structured not found error for missing region info", async () => { - const response = await postJson("/v1/region/info", { - name: "not-a-real-region", - language: "english", - regionLevel: 2, - body: "earth", - }); - - expect(response.status).toBe(404); - expect(await response.json()).toMatchObject({ - error: { - code: "NOT_FOUND", - }, - }); - }); - - 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 client error for incomplete search bias", async () => { - const response = await postJson("/v1/search", { - query: "Springfield", - language: "english", - regionLevel: 2, - biasLat: 42.1, - }); - - expect(response.status).toBe(400); - expect(await response.json()).toMatchObject({ - error: { - code: "INVALID_INPUT", - }, - }); - }); - - test("returns a structured client error for undecodable codes", async () => { - const response = await postJson("/v1/decode", { - code: "Seoul-notrealcode", - 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/endpoints/cors.ts b/apps/api-ground-codes/src/endpoints/cors.ts index f50c5166..08db9f92 100644 --- a/apps/api-ground-codes/src/endpoints/cors.ts +++ b/apps/api-ground-codes/src/endpoints/cors.ts @@ -6,10 +6,12 @@ export const getAllowedOriginsFromEnv = () => .map((origin) => origin.trim()) .filter(Boolean); -export const createCorsEndpoint = (allowedOrigins = getAllowedOriginsFromEnv()) => +export const createCorsEndpoint = ( + allowedOrigins = getAllowedOriginsFromEnv(), +) => new Elysia() .onAfterHandle(({ request, set }) => { - // * Only process CORS requests + // * Only process CORS requests if (request.method !== "OPTIONS") return; const allowHeader = set.headers["Access-Control-Allow-Headers"]; diff --git a/apps/api-ground-codes/src/endpoints/docs.ts b/apps/api-ground-codes/src/endpoints/docs.ts index 8cc7ee2f..7b28b342 100644 --- a/apps/api-ground-codes/src/endpoints/docs.ts +++ b/apps/api-ground-codes/src/endpoints/docs.ts @@ -91,7 +91,8 @@ const docsHtml = `

Languages

-

Ground Codes supports english, korean, chinese, japanese, spanish, french, german, portuguese, indonesian, thai, vietnamese, hindi, arabic, russian, swahili, filipino, hausa, bengali, urdu, amharic, burmese, khmer, nepali, somali, pashto, and lingala for codebooks and localized region labels.

+

Ground Codes ships 180 automated-stable language sets with codebooks, UI copy, and localized Earth, Moon, and Mars region labels. Automated-stable means the structural, regression, and minimum-score gates pass; native-speaker review remains ongoing maintenance.

+

Examples include english, korean, chinese, japanese, spanish, french, german, portuguese, indonesian, thai, vietnamese, hindi, arabic, russian, swahili, filipino, hausa, bengali, urdu, amharic, burmese, khmer, nepali, somali, pashto, and lingala.

Share URL Rules

@@ -138,10 +139,11 @@ const docsHtml = `

Operational Endpoints

+

Worker-isolate metrics are lightweight diagnostics for the Cloudflare Worker instance that serves the request. They are not a globally aggregated request history.

curl https://api.ground.codes/metrics
@@ -160,27 +162,19 @@ const docsHtml = ` `; const serveDocs = ({ set }: { set: { headers: Record } }) => { - set.headers["cache-control"] = "public, max-age=300"; - set.headers["content-type"] = "text/html; charset=utf-8"; - return docsHtml; + set.headers["cache-control"] = "public, max-age=300"; + set.headers["content-type"] = "text/html; charset=utf-8"; + return docsHtml; }; export const docsEndpoint = new Elysia() - .get( - "/", - serveDocs, - { - detail: { - hide: true, - }, + .get("/", serveDocs, { + detail: { + hide: true, }, - ) - .get( - "/docs", - serveDocs, - { + }) + .get("/docs", serveDocs, { detail: { hide: true, }, - }, -); + }); diff --git a/apps/api-ground-codes/src/endpoints/metrics.ts b/apps/api-ground-codes/src/endpoints/metrics.ts index 4924cdf8..566c5a10 100644 --- a/apps/api-ground-codes/src/endpoints/metrics.ts +++ b/apps/api-ground-codes/src/endpoints/metrics.ts @@ -104,6 +104,7 @@ export const metricsEndpoint = new Elysia() return { service: "api-ground-codes", + scope: "worker-isolate", startedAt: requestMetrics.startedAt, uptimeSeconds: Math.round( (Date.now() - Date.parse(requestMetrics.startedAt)) / 1000, @@ -113,8 +114,9 @@ export const metricsEndpoint = new Elysia() avgMs: requestMetrics.total === 0 ? 0 - : Math.round((requestMetrics.totalMs / requestMetrics.total) * 100) / - 100, + : Math.round( + (requestMetrics.totalMs / requestMetrics.total) * 100, + ) / 100, byPath: requestMetrics.byPath, routes: serializeRoutes(), }, diff --git a/apps/api-ground-codes/src/endpoints/swagger.ts b/apps/api-ground-codes/src/endpoints/swagger.ts index fed88c27..0ab24621 100644 --- a/apps/api-ground-codes/src/endpoints/swagger.ts +++ b/apps/api-ground-codes/src/endpoints/swagger.ts @@ -54,7 +54,7 @@ export const swaggerEndpoint = swagger({ info: { title: "Ground Codes API Documentation", 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}`.", + '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: [ @@ -116,59 +116,35 @@ export const openApiReferenceEndpoint = new Elysia() }, }, ) - .get( - "/openapi/json", - () => redirect("/openapi-json/json"), - { - detail: { - hide: true, - }, + .get("/openapi/json", () => redirect("/openapi-json/json"), { + detail: { + hide: true, }, - ); + }); export const swaggerRedirectEndpoint = new Elysia() - .get( - "/json", - () => redirect("/openapi-json/json"), - { - detail: { - hide: true, - }, + .get("/json", () => redirect("/openapi-json/json"), { + detail: { + hide: true, }, - ) - .get( - "/reference", - () => redirect("/openapi/"), - { - detail: { - hide: true, - }, + }) + .get("/reference", () => redirect("/openapi/"), { + detail: { + hide: true, }, - ) - .get( - "/swagger", - () => redirect("/openapi/"), - { - detail: { - hide: true, - }, + }) + .get("/swagger", () => redirect("/openapi/"), { + detail: { + hide: true, }, - ) - .get( - "/swagger/", - () => redirect("/openapi/"), - { - detail: { - hide: true, - }, + }) + .get("/swagger/", () => redirect("/openapi/"), { + detail: { + hide: true, }, - ) - .get( - "/swagger/json", - () => redirect("/openapi-json/json"), - { - detail: { - hide: true, - }, + }) + .get("/swagger/json", () => redirect("/openapi-json/json"), { + detail: { + hide: true, }, - ); + }); 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 e273b222..de9e89df 100644 --- a/apps/api-ground-codes/src/endpoints/v1/v1-endpoints.ts +++ b/apps/api-ground-codes/src/endpoints/v1/v1-endpoints.ts @@ -7,11 +7,11 @@ import { v1Search } from "./search"; const endpointGroup = () => new Elysia() - .use(v1Encode) - .use(v1Decode) - .use(v1Search) - .use(v1RegionAround) - .use(v1RegionInfo); + .use(v1Encode) + .use(v1Decode) + .use(v1Search) + .use(v1RegionAround) + .use(v1RegionInfo); export const v1Endpoints = new Elysia({ prefix: "/v1" }).use(endpointGroup()); diff --git a/apps/api-ground-codes/src/index.ts b/apps/api-ground-codes/src/index.ts index 552d9b4f..63b33463 100644 --- a/apps/api-ground-codes/src/index.ts +++ b/apps/api-ground-codes/src/index.ts @@ -10,6 +10,6 @@ void (async function () { const app = createApp(process.env.PORT ?? 3000); console.log( - `🦊 Elysia is running at http://${app.server?.hostname}:${app.server?.port}` + `🦊 Elysia is running at http://${app.server?.hostname}:${app.server?.port}`, ); })(); diff --git a/apps/api-ground-codes/src/postgis-region-selection.ts b/apps/api-ground-codes/src/postgis-region-selection.ts new file mode 100644 index 00000000..ac5e5e49 --- /dev/null +++ b/apps/api-ground-codes/src/postgis-region-selection.ts @@ -0,0 +1,161 @@ +import { + type CelestialBody, + type RegionSearchResult, + type SupportedLanguage, + getBodyMetersPerDegree, + normalizeLongitudeForBody, +} from "ground-codes/src/index.ts"; + +export type RegionRow = { + source_index: number; + name: string; + code: string; + lat: number | string; + lng: number | string; + body: CelestialBody; + region_level: number; + population: number | string | null; + country_code?: string | null; + distance_km?: number | string | null; +}; + +const PROMINENT_REGION_MIN_POPULATION = 1_000_000; +const PROMINENT_REGION_POPULATION_RATIO = 3; +const PROMINENT_REGION_MAX_DISTANCE_KM = 25; +const PROMINENT_REGION_MAX_DISTANCE_RATIO = 1.5; + +export const normalizeLookupKey = (value: string) => + value + .replace(/Æ/g, "Ae") + .replace(/æ/g, "ae") + .replace(/Œ/g, "Oe") + .replace(/œ/g, "oe") + .replace(/Ø/g, "O") + .replace(/ø/g, "o") + .replace(/Ð/g, "D") + .replace(/ð/g, "d") + .replace(/Þ/g, "Th") + .replace(/þ/g, "th") + .replace(/ß/g, "ss") + .normalize("NFKD") + .replace(/[\u0300-\u036f]/g, "") + .toLowerCase() + .trim(); + +export const toRegionSearchResult = (row: RegionRow): RegionSearchResult => ({ + name: row.name, + code: row.code, + lat: Number(row.lat), + lng: Number(row.lng), + body: row.body, + regionLevel: row.region_level, + population: row.population === null ? undefined : Number(row.population), + distanceKm: + row.distance_km === null || row.distance_km === undefined + ? undefined + : Number(row.distance_km), +}); + +const toRadians = (degrees: number) => degrees * (Math.PI / 180); + +export const calculateDistanceKm = ( + lat1: number, + lng1: number, + lat2: number, + lng2: number, + body: CelestialBody, +) => { + const radiusKm = getBodyMetersPerDegree(body) / 1000 / (Math.PI / 180); + const normalizedLng1 = normalizeLongitudeForBody(lng1, body); + const normalizedLng2 = normalizeLongitudeForBody(lng2, body); + const dLat = toRadians(lat2 - lat1); + const dLng = toRadians(normalizedLng2 - normalizedLng1); + const a = + Math.sin(dLat / 2) * Math.sin(dLat / 2) + + Math.cos(toRadians(lat1)) * + Math.cos(toRadians(lat2)) * + Math.sin(dLng / 2) * + Math.sin(dLng / 2); + + return 2 * radiusKm * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); +}; + +export const selectProminentRegionRow = ( + rows: RegionRow[], + target: { lat: number; lng: number }, + body: CelestialBody, +) => { + const candidates = rows.map((row) => ({ + row, + distanceKm: calculateDistanceKm( + target.lat, + target.lng, + Number(row.lat), + Number(row.lng), + body, + ), + })); + const closest = [...candidates].sort( + (left, right) => left.distanceKm - right.distanceKm, + )[0]; + if (!closest) return null; + + const closestPopulation = + closest.row.population === null || closest.row.population === undefined + ? 0 + : Number(closest.row.population); + const prominent = [ + ...candidates.filter( + (candidate) => + candidate.row.country_code && + candidate.row.country_code === closest.row.country_code && + (candidate.row.population === null || + candidate.row.population === undefined + ? 0 + : Number(candidate.row.population)) >= + PROMINENT_REGION_MIN_POPULATION && + (candidate.row.population === null || + candidate.row.population === undefined + ? 0 + : Number(candidate.row.population)) >= + closestPopulation * PROMINENT_REGION_POPULATION_RATIO && + candidate.distanceKm <= PROMINENT_REGION_MAX_DISTANCE_KM && + candidate.distanceKm <= + closest.distanceKm * PROMINENT_REGION_MAX_DISTANCE_RATIO, + ), + ].sort((left, right) => { + const distanceDelta = left.distanceKm - right.distanceKm; + if (Math.abs(distanceDelta) > 1) return distanceDelta; + return ( + (right.row.population === null || right.row.population === undefined + ? 0 + : Number(right.row.population)) - + (left.row.population === null || left.row.population === undefined + ? 0 + : Number(left.row.population)) + ); + })[0]; + + const selected = prominent ?? closest; + return { row: selected.row, distanceKm: selected.distanceKm }; +}; + +export const getFallbackSearchLevels = ( + body: CelestialBody, + regionLevel: number, +) => { + if (regionLevel !== 2) return []; + if (body === "mars") return [3]; + if (body === "earth") return [1, 3]; + return []; +}; + +export const getDatasetName = ( + body: CelestialBody, + regionLevel: number, + language: SupportedLanguage | "english", +) => { + const bodyPart = body === "earth" ? "" : `-${body}`; + const languagePart = language === "english" ? "" : `-${language}`; + return `region-${regionLevel}${bodyPart}${languagePart}`; +}; diff --git a/apps/api-ground-codes/src/postgis-region-store.ts b/apps/api-ground-codes/src/postgis-region-store.ts index 72a63a42..c2029ef6 100644 --- a/apps/api-ground-codes/src/postgis-region-store.ts +++ b/apps/api-ground-codes/src/postgis-region-store.ts @@ -4,10 +4,17 @@ import { type RegionSearchResult, type RegionStore, type SupportedLanguage, - getBodyMetersPerDegree, - normalizeLongitudeForBody, setRegionStore, } from "ground-codes/src/index.ts"; +import { + type RegionRow, + calculateDistanceKm, + getDatasetName, + getFallbackSearchLevels, + normalizeLookupKey, + selectProminentRegionRow, + toRegionSearchResult, +} from "./postgis-region-selection.ts"; type QueryableClient = { query>( @@ -18,160 +25,10 @@ type QueryableClient = { end(): Promise; }; -type RegionRow = { - source_index: number; - name: string; - code: string; - lat: number | string; - lng: number | string; - body: CelestialBody; - region_level: number; - population: number | string | null; - country_code?: string | null; - distance_km?: number | string | null; -}; - const DEFAULT_REGION_2_FALLBACK_DISTANCE_KM = 100; const DEFAULT_MARS_REGION_2_FALLBACK_DISTANCE_KM = 100; -const PROMINENT_REGION_MIN_POPULATION = 1_000_000; -const PROMINENT_REGION_POPULATION_RATIO = 3; -const PROMINENT_REGION_MAX_DISTANCE_KM = 25; -const PROMINENT_REGION_MAX_DISTANCE_RATIO = 1.5; const PROMINENT_REGION_CANDIDATE_LIMIT = 50; -const normalizeLookupKey = (value: string) => - value - .replace(/Æ/g, "Ae") - .replace(/æ/g, "ae") - .replace(/Œ/g, "Oe") - .replace(/œ/g, "oe") - .replace(/Ø/g, "O") - .replace(/ø/g, "o") - .replace(/Ð/g, "D") - .replace(/ð/g, "d") - .replace(/Þ/g, "Th") - .replace(/þ/g, "th") - .replace(/ß/g, "ss") - .normalize("NFKD") - .replace(/[\u0300-\u036f]/g, "") - .toLowerCase() - .trim(); - -const toRegionSearchResult = (row: RegionRow): RegionSearchResult => ({ - name: row.name, - code: row.code, - lat: Number(row.lat), - lng: Number(row.lng), - body: row.body, - regionLevel: row.region_level, - population: row.population === null ? undefined : Number(row.population), - distanceKm: - row.distance_km === null || row.distance_km === undefined - ? undefined - : Number(row.distance_km), -}); - -const toRadians = (degrees: number) => degrees * (Math.PI / 180); - -const calculateDistanceKm = ( - lat1: number, - lng1: number, - lat2: number, - lng2: number, - body: CelestialBody, -) => { - const radiusKm = getBodyMetersPerDegree(body) / 1000 / (Math.PI / 180); - const normalizedLng1 = normalizeLongitudeForBody(lng1, body); - const normalizedLng2 = normalizeLongitudeForBody(lng2, body); - const dLat = toRadians(lat2 - lat1); - const dLng = toRadians(normalizedLng2 - normalizedLng1); - const a = - Math.sin(dLat / 2) * Math.sin(dLat / 2) + - Math.cos(toRadians(lat1)) * - Math.cos(toRadians(lat2)) * - Math.sin(dLng / 2) * - Math.sin(dLng / 2); - - return 2 * radiusKm * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); -}; - -const selectProminentRegionRow = ( - rows: RegionRow[], - target: { lat: number; lng: number }, - body: CelestialBody, -) => { - const candidates = rows.map((row) => ({ - row, - distanceKm: calculateDistanceKm( - target.lat, - target.lng, - Number(row.lat), - Number(row.lng), - body, - ), - })); - const closest = [...candidates].sort( - (left, right) => left.distanceKm - right.distanceKm, - )[0]; - if (!closest) return null; - - const closestPopulation = - closest.row.population === null || closest.row.population === undefined - ? 0 - : Number(closest.row.population); - const prominent = [ - ...candidates.filter( - (candidate) => - candidate.row.country_code && - candidate.row.country_code === closest.row.country_code && - (candidate.row.population === null || - candidate.row.population === undefined - ? 0 - : Number(candidate.row.population)) >= - PROMINENT_REGION_MIN_POPULATION && - (candidate.row.population === null || - candidate.row.population === undefined - ? 0 - : Number(candidate.row.population)) >= - closestPopulation * PROMINENT_REGION_POPULATION_RATIO && - candidate.distanceKm <= PROMINENT_REGION_MAX_DISTANCE_KM && - candidate.distanceKm <= - closest.distanceKm * PROMINENT_REGION_MAX_DISTANCE_RATIO, - ), - ].sort((left, right) => { - const distanceDelta = left.distanceKm - right.distanceKm; - if (Math.abs(distanceDelta) > 1) return distanceDelta; - return ( - (right.row.population === null || right.row.population === undefined - ? 0 - : Number(right.row.population)) - - (left.row.population === null || left.row.population === undefined - ? 0 - : Number(left.row.population)) - ); - })[0]; - - const selected = prominent ?? closest; - return { row: selected.row, distanceKm: selected.distanceKm }; -}; - -const getFallbackSearchLevels = (body: CelestialBody, regionLevel: number) => { - if (regionLevel !== 2) return []; - if (body === "mars") return [3]; - if (body === "earth") return [1, 3]; - return []; -}; - -const getDatasetName = ( - body: CelestialBody, - regionLevel: number, - language: SupportedLanguage | "english", -) => { - const bodyPart = body === "earth" ? "" : `-${body}`; - const languagePart = language === "english" ? "" : `-${language}`; - return `region-${regionLevel}${bodyPart}${languagePart}`; -}; - export interface PostgisRegionStoreOptions { connectionString: string; ssl?: ClientConfig["ssl"]; diff --git a/apps/grok-spiral/lib/grok-spiral.ts b/apps/grok-spiral/lib/grok-spiral.ts index 7088c51f..208b4c36 100644 --- a/apps/grok-spiral/lib/grok-spiral.ts +++ b/apps/grok-spiral/lib/grok-spiral.ts @@ -1,1462 +1 @@ -/** - * Calculates the N value from the given x and y coordinates. - * - * @param {number} x - The x-coordinate. - * @param {number} y - The y-coordinate. - * @returns {number} The N value. - */ -export function getNFromCoordinates(x: number, y: number): number | bigint; -export function getNFromCoordinates(x: bigint, y: bigint): bigint; -export function getNFromCoordinates( - x: number | bigint, - y: number | bigint -): number | bigint; -export function getNFromCoordinates( - x: number | bigint, - y: number | bigint -): number | bigint { - if (typeof x === "bigint" || typeof y === "bigint") { - if (typeof x !== "bigint" || typeof y !== "bigint") { - throw new Error("x and y must both be bigint when using BigInt coordinates."); - } - return getNFromBigIntCoordinates(x, y); - } - - if (shouldUseBigIntCoordinates(x, y)) { - return getNFromBigIntCoordinates(BigInt(x), BigInt(y)); - } - - // Direct return for origin (0, 0) - if (x === 0 && y === 0) return 1; - - // Calculate the squared sum of coordinates - const m = x * x + y * y; - - // Calculate countLatticePoints(m-1) to find offset - const s_m_minus_1 = m > 0 ? countLatticePoints(m - 1) : 0; - - const k = getShellIndex(m, x, y); - - // Return the computed index n - return s_m_minus_1 + k; -} - -/** - * Calculates the coordinates from the given N value. - * - * @param {number} n - The N value. - * @returns {{ x: number; y: number }} The coordinates {x, y}. - */ -export function getCoordinates(n: number): { x: number; y: number }; -export function getCoordinates(n: bigint): { x: bigint; y: bigint }; -export function getCoordinates( - n: number | bigint -): { x: number; y: number } | { x: bigint; y: bigint }; -export function getCoordinates( - n: number | bigint -): { x: number; y: number } | { x: bigint; y: bigint } { - if (typeof n === "bigint") return getBigIntCoordinates(n); - - if (n <= 0) throw new Error("Invalid value for n."); - if (n === 1) return { x: 0, y: 0 }; - if ( - Number.isInteger(n) && - n >= Number(BIGINT_COORDINATE_SEARCH_THRESHOLD) - ) { - const coordinates = getBigIntCoordinates(BigInt(n)); - return { x: Number(coordinates.x), y: Number(coordinates.y) }; - } - - // Approximate m by using n - const approx_m = Math.floor(n / Math.PI); - // Define delta to narrow down the search range - const delta = Math.ceil(Math.sqrt(n)); - - // Binary search to find the smallest m where s(m) >= n - let low = Math.max(0, approx_m - delta); - let high = approx_m + delta; - let lastBelowCount = 0; - - while (low < high) { - const mid = Math.floor((low + high) / 2); - const midCount = countLatticePoints(mid); - if (midCount < n) { - lastBelowCount = midCount; - low = mid + 1; - } else { - high = mid; - } - } - const m = low; - - // Calculate s(m-1) to determine the offset - const s_m_minus_1 = m > 0 ? lastBelowCount : 0; - const k = n - s_m_minus_1; - - const shell = getShell(m); - - // Return the k-th point - const [x, y] = shell.points[k - 1]!; - return { x, y }; -} - -type Shell = { - points: [number, number][]; - indexByCoordinate: Map; -}; - -let spiralCacheEnabled = false; -const latticePointCountCache = new Map(); -const shellCache = new Map(); -const CONVEX_HULL_COUNT_THRESHOLD = 1_000_000_000; -const MAX_SAFE_BIGINT = BigInt(Number.MAX_SAFE_INTEGER); -const MAX_FAST_BIGINT_ROOT = 10n ** 30n; -const MAX_NUMBER_GUIDED_BIGINT_ROOT = 200_000_000_000n; -const BIGINT_CONVEX_HULL_NAIVE_THRESHOLD_DIVISOR = 128; -const BIGINT_SHELL_SCAN_SEARCH_THRESHOLD = 1024n; -const BIGINT_LARGE_SHELL_SCAN_SEARCH_THRESHOLD = 8192n; -const BIGINT_LARGE_SHELL_SCAN_MIN_M = 10n ** 18n; -const BIGINT_COORDINATE_SEARCH_THRESHOLD = 100_000_000_000_000n; -const NUMBER_GUIDED_POINT_CHECK_EPSILON = 65_536; -const NUMBER_GUIDED_POINT_CHECK_ULP_FACTOR = 512; -const NUMBER_GUIDED_LARGE_POINT_CHECK_ULP_FACTOR = 0.5; -const NUMBER_GUIDED_DYNAMIC_EPSILON_THRESHOLD = 4e20; - -export function setSpiralCacheEnabled(enabled: boolean): void { - spiralCacheEnabled = enabled; - if (!enabled) clearSpiralCache(); -} - -export function isSpiralCacheEnabled(): boolean { - return spiralCacheEnabled; -} - -export function clearSpiralCache(): void { - latticePointCountCache.clear(); - shellCache.clear(); - bigIntLatticePointCountCache.clear(); - bigIntShellCache.clear(); -} - -/** - * Calculates the number of lattice points where x^2 + y^2 <= m (optimized version) - * - * @param {number} m - The value m. - * @returns {number} The number of lattice points. - */ -function countLatticePoints(m: number): number { - if (m < 0) return 0; - if (spiralCacheEnabled) { - const cached = latticePointCountCache.get(m); - if (cached !== undefined) return cached; - } - - const count = - m >= CONVEX_HULL_COUNT_THRESHOLD - ? countLatticePointsByConvexHullFlat(m) - : countLatticePointsByQuadrant(m); - - if (spiralCacheEnabled) latticePointCountCache.set(m, count); - return count; -} - -function countLatticePointsByQuadrant(m: number): number { - if (m === 0) return 1; - - const r = Math.floor(Math.sqrt(m)); - let interiorQuadrantCount = 0; - - for (let x = 1; x <= r; x++) { - interiorQuadrantCount += Math.floor(Math.sqrt(m - x * x)); - } - - return 4 * (interiorQuadrantCount + r) + 1; -} - -function countLatticePointsByConvexHullFlat(m: number): number { - if (m === 0) return 1; - - let count = 0; - const stackX = [0, 1]; - const stackY = [1, 0]; - let stackLength = 2; - - const cbrt = Math.floor(Math.cbrt(m)); - const sqrt = Math.floor(Math.sqrt(m)); - const naiveThreshold = Math.min(cbrt + 1, sqrt); - - for (let x = 1; x <= naiveThreshold; x++) { - count += maxYInCircle(x, m); - } - - let currentX = naiveThreshold; - let currentY = maxYInCircle(naiveThreshold, m); - - while (true) { - stackLength--; - let slopeX = stackX[stackLength]!; - let slopeY = stackY[stackLength]!; - - while (true) { - const nextX = currentX + slopeX; - const nextY = currentY - slopeY; - if (!pointInCircle(nextX, nextY, m)) break; - - count += slopeX * nextY + ((slopeX - 1) * (slopeY - 1)) / 2; - currentX = nextX; - currentY = nextY; - } - - if (currentX >= sqrt - naiveThreshold) break; - - while ( - !pointInCircle( - currentX + stackX[stackLength - 1]!, - currentY - stackY[stackLength - 1]!, - m - ) - ) { - stackLength--; - slopeX = stackX[stackLength]!; - slopeY = stackY[stackLength]!; - } - - while (true) { - const rightX = stackX[stackLength - 1]!; - const rightY = stackY[stackLength - 1]!; - const middleX = slopeX + rightX; - const middleY = slopeY + rightY; - const nextX = currentX + middleX; - const nextY = currentY - middleY; - - if (pointInCircle(nextX, nextY, m)) { - stackX[stackLength] = middleX; - stackY[stackLength] = middleY; - stackLength++; - } else if (slopeOutNoDivision(rightX, rightY, nextX, m)) { - slopeX = middleX; - slopeY = middleY; - } else { - break; - } - } - } - - for (let x = currentX + 1; x <= sqrt; x++) { - count += maxYInCircle(x, m); - } - - return 4 * (count + sqrt) + 1; -} - -function maxYInCircle(x: number, m: number): number { - return Math.floor(Math.sqrt(m - x * x)); -} - -function pointInCircle(x: number, y: number, m: number): boolean { - return x * x + y * y <= m; -} - -function slopeOutNoDivision( - slopeX: number, - slopeY: number, - x: number, - m: number -): boolean { - return slopeY * Math.sqrt(m - x * x) > slopeX * x; -} - -function addSymmetricPoints(points: [number, number][], x: number, y: number) { - if (x === 0 && y === 0) { - points.push([0, 0]); - } else if (x === 0) { - points.push([0, y], [0, -y], [y, 0], [-y, 0]); - } else if (x === y) { - points.push([x, y], [x, -y], [-x, y], [-x, -y]); - } else { - points.push( - [x, y], - [x, -y], - [-x, y], - [-x, -y], - [y, x], - [y, -x], - [-y, x], - [-y, -x] - ); - } -} - -function getShell(m: number): Shell { - if (spiralCacheEnabled) { - const cached = shellCache.get(m); - if (cached) return cached; - } - - const points = getSymmetricPoints(m); - points.sort(compareByAngleDescending); - - const indexByCoordinate = new Map(); - points.forEach(([x, y], index) => { - indexByCoordinate.set(`${x},${y}`, index + 1); - }); - - const shell = { points, indexByCoordinate }; - if (spiralCacheEnabled) shellCache.set(m, shell); - return shell; -} - -function getShellIndex(m: number, x: number, y: number): number { - if (spiralCacheEnabled) { - const cached = shellCache.get(m); - if (cached) { - const k = cached.indexByCoordinate.get(`${x},${y}`); - if (k === undefined) throw new Error("Invalid coordinates."); - return k; - } - } - - let count = 0; - let found = false; - for (const [px, py] of getSymmetricPoints(m)) { - if (px === x && py === y) found = true; - if (isAngleGreater(px, py, x, y)) count++; - } - - if (!found) throw new Error("Invalid coordinates."); - return count + 1; -} - -function getSymmetricPoints(m: number): [number, number][] { - const points: [number, number][] = []; - const limit = Math.floor(Math.sqrt(m / 2)); - - for (let x = 0; x <= limit; x++) { - const ySquared = m - x * x; - const y = Math.floor(Math.sqrt(ySquared)); - if (y < x) continue; - if (y * y === ySquared) addSymmetricPoints(points, x, y); - } - - return points; -} - -function angleHalf([, y]: [number, number]): 0 | 1 { - return y >= 0 ? 0 : 1; -} - -function compareByAngleDescending( - a: [number, number], - b: [number, number] -): number { - const halfA = angleHalf(a); - const halfB = angleHalf(b); - if (halfA !== halfB) return halfA - halfB; - if (a[1] === 0 && b[1] === 0) return a[0] - b[0]; - - const cross = a[0] * b[1] - a[1] * b[0]; - if (cross !== 0) return cross < 0 ? -1 : 1; - return 0; -} - -function isAngleGreater(px: number, py: number, x: number, y: number): boolean { - return compareByAngleDescending([px, py], [x, y]) < 0; -} - -function shouldUseBigIntCoordinates(x: number, y: number): boolean { - if (!Number.isSafeInteger(x) || !Number.isSafeInteger(y)) { - throw new Error("x and y must be safe integers when using number inputs."); - } - - return !Number.isSafeInteger(x * x + y * y); -} - -type BigIntShell = { - points: [bigint, bigint][]; - indexByCoordinate: Map; -}; -type BigIntCountFunction = (m: bigint) => bigint; - -const bigIntLatticePointCountCache = new Map(); -const bigIntShellCache = new Map(); -const SMALL_TRIAL_PRIMES = [ - 3n, - 5n, - 7n, - 11n, - 13n, - 17n, - 19n, - 23n, - 29n, - 31n, - 37n, - 41n, - 43n, - 47n, - 53n, - 59n, - 61n, - 67n, - 71n, - 73n, - 79n, - 83n, - 89n, - 97n, -]; - -function getNFromBigIntCoordinates(x: bigint, y: bigint): bigint { - const m = x * x + y * y; - if ( - m <= MAX_SAFE_BIGINT && - x >= -MAX_SAFE_BIGINT && - x <= MAX_SAFE_BIGINT && - y >= -MAX_SAFE_BIGINT && - y <= MAX_SAFE_BIGINT - ) { - return BigInt(getNFromCoordinates(Number(x), Number(y))); - } - - if (x === 0n && y === 0n) return 1n; - - const previousCount = m > 0n ? countBigIntLatticePoints(m - 1n) : 0n; - const k = getBigIntShellIndex(m, x, y); - - return previousCount + k; -} - -function getBigIntCoordinates(n: bigint): { x: bigint; y: bigint } { - if (n <= 0n) throw new Error("Invalid value for n."); - if (n === 1n) return { x: 0n, y: 0n }; - if (n <= MAX_SAFE_BIGINT && n < BIGINT_COORDINATE_SEARCH_THRESHOLD) { - const coordinates = getCoordinates(Number(n)); - return { x: BigInt(coordinates.x), y: BigInt(coordinates.y) }; - } - - const count = createLocalBigIntCount(); - let { low, high } = - getBigIntSearchBoundsByInterpolation(n, count) ?? - getBigIntSearchBounds(n, count); - ({ low, high } = refineBigIntSearchBoundsByInterpolation(n, low, high, count)); - ({ low, high } = refineBigIntSearchBoundsBySecant(n, low, high, count)); - const scannedCoordinates = tryResolveBigIntCoordinatesByShellScan( - n, - low, - high, - count - ); - if (scannedCoordinates) return scannedCoordinates; - - while (low < high) { - const mid = (low + high) / 2n; - const midCount = count(mid); - if (midCount < n) { - low = mid + 1n; - } else { - high = mid; - } - } - - const previousCount = low > 0n ? count(low - 1n) : 0n; - const shell = getBigIntShell(low); - const k = n - previousCount; - const [x, y] = shell.points[Number(k - 1n)]!; - - return { x, y }; -} - -function tryResolveBigIntCoordinatesByShellScan( - n: bigint, - low: bigint, - high: bigint, - count: BigIntCountFunction -): { x: bigint; y: bigint } | undefined { - const threshold = getBigIntShellScanSearchThreshold(high); - if (low === 0n || high - low > threshold) { - return undefined; - } - - let cumulativeCount = count(low - 1n); - - for (let m = low; m <= high; m++) { - const points = getBigIntPointsByFactorization(m); - if (points.length === 0) continue; - - const shellCount = BigInt(points.length); - const nextCumulativeCount = cumulativeCount + shellCount; - if (nextCumulativeCount >= n) { - points.sort(compareBigIntByAngleDescending); - const [x, y] = points[Number(n - cumulativeCount - 1n)]!; - return { x, y }; - } - - cumulativeCount = nextCumulativeCount; - } - - return undefined; -} - -function refineBigIntSearchBoundsBySecant( - n: bigint, - low: bigint, - high: bigint, - count: BigIntCountFunction -): { low: bigint; high: bigint } { - if (high - low <= getBigIntShellScanSearchThreshold(high)) return { low, high }; - - let lowCount = low > 0n ? count(low - 1n) : 0n; - let highCount = count(high); - - for ( - let attempt = 0; - attempt < 20 && high - low > getBigIntShellScanSearchThreshold(high); - attempt++ - ) { - const countSpan = highCount - lowCount; - if (countSpan <= 0n) break; - - let current = low + ((n - lowCount) * (high - low)) / countSpan; - if (current <= low) current = low + 1n; - if (current >= high) current = high - 1n; - - const currentCount = count(current); - if (currentCount < n) { - low = current + 1n; - lowCount = currentCount; - } else { - high = current; - highCount = currentCount; - } - } - - return { low, high }; -} - -function refineBigIntSearchBoundsByInterpolation( - n: bigint, - low: bigint, - high: bigint, - count: BigIntCountFunction -): { low: bigint; high: bigint } { - let current = (low + high) / 2n; - - for ( - let attempt = 0; - attempt < 8 && high - low > getBigIntShellScanSearchThreshold(high); - attempt++ - ) { - const currentCount = count(current); - if (currentCount < n) { - low = current + 1n; - } else { - high = current; - } - - const countDelta = Number(n - currentCount); - if (!Number.isFinite(countDelta)) break; - - const step = BigInt(Math.trunc(countDelta / Math.PI)); - const next = current + step; - if (next <= low || next >= high) { - current = (low + high) / 2n; - } else { - current = next; - } - } - - return { low, high }; -} - -function getBigIntShellScanSearchThreshold(m: bigint): bigint { - return m >= BIGINT_LARGE_SHELL_SCAN_MIN_M - ? BIGINT_LARGE_SHELL_SCAN_SEARCH_THRESHOLD - : BIGINT_SHELL_SCAN_SEARCH_THRESHOLD; -} - -function createLocalBigIntCount(): BigIntCountFunction { - const localCounts = new Map(); - - return (m: bigint) => { - const cached = localCounts.get(m); - if (cached !== undefined) return cached; - const count = countBigIntLatticePoints(m); - localCounts.set(m, count); - return count; - }; -} - -function getBigIntSearchBoundsByInterpolation( - n: bigint, - count: BigIntCountFunction -): { low: bigint; high: bigint } | undefined { - const numericN = Number(n); - if (!Number.isFinite(numericN)) return undefined; - - let low: bigint | undefined; - let high: bigint | undefined; - let current = BigInt(Math.max(0, Math.floor(numericN / Math.PI))); - - for (let attempt = 0; attempt < 8; attempt++) { - const currentCount = count(current); - if (currentCount < n) { - low = current + 1n; - } else { - high = current; - } - - if (low !== undefined && high !== undefined) { - return low <= high ? { low, high } : undefined; - } - - const countDelta = Number(n - currentCount); - if (!Number.isFinite(countDelta)) return undefined; - - let step = BigInt(Math.trunc(countDelta / Math.PI)); - if (step === 0n) step = currentCount < n ? 1n : -1n; - - const next = current + step; - current = next >= 0n ? next : 0n; - } - - return undefined; -} - -function getBigIntSearchBounds( - n: bigint, - count: BigIntCountFunction = countBigIntLatticePoints -): { low: bigint; high: bigint } { - const numericN = Number(n); - if (!Number.isFinite(numericN)) return { low: 0n, high: n }; - - const approx = BigInt(Math.max(0, Math.floor(numericN / Math.PI))); - let delta = BigInt(Math.max(1, Math.ceil(Math.sqrt(numericN)))) * 4n; - - while (true) { - const low = approx > delta ? approx - delta : 0n; - const high = approx + delta; - - if ( - (low === 0n || count(low - 1n) < n) && - count(high) >= n - ) { - return { low, high }; - } - - delta *= 2n; - } -} - -function countBigIntLatticePoints(m: bigint): bigint { - if (m < 0n) return 0n; - if (spiralCacheEnabled) { - const cached = bigIntLatticePointCountCache.get(m); - if (cached !== undefined) return cached; - } - - const count = - m >= BigInt(CONVEX_HULL_COUNT_THRESHOLD) - ? countBigIntLatticePointsByConvexHull(m) - : countBigIntLatticePointsByQuadrant(m); - if (spiralCacheEnabled) bigIntLatticePointCountCache.set(m, count); - return count; -} - -function countBigIntLatticePointsByQuadrant(m: bigint): bigint { - if (m === 0n) return 1n; - - const r = integerSqrt(m); - let interior = 0n; - - for (let x = 1n; x <= r; x++) { - interior += integerSqrt(m - x * x); - } - - return 4n * (interior + r) + 1n; -} - -function countBigIntLatticePointsByConvexHull(m: bigint): bigint { - if (m === 0n) return 1n; - if (m <= MAX_SAFE_BIGINT) { - return BigInt(countLatticePointsByConvexHullFlat(Number(m))); - } - - const sqrtForGuidedPath = integerSqrt(m); - if (sqrtForGuidedPath <= MAX_NUMBER_GUIDED_BIGINT_ROOT) { - return countBigIntLatticePointsByNumberGuidedConvexHull( - m, - Number(sqrtForGuidedPath) - ); - } - - let count = 0n; - const stackX = [0n, 1n]; - const stackY = [1n, 0n]; - let stackLength = 2; - - const cbrt = integerCubeRoot(m); - const sqrt = integerSqrt(m); - const naiveThreshold = cbrt + 1n < sqrt ? cbrt + 1n : sqrt; - - for (let x = 1n; x <= naiveThreshold; x++) { - count += maxBigIntYInCircle(x, m); - } - - let currentX = naiveThreshold; - let currentY = maxBigIntYInCircle(naiveThreshold, m); - - while (true) { - stackLength--; - let slopeX = stackX[stackLength]!; - let slopeY = stackY[stackLength]!; - - while (true) { - const nextX = currentX + slopeX; - const nextY = currentY - slopeY; - if (!bigIntPointInCircle(nextX, nextY, m)) break; - - count += slopeX * nextY + ((slopeX - 1n) * (slopeY - 1n)) / 2n; - currentX = nextX; - currentY = nextY; - } - - if (currentX >= sqrt - naiveThreshold) break; - - while ( - !bigIntPointInCircle( - currentX + stackX[stackLength - 1]!, - currentY - stackY[stackLength - 1]!, - m - ) - ) { - stackLength--; - slopeX = stackX[stackLength]!; - slopeY = stackY[stackLength]!; - } - - while (true) { - const rightX = stackX[stackLength - 1]!; - const rightY = stackY[stackLength - 1]!; - const middleX = slopeX + rightX; - const middleY = slopeY + rightY; - const nextX = currentX + middleX; - const nextY = currentY - middleY; - - if (bigIntPointInCircle(nextX, nextY, m)) { - stackX[stackLength] = middleX; - stackY[stackLength] = middleY; - stackLength++; - } else if (bigIntSlopeOutSquared(rightX, rightY, nextX, m)) { - slopeX = middleX; - slopeY = middleY; - } else { - break; - } - } - } - - for (let x = currentX + 1n; x <= sqrt; x++) { - count += maxBigIntYInCircle(x, m); - } - - return 4n * (count + sqrt) + 1n; -} - -function countBigIntLatticePointsByNumberGuidedConvexHull( - m: bigint, - sqrt: number -): bigint { - let count = 0n; - const numericM = Number(m); - const stackX = [0, 1]; - const stackY = [1, 0]; - let stackLength = 2; - - const cbrt = Number(integerCubeRoot(m)); - const naiveThreshold = Math.min( - Math.max( - 1, - Math.floor(cbrt / BIGINT_CONVEX_HULL_NAIVE_THRESHOLD_DIVISOR) + 1 - ), - sqrt - ); - - count += sumNumberGuidedYInCircleRange(1, naiveThreshold, m, numericM); - - let currentX = naiveThreshold; - let currentY = maxNumberGuidedYInCircle(naiveThreshold, m, numericM); - - while (true) { - stackLength--; - let slopeX = stackX[stackLength]!; - let slopeY = stackY[stackLength]!; - - const bigSlopeX = BigInt(slopeX); - const bigSlopeY = BigInt(slopeY); - let bigCurrentY = BigInt(currentY); - const areaCorrection = - (BigInt(slopeX - 1) * (bigSlopeY - 1n)) / 2n; - - while (true) { - const nextX = currentX + slopeX; - const nextY = currentY - slopeY; - if (!numberGuidedPointInCircle(nextX, nextY, m, numericM)) break; - - bigCurrentY -= bigSlopeY; - count += bigSlopeX * bigCurrentY + areaCorrection; - currentX = nextX; - currentY = nextY; - } - - if (currentX >= sqrt - naiveThreshold) break; - - while ( - !numberGuidedPointInCircle( - currentX + stackX[stackLength - 1]!, - currentY - stackY[stackLength - 1]!, - m, - numericM - ) - ) { - stackLength--; - slopeX = stackX[stackLength]!; - slopeY = stackY[stackLength]!; - } - - while (true) { - const rightX = stackX[stackLength - 1]!; - const rightY = stackY[stackLength - 1]!; - const middleX = slopeX + rightX; - const middleY = slopeY + rightY; - const nextX = currentX + middleX; - const nextY = currentY - middleY; - - if (numberGuidedPointInCircle(nextX, nextY, m, numericM)) { - stackX[stackLength] = middleX; - stackY[stackLength] = middleY; - stackLength++; - } else if ( - numberGuidedSlopeOutSquared(rightX, rightY, nextX, m, numericM) - ) { - slopeX = middleX; - slopeY = middleY; - } else { - break; - } - } - } - - count += sumNumberGuidedYInCircleRange(currentX + 1, sqrt, m, numericM); - - return 4n * (count + BigInt(sqrt)) + 1n; -} - -function sumNumberGuidedYInCircleRange( - start: number, - end: number, - m: bigint, - numericM: number -): bigint { - if (start > end) return 0n; - - let total = 0n; - let xSquared = BigInt(start) * BigInt(start); - let squareStep = BigInt(2 * start + 1); - - for (let x = start; x <= end; x++) { - total += BigInt(maxNumberGuidedYInCircleWithSquare(x, xSquared, m, numericM)); - xSquared += squareStep; - squareStep += 2n; - } - - return total; -} - -function maxNumberGuidedYInCircle( - x: number, - m: bigint, - numericM: number -): number { - const bigX = BigInt(x); - return maxNumberGuidedYInCircleWithSquare(x, bigX * bigX, m, numericM); -} - -function maxNumberGuidedYInCircleWithSquare( - x: number, - xSquared: bigint, - m: bigint, - numericM: number -): number { - let y = Math.floor(Math.sqrt(Math.max(0, numericM - x * x))); - let bigY = BigInt(y); - let remaining = m - xSquared - bigY * bigY; - - while (remaining < 0n) { - remaining += 2n * bigY - 1n; - bigY--; - y--; - } - - while (remaining >= 2n * bigY + 1n) { - remaining -= 2n * bigY + 1n; - bigY++; - y++; - } - - return y; -} - -function numberGuidedPointInCircle( - x: number, - y: number, - m: bigint, - numericM?: number -): boolean { - if (numericM !== undefined) { - const delta = numericM - (x * x + y * y); - const tolerance = getNumberGuidedPointCheckTolerance(numericM, x, y); - if (delta > tolerance) return true; - if (delta < -tolerance) return false; - } - - const bigX = BigInt(x); - const bigY = BigInt(y); - return bigX * bigX + bigY * bigY <= m; -} - -function getNumberGuidedPointCheckTolerance( - numericM: number, - x: number, - y: number -): number { - if (numericM <= NUMBER_GUIDED_DYNAMIC_EPSILON_THRESHOLD) { - return NUMBER_GUIDED_POINT_CHECK_EPSILON; - } - - return ( - (Math.abs(numericM) + Math.abs(x * x) + Math.abs(y * y)) * - Number.EPSILON * - NUMBER_GUIDED_LARGE_POINT_CHECK_ULP_FACTOR + - NUMBER_GUIDED_POINT_CHECK_EPSILON - ); -} - -function numberGuidedSlopeOutSquared( - slopeX: number, - slopeY: number, - x: number, - m: bigint, - numericM?: number -): boolean { - if (numericM !== undefined) { - const xSquared = x * x; - const left = slopeY * slopeY * (numericM - xSquared); - const right = slopeX * slopeX * xSquared; - const delta = left - right; - const tolerance = - (Math.abs(left) + Math.abs(right)) * - Number.EPSILON * - NUMBER_GUIDED_POINT_CHECK_ULP_FACTOR + - NUMBER_GUIDED_POINT_CHECK_EPSILON; - if (delta > tolerance) return true; - if (delta < -tolerance) return false; - } - - const bigSlopeX = BigInt(slopeX); - const bigSlopeY = BigInt(slopeY); - const bigSlopeXSquared = bigSlopeX * bigSlopeX; - const bigSlopeYSquared = bigSlopeY * bigSlopeY; - const bigX = BigInt(x); - const bigXSquared = bigX * bigX; - return bigSlopeYSquared * (m - bigXSquared) > bigSlopeXSquared * bigXSquared; -} - - -function maxBigIntYInCircle(x: bigint, m: bigint): bigint { - return integerSqrt(m - x * x); -} - -function bigIntPointInCircle(x: bigint, y: bigint, m: bigint): boolean { - return x * x + y * y <= m; -} - -function bigIntSlopeOutSquared( - slopeX: bigint, - slopeY: bigint, - x: bigint, - m: bigint -): boolean { - return slopeY * slopeY * (m - x * x) > slopeX * slopeX * x * x; -} - -function getBigIntShell(m: bigint): BigIntShell { - if (spiralCacheEnabled) { - const cached = bigIntShellCache.get(m); - if (cached) return cached; - } - - const points = getBigIntSymmetricPoints(m); - points.sort(compareBigIntByAngleDescending); - - const indexByCoordinate = new Map(); - points.forEach(([x, y], index) => { - indexByCoordinate.set(`${x},${y}`, BigInt(index + 1)); - }); - - const shell = { points, indexByCoordinate }; - if (spiralCacheEnabled) bigIntShellCache.set(m, shell); - return shell; -} - -function getBigIntShellIndex(m: bigint, x: bigint, y: bigint): bigint { - if (!spiralCacheEnabled) return getBigIntShellIndexDirect(m, x, y); - - const shell = getBigIntShell(m); - const k = shell.indexByCoordinate.get(`${x},${y}`); - if (k === undefined) throw new Error("Invalid coordinates."); - return k; -} - -function getBigIntShellIndexDirect(m: bigint, x: bigint, y: bigint): bigint { - if (m >= 1_000_000_000_000n) { - const shell = getBigIntShellByFactorization(m); - const k = shell.indexByCoordinate.get(`${x},${y}`); - if (k === undefined) throw new Error("Invalid coordinates."); - return k; - } - - let count = 0n; - let found = false; - const limit = integerSqrt(m / 2n); - - for (let representativeX = 0n; representativeX <= limit; representativeX++) { - const ySquared = m - representativeX * representativeX; - const representativeY = integerSqrt(ySquared); - if (representativeY < representativeX) continue; - if (representativeY * representativeY !== ySquared) continue; - - for (const [px, py] of getBigIntSymmetricPointCandidates( - representativeX, - representativeY - )) { - if (px === x && py === y) found = true; - if (compareBigIntByAngleDescending([px, py], [x, y]) < 0) count++; - } - } - - if (!found) throw new Error("Invalid coordinates."); - return count + 1n; -} - -function getBigIntShellByFactorization(m: bigint): BigIntShell { - const points = getBigIntPointsByFactorization(m); - points.sort(compareBigIntByAngleDescending); - - const indexByCoordinate = new Map(); - points.forEach(([x, y], index) => { - indexByCoordinate.set(`${x},${y}`, BigInt(index + 1)); - }); - - return { points, indexByCoordinate }; -} - -function getBigIntPointsByFactorization(m: bigint): [bigint, bigint][] { - if (m === 0n) return [[0n, 0n]]; - if (hasOddSmallThreeModFourFactor(m)) return []; - - const factors = factorBigInt(m); - let scale = 1n; - let reps: [bigint, bigint][] = [[1n, 0n]]; - - for (const [prime, exponent] of factors) { - if (prime === 2n) { - const factor = gaussianBigIntPow([1n, 1n], exponent); - reps = reps.map((rep) => gaussianBigIntMultiply(rep, factor)); - } else if (prime % 4n === 3n) { - if (exponent % 2 !== 0) return []; - scale *= prime ** BigInt(exponent / 2); - } else { - const primeRep = cornacchiaPrimeSumOfSquares(prime); - const conjugate: [bigint, bigint] = [primeRep[0], -primeRep[1]]; - const options: [bigint, bigint][] = []; - - for (let k = 0; k <= exponent; k++) { - options.push( - gaussianBigIntMultiply( - gaussianBigIntPow(primeRep, k), - gaussianBigIntPow(conjugate, exponent - k) - ) - ); - } - - const nextReps: [bigint, bigint][] = []; - for (const rep of reps) { - for (const option of options) { - nextReps.push(gaussianBigIntMultiply(rep, option)); - } - } - reps = nextReps; - } - } - - const points: [bigint, bigint][] = []; - const seenRepresentatives = new Set(); - - for (const [real, imaginary] of reps) { - const x = absBigInt(real * scale); - const y = absBigInt(imaginary * scale); - const a = x < y ? x : y; - const b = x < y ? y : x; - const key = `${a},${b}`; - if (seenRepresentatives.has(key)) continue; - seenRepresentatives.add(key); - addBigIntSymmetricPoints(points, a, b); - } - - return points; -} - -function hasOddSmallThreeModFourFactor(value: bigint): boolean { - let n = value; - - for (const prime of SMALL_TRIAL_PRIMES) { - if (prime % 4n !== 3n || n % prime !== 0n) continue; - - let exponent = 0; - do { - exponent++; - n /= prime; - } while (n % prime === 0n); - - if (exponent % 2 !== 0) return true; - } - - return false; -} - -function getBigIntSymmetricPoints(m: bigint): [bigint, bigint][] { - const points: [bigint, bigint][] = []; - const limit = integerSqrt(m / 2n); - - for (let x = 0n; x <= limit; x++) { - const ySquared = m - x * x; - const y = integerSqrt(ySquared); - if (y < x) continue; - if (y * y === ySquared) addBigIntSymmetricPoints(points, x, y); - } - - return points; -} - -function addBigIntSymmetricPoints( - points: [bigint, bigint][], - x: bigint, - y: bigint -) { - if (x === 0n && y === 0n) { - points.push([0n, 0n]); - } else if (x === 0n) { - points.push([0n, y], [0n, -y], [y, 0n], [-y, 0n]); - } else if (x === y) { - points.push([x, y], [x, -y], [-x, y], [-x, -y]); - } else { - points.push( - [x, y], - [x, -y], - [-x, y], - [-x, -y], - [y, x], - [y, -x], - [-y, x], - [-y, -x] - ); - } -} - -function getBigIntSymmetricPointCandidates( - x: bigint, - y: bigint -): [bigint, bigint][] { - if (x === 0n && y === 0n) return [[0n, 0n]]; - if (x === 0n) return [[0n, y], [0n, -y], [y, 0n], [-y, 0n]]; - if (x === y) return [[x, y], [x, -y], [-x, y], [-x, -y]]; - - return [ - [x, y], - [x, -y], - [-x, y], - [-x, -y], - [y, x], - [y, -x], - [-y, x], - [-y, -x], - ]; -} - -function compareBigIntByAngleDescending( - a: [bigint, bigint], - b: [bigint, bigint] -): number { - const halfA = a[1] >= 0n ? 0 : 1; - const halfB = b[1] >= 0n ? 0 : 1; - if (halfA !== halfB) return halfA - halfB; - if (a[1] === 0n && b[1] === 0n) return a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0; - - const cross = a[0] * b[1] - a[1] * b[0]; - if (cross !== 0n) return cross < 0n ? -1 : 1; - return 0; -} - -function factorBigInt(value: bigint): Array<[bigint, number]> { - const factors = new Map(); - - function addFactor(n: bigint): void { - if (n === 1n) return; - if (isProbablePrime(n)) { - factors.set(n, (factors.get(n) ?? 0) + 1); - return; - } - - const divisor = pollardRho(n); - addFactor(divisor); - addFactor(n / divisor); - } - - let n = value; - while (n % 2n === 0n) { - factors.set(2n, (factors.get(2n) ?? 0) + 1); - n /= 2n; - } - - for (const prime of SMALL_TRIAL_PRIMES) { - while (n % prime === 0n) { - factors.set(prime, (factors.get(prime) ?? 0) + 1); - n /= prime; - } - } - - addFactor(n); - return [...factors.entries()].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)); -} - -function pollardRho(n: bigint): bigint { - if (n % 2n === 0n) return 2n; - if (n % 3n === 0n) return 3n; - - for (let c = 1n; ; c++) { - let x = 2n; - let y = 2n; - let d = 1n; - - while (d === 1n) { - x = pollardStep(x, c, n); - y = pollardStep(pollardStep(y, c, n), c, n); - d = gcd(absBigInt(x - y), n); - } - - if (d !== n) return d; - } -} - -function pollardStep(x: bigint, c: bigint, mod: bigint): bigint { - return (x * x + c) % mod; -} - -function isProbablePrime(n: bigint): boolean { - if (n < 2n) return false; - const bases = [2n, 3n, 5n, 7n, 11n, 13n, 17n, 19n, 23n, 29n, 31n, 37n]; - - for (const base of bases) { - if (n === base) return true; - if (n % base === 0n) return false; - } - - let d = n - 1n; - let shifts = 0; - while (d % 2n === 0n) { - d /= 2n; - shifts++; - } - - for (const base of bases) { - if (base >= n - 1n) continue; - let x = modPow(base, d, n); - if (x === 1n || x === n - 1n) continue; - - let maybePrime = false; - for (let r = 1; r < shifts; r++) { - x = (x * x) % n; - if (x === n - 1n) { - maybePrime = true; - break; - } - } - - if (!maybePrime) return false; - } - - return true; -} - -function cornacchiaPrimeSumOfSquares(prime: bigint): [bigint, bigint] { - const rootOfMinusOne = tonelliShanks(prime - 1n, prime); - let a = prime; - let b = rootOfMinusOne; - - while (b * b > prime) { - const next = a % b; - a = b; - b = next; - } - - const ySquared = prime - b * b; - const y = integerSqrt(ySquared); - if (y * y !== ySquared) { - throw new Error(`No sum-of-squares representation for prime ${prime}`); - } - - return [b, y]; -} - -function tonelliShanks(n: bigint, prime: bigint): bigint { - if (n === 0n) return 0n; - if (prime === 2n) return n; - if (modPow(n, (prime - 1n) / 2n, prime) !== 1n) { - throw new Error("No modular square root exists."); - } - if (prime % 4n === 3n) return modPow(n, (prime + 1n) / 4n, prime); - - let q = prime - 1n; - let s = 0n; - while (q % 2n === 0n) { - q /= 2n; - s++; - } - - let z = 2n; - while (modPow(z, (prime - 1n) / 2n, prime) !== prime - 1n) z++; - - let m = s; - let c = modPow(z, q, prime); - let t = modPow(n, q, prime); - let r = modPow(n, (q + 1n) / 2n, prime); - - while (t !== 1n) { - let i = 1n; - let t2i = (t * t) % prime; - while (t2i !== 1n) { - t2i = (t2i * t2i) % prime; - i++; - } - - const b = modPow(c, 1n << (m - i - 1n), prime); - m = i; - c = (b * b) % prime; - t = (t * c) % prime; - r = (r * b) % prime; - } - - return r; -} - -function gaussianBigIntPow( - base: [bigint, bigint], - exponent: number -): [bigint, bigint] { - let result: [bigint, bigint] = [1n, 0n]; - let power = base; - let n = exponent; - - while (n > 0) { - if (n & 1) result = gaussianBigIntMultiply(result, power); - power = gaussianBigIntMultiply(power, power); - n = Math.floor(n / 2); - } - - return result; -} - -function gaussianBigIntMultiply( - a: [bigint, bigint], - b: [bigint, bigint] -): [bigint, bigint] { - return [a[0] * b[0] - a[1] * b[1], a[0] * b[1] + a[1] * b[0]]; -} - -function modPow(base: bigint, exponent: bigint, mod: bigint): bigint { - let result = 1n; - let power = base % mod; - let n = exponent; - - while (n > 0n) { - if (n & 1n) result = (result * power) % mod; - power = (power * power) % mod; - n >>= 1n; - } - - return result; -} - -function gcd(a: bigint, b: bigint): bigint { - let x = a; - let y = b; - - while (y !== 0n) { - const next = x % y; - x = y; - y = next; - } - - return x; -} - -function absBigInt(value: bigint): bigint { - return value < 0n ? -value : value; -} - -function integerSqrt(value: bigint): bigint { - if (value < 0n) throw new Error("Cannot calculate square root of negative bigint."); - if (value < 2n) return value; - - if (value <= MAX_FAST_BIGINT_ROOT) { - let root = BigInt(Math.floor(Math.sqrt(Number(value)))); - while ((root + 1n) * (root + 1n) <= value) root++; - while (root * root > value) root--; - return root; - } - - let x0 = 1n << (BigInt(value.toString(2).length + 1) / 2n); - let x1 = (x0 + value / x0) / 2n; - - while (x1 < x0) { - x0 = x1; - x1 = (x0 + value / x0) / 2n; - } - - return x0; -} - -function integerCubeRoot(value: bigint): bigint { - if (value < 0n) throw new Error("Cannot calculate cube root of negative bigint."); - if (value < 8n) return value >= 1n ? 1n : 0n; - - if (value <= MAX_FAST_BIGINT_ROOT) { - let root = BigInt(Math.floor(Math.cbrt(Number(value)))); - while ((root + 1n) * (root + 1n) * (root + 1n) <= value) root++; - while (root * root * root > value) root--; - return root; - } - - let low = 0n; - let high = 1n << (BigInt(value.toString(2).length + 2) / 3n); - - while (low <= high) { - const mid = (low + high) / 2n; - const cube = mid * mid * mid; - if (cube === value) return mid; - if (cube < value) low = mid + 1n; - else high = mid - 1n; - } - - return high; -} +export { getCoordinates, getNFromCoordinates } from "ground-codes/src/spiral"; diff --git a/apps/grok-spiral/next.config.ts b/apps/grok-spiral/next.config.ts index 6968c7c9..9c853e1e 100644 --- a/apps/grok-spiral/next.config.ts +++ b/apps/grok-spiral/next.config.ts @@ -11,6 +11,14 @@ const nextConfig: NextConfig = { }, // Ensure trailing slashes for Cloudflare Pages compatibility trailingSlash: true, + webpack(config) { + config.resolve.extensionAlias = { + ".js": [".ts", ".tsx", ".js"], + ".mjs": [".mts", ".mjs"], + ".cjs": [".cts", ".cjs"], + }; + return config; + }, }; if (process.env.NODE_ENV === "development") { diff --git a/apps/grok-spiral/package.json b/apps/grok-spiral/package.json index 974b80fe..09390a74 100644 --- a/apps/grok-spiral/package.json +++ b/apps/grok-spiral/package.json @@ -15,6 +15,7 @@ }, "dependencies": { "@repo/ui": "workspace:*", + "ground-codes": "workspace:*", "next": "15.5.18", "react": "^19.0.0", "react-dom": "^19.0.0" diff --git a/apps/web/README.md b/apps/web/README.md index ecf3ac80..d896fb32 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -13,7 +13,6 @@ Ground.codes web application is an interactive map service utilizing the Google ## ✨ Features - **🗺️ Google Maps Integration** - - 🌓 Custom dark/light theme support - 📍 POI (Point of Interest) markers and place details display - 📱 User location tracking and display @@ -22,14 +21,12 @@ Ground.codes web application is an interactive map service utilizing the Google - 🔍 Place search functionality - **📏 Grid System** - - 🧩 Grid display on the map - 🌐 Body-aware grid sizing for Earth, Moon, and Mars - 👆 Grid cell click event handling - 👁️ Grid visibility management - **🌐 Multilingual Support** - - 🇬🇧 English (default), 🇰🇷 Korean, 🇨🇳 Chinese, 🇯🇵 Japanese - 🍪 Cookie-based language settings without URL locale prefixes - 🏷️ Multilingual display of place types @@ -179,29 +176,24 @@ docs screenshots. The `Visual QA` GitHub workflow uploads the screenshots from ### 🔐 How to obtain API keys: - **NEXT_PUBLIC_GOOGLE_MAPS_API_KEY** (Required): - - 📝 Create a project in the [Google Cloud Console](https://console.cloud.google.com/) - ✅ Enable the Maps JavaScript API, Places API, and Geocoding API - 🔑 Create an API key with appropriate restrictions - 📚 More info: [Google Maps Platform Documentation](https://developers.google.com/maps/documentation/javascript/get-api-key) - **NEXT_PUBLIC_GOOGLE_MAPS_ROADMAP_ID** (Optional): - - 🎨 Create a custom map style in the [Google Cloud Console Map Management](https://console.cloud.google.com/google/maps-apis/studio/maps) - 🆔 Use the generated Map ID for this variable - **NEXT_PUBLIC_CESIUM_ION_TOKEN** (Optional): - - 🌕 Enables Cesium ion Moon/Mars 3D Tiles in experimental planetary 3D mode - 🔑 Create a token in [Cesium ion](https://ion.cesium.com/tokens) - **NEXT_PUBLIC_CESIUM_MOON_ASSET_ID / NEXT_PUBLIC_CESIUM_MARS_ASSET_ID** (Optional): - - 🪐 Add Cesium Moon or Cesium Mars from the Cesium ion Asset Depot to your assets - 🆔 Use the corresponding asset IDs with the Cesium ion token - **GOOGLE_MAPS_NODEJS_API_KEY** (Optional): - - 🖥️ Similar to the NEXT_PUBLIC_GOOGLE_MAPS_API_KEY, but with server-side restrictions - 🔧 Used for server-side Google Maps API calls diff --git a/apps/web/components/disable-zoom.tsx b/apps/web/components/disable-zoom.tsx index 2c33dc57..b3a435b7 100644 --- a/apps/web/components/disable-zoom.tsx +++ b/apps/web/components/disable-zoom.tsx @@ -1,6 +1,6 @@ -'use client'; +"use client"; -import { useDisableZoom } from '@/hooks/use-disable-zoom'; +import { useDisableZoom } from "@/hooks/use-disable-zoom"; /** * A component that prevents browser zoom functionality @@ -9,7 +9,7 @@ import { useDisableZoom } from '@/hooks/use-disable-zoom'; export function DisableZoom() { // Apply the zoom disabling hook useDisableZoom(); - + // This component doesn't render anything return null; } diff --git a/apps/web/components/google-map/context/index.ts b/apps/web/components/google-map/context/index.ts index aee2834f..ecc6e725 100644 --- a/apps/web/components/google-map/context/index.ts +++ b/apps/web/components/google-map/context/index.ts @@ -1,3 +1,3 @@ -export * from './types'; -export * from './map-provider'; -export * from './use-map-context'; +export * from "./types"; +export * from "./map-provider"; +export * from "./use-map-context"; 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 483c521f..744146f1 100644 --- a/apps/web/components/google-map/context/use-map-context.ts +++ b/apps/web/components/google-map/context/use-map-context.ts @@ -99,7 +99,7 @@ export const useMapContextState = (): MapContextType => { removeMapEventHandlers(mapInstance); setMap(null); }, - [clearAllGridLines, removeMapEventHandlers] + [clearAllGridLines, removeMapEventHandlers], ); return { diff --git a/apps/web/components/google-map/earth-3d-grid.ts b/apps/web/components/google-map/earth-3d-grid.ts new file mode 100644 index 00000000..3cec8d02 --- /dev/null +++ b/apps/web/components/google-map/earth-3d-grid.ts @@ -0,0 +1,192 @@ +export type Map3DElementInstance = HTMLElement & { + center?: { lat: number; lng: number; altitude?: number }; + flyCameraTo?: (options: Record) => void; + heading?: number; + range?: number; + tilt?: number; +}; + +const GRID_ALTITUDE_METERS = 1200; +export const INITIAL_CAMERA_RANGE_METERS = 32000000; +const METER_GRID_STEP_DEGREES = 0.000027; +const GRID_AXIS_COLOR = "rgba(32, 214, 255, 0.58)"; +const GRID_LINE_COLOR = "rgba(255, 255, 255, 0.42)"; +const GRID_OUTER_COLOR = "rgba(16, 24, 32, 0.38)"; + +type GridViewport = { + east: number; + north: number; + range: number; + south: number; + span: number; + step: number; + west: number; +}; + +const getGridStepDegrees = (range: number) => { + if (range > 18000000) return 15; + if (range > 6000000) return 5; + if (range > 2000000) return 1; + if (range > 600000) return 0.25; + if (range > 180000) return 0.05; + if (range > 60000) return 0.01; + if (range > 20000) return 0.0025; + if (range > 5000) return 0.001; + if (range > 1000) return 0.0005; + if (range > 250) return 0.0001; + if (range > 75) return 0.00005; + return METER_GRID_STEP_DEGREES; +}; + +const getGridSpanDegrees = (range: number) => { + if (range > 18000000) return 180; + if (range > 6000000) return 70; + if (range > 2000000) return 24; + if (range > 600000) return 8; + if (range > 180000) return 2; + if (range > 60000) return 0.5; + if (range > 20000) return 0.12; + if (range > 5000) return 0.04; + if (range > 1000) return 0.02; + if (range > 250) return 0.005; + if (range > 75) return 0.002; + return 0.0003; +}; + +const getLocationValue = ( + value: unknown, + fallback: number, + names: string[], +) => { + if (!value || typeof value !== "object") return fallback; + + for (const name of names) { + const maybeValue = (value as Record)[name]; + if (typeof maybeValue === "number") return maybeValue; + } + + return fallback; +}; + +const snapDown = (value: number, step: number) => + Math.floor(value / step) * step; + +const snapUp = (value: number, step: number) => Math.ceil(value / step) * step; + +const formatGridCoordinate = (value: number) => + Number(value.toFixed(6)).toString(); + +const createGridValues = (start: number, end: number, step: number) => { + const count = Math.max(0, Math.round((end - start) / step)); + return Array.from({ length: count + 1 }, (_, index) => + Number((start + index * step).toFixed(6)), + ); +}; + +export const getGridViewport = (map3d: Map3DElementInstance): GridViewport => { + const range = map3d.range ?? INITIAL_CAMERA_RANGE_METERS; + const step = getGridStepDegrees(range); + const span = getGridSpanDegrees(range); + const centerLat = getLocationValue(map3d.center, 0, ["lat", "kC", "pC"]); + const centerLng = getLocationValue(map3d.center, 0, ["lng", "lC", "qC"]); + + return { + east: Math.min(180, snapUp(centerLng + span / 2, step)), + north: Math.min(85, snapUp(centerLat + span / 2, step)), + range, + south: Math.max(-85, snapDown(centerLat - span / 2, step)), + span, + step, + west: Math.max(-180, snapDown(centerLng - span / 2, step)), + }; +}; + +export const getGridSignature = ({ + east, + north, + range, + south, + step, + west, +}: GridViewport) => + [step, Math.round(range / 10), south, north, west, east].join(":"); + +const createLatitudePath = (lat: number, west: number, east: number) => { + const segmentCount = Math.max(4, Math.min(96, Math.ceil((east - west) / 2))); + return Array.from({ length: segmentCount + 1 }, (_, index) => ({ + lat, + lng: west + ((east - west) * index) / segmentCount, + altitude: GRID_ALTITUDE_METERS, + })); +}; + +const createLongitudePath = (lng: number, south: number, north: number) => { + const segmentCount = Math.max( + 4, + Math.min(96, Math.ceil((north - south) / 2)), + ); + return Array.from({ length: segmentCount + 1 }, (_, index) => ({ + lat: south + ((north - south) * index) / segmentCount, + lng, + altitude: GRID_ALTITUDE_METERS, + })); +}; + +const serializeGridPath = (path: ReturnType) => + path + .map( + ({ altitude, lat, lng }) => + `${formatGridCoordinate(lat)},${formatGridCoordinate(lng)},${formatGridCoordinate(altitude)}`, + ) + .join(" "); + +const createGridLine = ({ + isAxis, + path, + strokeWidth, +}: { + isAxis: boolean; + path: ReturnType; + strokeWidth: number; +}) => { + const line = document.createElement("gmp-polyline-3d"); + line.setAttribute("path", serializeGridPath(path)); + line.setAttribute("stroke-color", isAxis ? GRID_AXIS_COLOR : GRID_LINE_COLOR); + line.setAttribute( + "stroke-width", + String(isAxis ? strokeWidth + 2 : strokeWidth), + ); + line.setAttribute("outer-color", GRID_OUTER_COLOR); + line.setAttribute("outer-width", "1"); + line.setAttribute("altitude-mode", "relative-to-ground"); + line.setAttribute("draws-occluded-segments", ""); + return line; +}; + +export const appendGrid = (map3d: Map3DElementInstance) => { + const overlays: HTMLElement[] = []; + const { east, north, range, south, step, west } = getGridViewport(map3d); + const strokeWidth = range > 2000000 ? 2 : 3; + + for (const lat of createGridValues(south, north, step)) { + const line = createGridLine({ + isAxis: Math.abs(lat) < step / 2, + path: createLatitudePath(lat, west, east), + strokeWidth, + }); + map3d.append(line); + overlays.push(line); + } + + for (const lng of createGridValues(west, east, step)) { + const line = createGridLine({ + isAxis: Math.abs(lng) < step / 2, + path: createLongitudePath(lng, south, north), + strokeWidth, + }); + map3d.append(line); + overlays.push(line); + } + + return overlays; +}; diff --git a/apps/web/components/google-map/earth-3d-map.tsx b/apps/web/components/google-map/earth-3d-map.tsx index 6e95e14c..e731c2ef 100644 --- a/apps/web/components/google-map/earth-3d-map.tsx +++ b/apps/web/components/google-map/earth-3d-map.tsx @@ -5,6 +5,13 @@ import { DEFAULT_GROUND_CODE_PRECISION_METERS, formatPrecisionMeters, } from "@/lib/code/ground-codes"; +import { + appendGrid, + getGridSignature, + getGridViewport, + INITIAL_CAMERA_RANGE_METERS, + type Map3DElementInstance, +} from "./earth-3d-grid"; type Earth3DMapProps = { center: Coordinates; @@ -18,14 +25,6 @@ type Earth3DMapProps = { userLocation: Coordinates | null; }; -type Map3DElementInstance = HTMLElement & { - center?: { lat: number; lng: number; altitude?: number }; - flyCameraTo?: (options: Record) => void; - heading?: number; - range?: number; - tilt?: number; -}; - type PopoverElementInstance = HTMLElement & { open?: boolean; positionAnchor?: unknown; @@ -48,76 +47,7 @@ type LocationClickEventLike = Event & { position?: { lat?: number; lng?: number; altitude?: number }; }; -const GRID_ALTITUDE_METERS = 1200; -const INITIAL_CAMERA_RANGE_METERS = 32000000; const USER_LOCATION_CAMERA_RANGE_METERS = 25; -const METER_GRID_STEP_DEGREES = 0.000027; -const GRID_AXIS_COLOR = "rgba(32, 214, 255, 0.58)"; -const GRID_LINE_COLOR = "rgba(255, 255, 255, 0.42)"; -const GRID_OUTER_COLOR = "rgba(16, 24, 32, 0.38)"; - -type GridViewport = { - east: number; - north: number; - range: number; - south: number; - span: number; - step: number; - west: number; -}; - -const getGridStepDegrees = (range: number) => { - if (range > 18000000) return 15; - if (range > 6000000) return 5; - if (range > 2000000) return 1; - if (range > 600000) return 0.25; - if (range > 180000) return 0.05; - if (range > 60000) return 0.01; - if (range > 20000) return 0.0025; - if (range > 5000) return 0.001; - if (range > 1000) return 0.0005; - if (range > 250) return 0.0001; - if (range > 75) return 0.00005; - return METER_GRID_STEP_DEGREES; -}; - -const getGridSpanDegrees = (range: number) => { - if (range > 18000000) return 180; - if (range > 6000000) return 70; - if (range > 2000000) return 24; - if (range > 600000) return 8; - if (range > 180000) return 2; - if (range > 60000) return 0.5; - if (range > 20000) return 0.12; - if (range > 5000) return 0.04; - if (range > 1000) return 0.02; - if (range > 250) return 0.005; - if (range > 75) return 0.002; - return 0.0003; -}; - -const getLocationValue = ( - value: unknown, - fallback: number, - names: string[], -) => { - if (!value || typeof value !== "object") return fallback; - - for (const name of names) { - const maybeValue = (value as Record)[name]; - if (typeof maybeValue === "number") return maybeValue; - } - - return fallback; -}; - -const snapDown = (value: number, step: number) => - Math.floor(value / step) * step; - -const snapUp = (value: number, step: number) => Math.ceil(value / step) * step; - -const formatGridCoordinate = (value: number) => - Number(value.toFixed(6)).toString(); const getMarkerLabel = (isEncoding: boolean, encodedCoordinates: string) => isEncoding || !encodedCoordinates ? "Encoding..." : encodedCoordinates; @@ -129,121 +59,6 @@ const getHeadingDelta = (a: number, b: number) => { return Math.min(delta, 360 - delta); }; -const createGridValues = (start: number, end: number, step: number) => { - const count = Math.max(0, Math.round((end - start) / step)); - return Array.from({ length: count + 1 }, (_, index) => - Number((start + index * step).toFixed(6)), - ); -}; - -const getGridViewport = (map3d: Map3DElementInstance): GridViewport => { - const range = map3d.range ?? INITIAL_CAMERA_RANGE_METERS; - const step = getGridStepDegrees(range); - const span = getGridSpanDegrees(range); - const centerLat = getLocationValue(map3d.center, 0, ["lat", "kC", "pC"]); - const centerLng = getLocationValue(map3d.center, 0, ["lng", "lC", "qC"]); - - return { - east: Math.min(180, snapUp(centerLng + span / 2, step)), - north: Math.min(85, snapUp(centerLat + span / 2, step)), - range, - south: Math.max(-85, snapDown(centerLat - span / 2, step)), - span, - step, - west: Math.max(-180, snapDown(centerLng - span / 2, step)), - }; -}; - -const getGridSignature = ({ - east, - north, - range, - south, - step, - west, -}: GridViewport) => - [step, Math.round(range / 10), south, north, west, east].join(":"); - -const createLatitudePath = (lat: number, west: number, east: number) => { - const segmentCount = Math.max(4, Math.min(96, Math.ceil((east - west) / 2))); - return Array.from({ length: segmentCount + 1 }, (_, index) => ({ - lat, - lng: west + ((east - west) * index) / segmentCount, - altitude: GRID_ALTITUDE_METERS, - })); -}; - -const createLongitudePath = (lng: number, south: number, north: number) => { - const segmentCount = Math.max( - 4, - Math.min(96, Math.ceil((north - south) / 2)), - ); - return Array.from({ length: segmentCount + 1 }, (_, index) => ({ - lat: south + ((north - south) * index) / segmentCount, - lng, - altitude: GRID_ALTITUDE_METERS, - })); -}; - -const serializeGridPath = (path: ReturnType) => - path - .map( - ({ altitude, lat, lng }) => - `${formatGridCoordinate(lat)},${formatGridCoordinate(lng)},${formatGridCoordinate(altitude)}`, - ) - .join(" "); - -const createGridLine = ({ - isAxis, - path, - strokeWidth, -}: { - isAxis: boolean; - path: ReturnType; - strokeWidth: number; -}) => { - const line = document.createElement("gmp-polyline-3d"); - line.setAttribute("path", serializeGridPath(path)); - line.setAttribute("stroke-color", isAxis ? GRID_AXIS_COLOR : GRID_LINE_COLOR); - line.setAttribute( - "stroke-width", - String(isAxis ? strokeWidth + 2 : strokeWidth), - ); - line.setAttribute("outer-color", GRID_OUTER_COLOR); - line.setAttribute("outer-width", "1"); - line.setAttribute("altitude-mode", "relative-to-ground"); - line.setAttribute("draws-occluded-segments", ""); - return line; -}; - -const appendGrid = (map3d: Map3DElementInstance) => { - const overlays: HTMLElement[] = []; - const { east, north, range, south, step, west } = getGridViewport(map3d); - const strokeWidth = range > 2000000 ? 2 : 3; - - for (const lat of createGridValues(south, north, step)) { - const line = createGridLine({ - isAxis: Math.abs(lat) < step / 2, - path: createLatitudePath(lat, west, east), - strokeWidth, - }); - map3d.append(line); - overlays.push(line); - } - - for (const lng of createGridValues(west, east, step)) { - const line = createGridLine({ - isAxis: Math.abs(lng) < step / 2, - path: createLongitudePath(lng, south, north), - strokeWidth, - }); - map3d.append(line); - overlays.push(line); - } - - return overlays; -}; - const Earth3DMap = ({ center, encodedCoordinates, @@ -487,9 +302,8 @@ const Earth3DMap = ({ const markerLabel = getMarkerLabel(isEncoding, encodedCoordinates); markerRef.current.setAttribute("title", markerLabel); - const content = markerPopoverRef.current.firstElementChild as - | HTMLElement - | null; + const content = markerPopoverRef.current + .firstElementChild as HTMLElement | null; const label = content?.firstElementChild; const precision = content?.lastElementChild; if (label) { diff --git a/apps/web/components/google-map/hooks/map-container-initial-state.ts b/apps/web/components/google-map/hooks/map-container-initial-state.ts new file mode 100644 index 00000000..7f259c04 --- /dev/null +++ b/apps/web/components/google-map/hooks/map-container-initial-state.ts @@ -0,0 +1,119 @@ +import { parseGroundCodeSharePath } from "@/lib/code/share-url"; +import { + type CelestialBody, + getDefaultPlanetaryLayerId, + getDefaultViewForBody, + parseCelestialBody, +} from "@/lib/map/celestial-bodies"; + +export const libraries: "places"[] = ["places"]; +export type EarthMapType = "roadmap" | "satellite" | "earth3d" | "planetary3d"; + +const getMapTypeFromCookie = (): EarthMapType => { + try { + if (typeof window === "undefined") return "roadmap"; + + const params = new URLSearchParams(window.location.search); + const queryBody = parseCelestialBody(params.get("body")); + const queryMapType = params.get("map") ?? params.get("view"); + if ( + queryMapType === "planetary3d" || + (queryMapType === "3d" && queryBody !== "earth") + ) { + return "planetary3d"; + } + if ( + queryMapType === "earth3d" || + (queryMapType === "3d" && queryBody === "earth") + ) { + return "earth3d"; + } + if (queryMapType === "satellite") return "satellite"; + if (queryMapType === "roadmap" || queryMapType === "2d") return "roadmap"; + + const cookieMapTypeMatch = document.cookie + .split("; ") + .find((row) => row.startsWith("MAP_TYPE=")); + + const cookieMapType = cookieMapTypeMatch + ? cookieMapTypeMatch.split("=")[1] + : undefined; + + if ( + cookieMapType && + (cookieMapType === "roadmap" || + cookieMapType === "satellite" || + cookieMapType === "earth3d" || + cookieMapType === "planetary3d") + ) { + return cookieMapType; + } + + return "roadmap"; + } catch (error) { + console.error("Error getting map type from cookie:", error); + return "roadmap"; + } +}; + +export const getDefaultMapTypeForBody = (body: CelestialBody): EarthMapType => + body === "earth" ? "earth3d" : "planetary3d"; + +export const getInitialMapType = (body: CelestialBody): EarthMapType => { + try { + if (typeof window === "undefined") return getDefaultMapTypeForBody(body); + + const params = new URLSearchParams(window.location.search); + if (params.has("map") || params.has("view")) { + return getMapTypeFromCookie(); + } + + return getDefaultMapTypeForBody(body); + } catch (error) { + console.error("Error getting initial map type:", error); + return getDefaultMapTypeForBody(body); + } +}; + +export 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"), + ); +}; + +export const getInitialPlanetaryLayerId = (body: CelestialBody) => { + if (body === "earth") return getDefaultPlanetaryLayerId("moon"); + + return getDefaultPlanetaryLayerId(body); +}; + +export const getInitialCenter = ( + body: CelestialBody, +): google.maps.LatLngLiteral => { + const defaultView = getDefaultViewForBody(body); + if (typeof window === "undefined") return defaultView.center; + + const params = new URLSearchParams(window.location.search); + if (!params.has("lat") || !params.has("lng")) return defaultView.center; + + const lat = Number(params.get("lat")); + const lng = Number(params.get("lng")); + if (Number.isFinite(lat) && Number.isFinite(lng)) return { lat, lng }; + + return defaultView.center; +}; + +export const getInitialZoom = (body: CelestialBody): number => { + const defaultView = getDefaultViewForBody(body); + if (typeof window === "undefined") return defaultView.zoom; + + const params = new URLSearchParams(window.location.search); + if (!params.has("zoom")) return defaultView.zoom; + + const zoom = Number(params.get("zoom")); + return Number.isFinite(zoom) ? zoom : defaultView.zoom; +}; 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 5cb7d78d..2654b6a9 100644 --- a/apps/web/components/google-map/hooks/use-air-quality.ts +++ b/apps/web/components/google-map/hooks/use-air-quality.ts @@ -1,9 +1,9 @@ import { useState, useEffect, useCallback } from "react"; import { Coordinates } from "../types"; -import type { - WeatherData, +import type { + WeatherData, AirQualityData, - CombinedWeatherData + CombinedWeatherData, } from "@/app/api/weather-data/route"; import { useI18n } from "@/lib/i18n/i18n-context"; @@ -33,7 +33,7 @@ interface UseAirQualityOptions { */ export const useAirQuality = ( coordinates: Coordinates | null, - options: UseAirQualityOptions = {} + options: UseAirQualityOptions = {}, ) => { const { debounceMs = 1000 } = options; const { locale } = useI18n(); @@ -107,7 +107,9 @@ export const useAirQuality = ( // Handle error if both failed if (!data.airQuality && !data.weather) { - setError(data.error || "Failed to fetch weather and air quality data"); + setError( + data.error || "Failed to fetch weather and air quality data", + ); } } catch (err) { setError(err instanceof Error ? err.message : "Failed to fetch data"); 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 72effd6e..55b13988 100644 --- a/apps/web/components/google-map/hooks/use-device-orientation.ts +++ b/apps/web/components/google-map/hooks/use-device-orientation.ts @@ -123,7 +123,8 @@ export const useDeviceOrientation = (): UseDeviceOrientationReturn => { }); debounceTimerRef.current = null; }, - MIN_UPDATE_INTERVAL - (currentTime - lastHeadingUpdateTimeRef.current) + MIN_UPDATE_INTERVAL - + (currentTime - lastHeadingUpdateTimeRef.current), ); } else { requestAnimationFrame(() => { @@ -131,7 +132,7 @@ export const useDeviceOrientation = (): UseDeviceOrientationReturn => { }); } }, - [updateHeading] + [updateHeading], ); // Request permission for device orientation @@ -142,7 +143,7 @@ export const useDeviceOrientation = (): UseDeviceOrientationReturn => { window.addEventListener( "deviceorientationabsolute", handleOrientation, - true + true, ); if (!window.DeviceOrientationEvent) { window.addEventListener("deviceorientation", handleOrientation, true); @@ -171,7 +172,7 @@ export const useDeviceOrientation = (): UseDeviceOrientationReturn => { window.addEventListener( "deviceorientation", handleOrientation, - true + true, ); orientationListenerAddedRef.current = true; } @@ -218,12 +219,12 @@ export const useDeviceOrientation = (): UseDeviceOrientationReturn => { window.removeEventListener( "deviceorientationabsolute", handleOrientation, - true + true, ); window.removeEventListener( "deviceorientation", handleOrientation, - true + true, ); orientationListenerAddedRef.current = false; } @@ -254,7 +255,7 @@ export const useDeviceOrientation = (): UseDeviceOrientationReturn => { window.addEventListener( "deviceorientationabsolute", handleOrientation, - true + true, ); if (!window.DeviceOrientationEvent) { window.addEventListener("deviceorientation", handleOrientation, true); @@ -270,12 +271,12 @@ export const useDeviceOrientation = (): UseDeviceOrientationReturn => { window.removeEventListener( "deviceorientationabsolute", handleOrientation, - true + true, ); window.removeEventListener( "deviceorientation", handleOrientation, - true + true, ); orientationListenerAddedRef.current = false; } 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 8df4fe7b..ac2be2ba 100644 --- a/apps/web/components/google-map/hooks/use-location-tracking.ts +++ b/apps/web/components/google-map/hooks/use-location-tracking.ts @@ -17,7 +17,7 @@ export const useLocationTracking = ({ }: UseLocationTrackingProps) => { // Default value is OFF const [locationMode, setLocationMode] = useState( - LocationMode.OFF + LocationMode.OFF, ); // Location information loaded flag @@ -29,7 +29,7 @@ export const useLocationTracking = ({ // Loading state getter function const getIsLoadingLocation = useCallback( () => isLoadingLocationRef.current, - [] + [], ); // Watch position ID reference @@ -138,7 +138,7 @@ export const useLocationTracking = ({ enableHighAccuracy: true, timeout: 5000, maximumAge: 0, - } + }, ); } else if (currentLocationMode === LocationMode.TRACKING) { // TRACKING mode: start location tracking @@ -187,7 +187,7 @@ export const useLocationTracking = ({ enableHighAccuracy: true, maximumAge: 0, timeout: 5000, - } + }, ); // watchPosition ID storage 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 583626a9..a50f8b2c 100644 --- a/apps/web/components/google-map/hooks/use-map-container.ts +++ b/apps/web/components/google-map/hooks/use-map-container.ts @@ -2,138 +2,34 @@ import { useState, useCallback, useEffect, useRef } from "react"; import { useGridSystem } from "@/lib/grid-system"; import { useMapCoordinates } from "./use-map-coordinates"; import { useGeolocation } from "./use-geolocation"; -import { googleMapDarkTheme } from "@/lib/map/google-map-theme"; import { useJsApiLoader } from "@react-google-maps/api"; 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, getDefaultPlanetaryLayerId, getDefaultViewForBody, getPlanetaryLayerConfig, METERS_PER_DEGREE_BY_BODY, - parseCelestialBody, } from "@/lib/map/celestial-bodies"; - -// Define libraries array as a constant to prevent recreation on each render -const libraries: "places"[] = ["places"]; -export type EarthMapType = "roadmap" | "satellite" | "earth3d" | "planetary3d"; - -const getMapTypeFromCookie = (): EarthMapType => { - try { - if (typeof window === "undefined") return "roadmap"; - - const params = new URLSearchParams(window.location.search); - const queryBody = parseCelestialBody(params.get("body")); - const queryMapType = params.get("map") ?? params.get("view"); - if ( - queryMapType === "planetary3d" || - (queryMapType === "3d" && queryBody !== "earth") - ) { - return "planetary3d"; - } - if ( - queryMapType === "earth3d" || - (queryMapType === "3d" && queryBody === "earth") - ) { - return "earth3d"; - } - if (queryMapType === "satellite") return "satellite"; - if (queryMapType === "roadmap" || queryMapType === "2d") return "roadmap"; - - const cookieMapTypeMatch = document.cookie - .split("; ") - .find((row) => row.startsWith("MAP_TYPE=")); - - const cookieMapType = cookieMapTypeMatch - ? cookieMapTypeMatch.split("=")[1] - : undefined; - - if ( - cookieMapType && - (cookieMapType === "roadmap" || - cookieMapType === "satellite" || - cookieMapType === "earth3d" || - cookieMapType === "planetary3d") - ) { - return cookieMapType; - } - - return "roadmap"; - } catch (error) { - console.error("Error getting map type from cookie:", error); - return "roadmap"; - } -}; - -const getDefaultMapTypeForBody = (body: CelestialBody): EarthMapType => - body === "earth" ? "earth3d" : "planetary3d"; - -const getInitialMapType = (body: CelestialBody): EarthMapType => { - try { - if (typeof window === "undefined") return getDefaultMapTypeForBody(body); - - const params = new URLSearchParams(window.location.search); - if (params.has("map") || params.has("view")) { - return getMapTypeFromCookie(); - } - - return getDefaultMapTypeForBody(body); - } catch (error) { - console.error("Error getting initial map type:", error); - return getDefaultMapTypeForBody(body); - } -}; - -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"), - ); -}; - -const getInitialPlanetaryLayerId = (body: CelestialBody) => { - if (body === "earth") return getDefaultPlanetaryLayerId("moon"); - - return getDefaultPlanetaryLayerId(body); -}; - -const getInitialCenter = (body: CelestialBody): google.maps.LatLngLiteral => { - const defaultView = getDefaultViewForBody(body); - if (typeof window === "undefined") return defaultView.center; - - const params = new URLSearchParams(window.location.search); - if (!params.has("lat") || !params.has("lng")) return defaultView.center; - - const lat = Number(params.get("lat")); - const lng = Number(params.get("lng")); - if (Number.isFinite(lat) && Number.isFinite(lng)) return { lat, lng }; - - return defaultView.center; -}; - -const getInitialZoom = (body: CelestialBody): number => { - const defaultView = getDefaultViewForBody(body); - if (typeof window === "undefined") return defaultView.zoom; - - const params = new URLSearchParams(window.location.search); - if (!params.has("zoom")) return defaultView.zoom; - - const zoom = Number(params.get("zoom")); - return Number.isFinite(zoom) ? zoom : defaultView.zoom; -}; +import { + type EarthMapType, + getDefaultMapTypeForBody, + getInitialBody, + getInitialCenter, + getInitialMapType, + getInitialPlanetaryLayerId, + getInitialZoom, + libraries, +} from "./map-container-initial-state"; +import { useMapSearch } from "./use-map-search"; +import { useMapControls } from "./use-map-controls"; +import { useMapPresentation } from "./use-map-presentation"; +import { useMapLifecycle } from "./use-map-lifecycle"; + +export type { EarthMapType } from "./map-container-initial-state"; export const useMapContainer = () => { const { getUserLanguage } = useLanguage(); @@ -167,9 +63,6 @@ export const useMapContainer = () => { ); const [zoom, setZoom] = useState(() => getInitialZoom(body)); const userZoomRef = useRef(getInitialZoom(body)); - const [mapHeading, setMapHeading] = useState(0); - const [mapTilt, setMapTilt] = useState(0); - const [isFullscreen, setIsFullscreen] = useState(false); // User location state const [userLocation, setUserLocation] = useState(null); @@ -187,7 +80,23 @@ export const useMapContainer = () => { // Grid state const [showGrid, setShowGrid] = useState(true); - const [gridWasVisible, setGridWasVisible] = useState(true); + const { + isFullscreen, + mapHeading, + mapTilt, + onHeadingChanged, + onTiltChanged, + resetMapHeading, + selectMapType, + setMapHeadingValue, + toggleFullscreen, + } = useMapControls({ + map, + mapType, + setMapType, + setShowGrid, + showGrid, + }); // Selected area state const [selectedArea, setSelectedArea] = useState(null); @@ -195,25 +104,6 @@ export const useMapContainer = () => { null, ); - // 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, - ); - const [infoWindow, setInfoWindow] = useState( - null, - ); - // Show info window state const [showInfoWindow, setShowInfoWindow] = useState(false); @@ -315,111 +205,6 @@ export const useMapContainer = () => { setLocationMode, ]); - const selectMapType = useCallback( - (newType: EarthMapType) => { - if (newType === mapType) return; - - try { - // Store map type in cookie (1 year validity) - document.cookie = `MAP_TYPE=${newType};path=/;max-age=31536000`; - setMapType(newType); - - if (!map || newType === "earth3d" || newType === "planetary3d") return; - - if (newType === "roadmap") { - map.setOptions({ - styles: googleMapDarkTheme, - mapTypeId: google.maps.MapTypeId.ROADMAP, - }); - } else { - map.setOptions({ - styles: [], - mapTypeControlOptions: { - mapTypeIds: [google.maps.MapTypeId.HYBRID], - }, - }); - map.setMapTypeId(google.maps.MapTypeId.HYBRID); - } - } catch (error) { - console.error("Failed to change map type:", error); - } - }, - [map, mapType], - ); - - // Reset map heading to 0 (north) - const resetMapHeading = useCallback(() => { - if (map) { - map.setHeading(0); - } - setMapHeading(0); - }, [map]); - - // Set map heading to a specific value - const setMapHeadingValue = useCallback( - (heading: number) => { - if (map) { - // Set map to be rotatable - map.setOptions({ rotateControl: true }); - map.setHeading(heading); - } - setMapHeading(heading); - }, - [map], - ); - - // Map heading change handler - const onHeadingChanged = useCallback(() => { - if (map) { - const newHeading = map.getHeading(); - if (newHeading !== undefined) { - setMapHeading(newHeading); - - // If rotation is present, turn off grid - if (newHeading !== 0 && showGrid) { - setGridWasVisible(true); // Store current grid state - setShowGrid(false); // Turn off grid - } - // If rotation is absent and grid was previously on, turn it back on - else if ( - newHeading === 0 && - !showGrid && - gridWasVisible && - mapTilt === 0 - ) { - setShowGrid(true); // Turn grid back on - setGridWasVisible(false); // Reset state - } - } - } - }, [map, showGrid, mapTilt, gridWasVisible]); - - // Map tilt change handler - const onTiltChanged = useCallback(() => { - if (map) { - const newTilt = map.getTilt(); - if (newTilt !== undefined) { - setMapTilt(newTilt); - - // If tilt is present, turn off grid - if (newTilt !== 0 && showGrid) { - setGridWasVisible(true); // Store current grid state - setShowGrid(false); // Turn off grid - } - // If tilt is absent and grid was previously on, turn it back on - else if ( - newTilt === 0 && - !showGrid && - gridWasVisible && - mapHeading === 0 - ) { - setShowGrid(true); // Turn grid back on - setGridWasVisible(false); // Reset state - } - } - } - }, [map, showGrid, mapHeading, gridWasVisible]); - // Get user location function const getUserLocation = useCallback(() => { if (!isEarth) return; @@ -434,36 +219,6 @@ export const useMapContainer = () => { } }, [locationMode, setLocationMode, isEarth]); - // Toggle fullscreen function - const toggleFullscreen = useCallback(() => { - const mapContainer = document.querySelector(".map-container"); - - if (!mapContainer) return; - - if (!document.fullscreenElement) { - mapContainer.requestFullscreen().catch((err) => { - console.error(`Error attempting to enable fullscreen: ${err.message}`); - }); - setIsFullscreen(true); - } else { - document.exitFullscreen(); - setIsFullscreen(false); - } - }, []); - - // Listen for fullscreen change events - useEffect(() => { - const handleFullscreenChange = () => { - setIsFullscreen(!!document.fullscreenElement); - }; - - document.addEventListener("fullscreenchange", handleFullscreenChange); - - return () => { - document.removeEventListener("fullscreenchange", handleFullscreenChange); - }; - }, []); - // Get encoded coordinates using the hook const { encodedCoordinates, isEncoding, encodeSelectedAreaCoordinates } = useMapCoordinates(selectedArea, body); @@ -520,97 +275,47 @@ export const useMapContainer = () => { [body, map], ); - useEffect(() => { - if (!map) return; - - if (body === "earth") { - if (mapType === "earth3d" || mapType === "planetary3d") { - clearAllGridLines(); - return; - } - - map.setOptions({ clickableIcons: true }); - if (mapType === "roadmap") { - map.setOptions({ - styles: googleMapDarkTheme, - mapTypeId: google.maps.MapTypeId.ROADMAP, - }); - } else { - map.setOptions({ - styles: [], - mapTypeControlOptions: { - mapTypeIds: [google.maps.MapTypeId.HYBRID], - }, - }); - map.setMapTypeId(google.maps.MapTypeId.HYBRID); - } - return; - } - - if (mapType === "planetary3d") { - clearAllGridLines(); - return; - } - - const planetaryMapType = createPlanetaryMapType(body, planetaryLayerId); - map.mapTypes.set(body, planetaryMapType); - map.setOptions({ - clickableIcons: false, - styles: [], - backgroundColor: "#050505", - mapTypeControlOptions: { mapTypeIds: [body] }, - }); - map.setMapTypeId(body); + const { + applyGroundSearchResult, + cleanupSearch, + groundSearchError, + groundSearchResults, + handleGroundSearch, + handleGroundSuggest, + handlePlaceSelect, + initialGroundSearchQuery, + isGroundSearchLoading, + searchedPlace, + } = useMapSearch({ + body, + center, + locale, + locationMode, + map, + selectBody, + setCenter, + setLocationMode: (mode) => setLocationMode(mode), + setPlaceDetailsVisible, + setSelectedArea, + setSelectedAreaAddress, + setSelectedLocation, + setSelectedPlaceId, + setShowInfoWindow, + setZoom, + userZoomRef, + }); - if (showGrid) { - window.requestAnimationFrame(() => drawGrid(map)); - } - }, [ + useMapPresentation({ body, + center, + clearAllGridLines, + drawGrid, map, mapType, planetaryLayerId, showGrid, - drawGrid, - clearAllGridLines, - ]); - - useEffect(() => { - if (typeof window === "undefined") return; - if (parseGroundCodeSharePath(window.location.pathname)) return; - - const params = new URLSearchParams(window.location.search); - if (body === "earth") { - params.delete("body"); - params.delete("layer"); - params.delete("lat"); - params.delete("lng"); - params.delete("zoom"); - if (mapType === "earth3d") { - params.delete("map"); - params.delete("view"); - } else { - params.delete("view"); - params.set("map", mapType); - } - } else { - params.set("body", body); - params.delete("map"); - params.delete("layer"); - params.set("lat", center.lat.toFixed(5)); - params.set("lng", center.lng.toFixed(5)); - params.set("zoom", String(zoom)); - if (mapType === "planetary3d") { - params.set("view", "3d"); - } else { - params.set("view", "2d"); - } - } - - const query = params.toString(); - const nextUrl = `${window.location.pathname}${query ? `?${query}` : ""}`; - window.history.replaceState(null, "", nextUrl); - }, [body, center, zoom, mapType]); + zoom, + }); // Close place details const closePlaceDetails = useCallback(() => { @@ -622,445 +327,31 @@ export const useMapContainer = () => { setShowInfoWindow(true); }, [setShowInfoWindow]); - // Setup map event handlers when map is ready - useEffect(() => { - if (map) { - removeMapEventHandlers(map); - setupMapEventHandlers(map); - - if (!showGrid) { - clearAllGridLines(); - } else { - drawGrid(map); - } - - // Add map drag start listener - map.addListener("dragstart", handleMapInteraction); - - // Add heading changed listener - map.addListener("heading_changed", () => { - onHeadingChanged(); - }); - - // Add tilt changed listener - map.addListener("tilt_changed", () => { - onTiltChanged(); - }); - - return () => { - if (map) { - google.maps.event.clearListeners(map, "dragstart"); - google.maps.event.clearListeners(map, "heading_changed"); - google.maps.event.clearListeners(map, "tilt_changed"); - } - }; - } - }, [ - showGrid, - map, - setupMapEventHandlers, - removeMapEventHandlers, + const { onLoad, onUnmount, onZoomChanged } = useMapLifecycle({ + body, + cleanupSearch, clearAllGridLines, drawGrid, - selectedArea, + encodeSelectedAreaCoordinates, handleMapInteraction, - onHeadingChanged, - onTiltChanged, - ]); - - // Encode coordinates when selected area changes - useEffect(() => { - if (selectedArea) { - encodeSelectedAreaCoordinates(); - } - }, [selectedArea, encodeSelectedAreaCoordinates]); - - useEffect(() => { - if (!selectedArea) { - setSelectedAreaAddress(null); - return; - } - - if ( - !isEarth || - !hasGoogleMapsApiKey || - !isGoogleMapsLoaded || - typeof google === "undefined" || - !google.maps?.Geocoder - ) { - setSelectedAreaAddress(null); - return; - } - - let isActive = true; - const geocoder = new google.maps.Geocoder(); - - geocoder.geocode({ location: selectedArea }, (results, status) => { - if (!isActive) return; - - if (status !== google.maps.GeocoderStatus.OK || !results?.length) { - setSelectedAreaAddress(null); - return; - } - - setSelectedAreaAddress(results[0]?.formatted_address ?? null); - }); - - return () => { - isActive = false; - }; - }, [ hasGoogleMapsApiKey, isEarth, isGoogleMapsLoaded, + map, + mapType, + onHeadingChanged, + onTiltChanged, + planetaryLayerId, + removeMapEventHandlers, selectedArea, - selectedArea?.lat, - selectedArea?.lng, - ]); - - // Initialize InfoWindow - useEffect(() => { - if (hasGoogleMapsApiKey && isGoogleMapsLoaded && !infoWindow) { - setInfoWindow(new google.maps.InfoWindow()); - } - }, [hasGoogleMapsApiKey, isGoogleMapsLoaded, infoWindow]); - - // Handle place selection from search - const handlePlaceSelect = useCallback( - (place: google.maps.places.PlaceResult) => { - if (!place.geometry || !place.geometry.location || !map) return; - - // When place is selected, disable location tracking - if (locationMode === LocationMode.TRACKING) { - setLocationMode(LocationMode.OFF); - } - - setSearchedPlace(place); - setSelectedAreaAddress(place.formatted_address ?? place.name ?? null); - - // Adjust map view based on place geometry - if (place.geometry.viewport) { - map.fitBounds(place.geometry.viewport); - } else { - map.setCenter(place.geometry.location); - map.setZoom(17); - } - - // Create or update marker - if (!searchMarker) { - const marker = new google.maps.Marker({ - map, - position: place.geometry.location, - animation: google.maps.Animation.DROP, - }); - setSearchMarker(marker); - } else { - searchMarker.setPosition(place.geometry.location); - } - - // Show info window - if (infoWindow && searchMarker) { - const content = ` -
- ${place.name || ""}
- ${place.formatted_address || ""} -
- `; - infoWindow.setContent(content); - - // Keep search result InfoWindow open - // Hide ground codes InfoWindow - setShowInfoWindow(false); - - infoWindow.open(map, searchMarker); - } - - // Update selected area - const location = place.geometry.location.toJSON(); - setSelectedArea(location); - }, - [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); - setSelectedAreaAddress( - resultBody === "earth" && result.type !== "ground-code" - ? result.label - : null, - ); - 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); - let results: GroundCodeSearchResult[] = []; - - let apiSearchUnavailable = false; - try { - const response = await searchGroundCodes({ - query: trimmedQuery, - language: getGroundCodeLanguage(locale), - body: searchBody, - maxResults: 5, - biasLat: center.lat, - biasLng: center.lng, - }); - results = response.results; - } catch (apiError) { - apiSearchUnavailable = true; - console.warn("Ground code API search unavailable:", apiError); - } - - if (results.length === 0 && searchBody === "earth") { - const geocodedResult = - await new Promise((resolve) => { - if (typeof google === "undefined" || !google.maps?.Geocoder) { - resolve(null); - return; - } - - const geocoder = new google.maps.Geocoder(); - let settled = false; - const timeoutId = window.setTimeout(() => { - if (settled) return; - settled = true; - resolve(null); - }, 3_500); - const finish = (result: GroundCodeSearchResult | null) => { - if (settled) return; - settled = true; - window.clearTimeout(timeoutId); - resolve(result); - }; - - geocoder.geocode( - { address: trimmedQuery }, - (geocodeResults, status) => { - if ( - status !== google.maps.GeocoderStatus.OK || - !geocodeResults?.[0]?.geometry?.location - ) { - finish(null); - return; - } - - const geocodeResult = geocodeResults[0]; - const location = geocodeResult.geometry.location; - finish({ - type: "region", - label: geocodeResult.formatted_address ?? trimmedQuery, - lat: location.lat(), - lng: location.lng(), - body: "earth", - regionLevel: 2, - }); - }, - ); - }); - - if (geocodedResult) { - results = [geocodedResult]; - } - } - - setGroundSearchResults(results); - const result = results[0]; - - if (!result) { - setGroundSearchError( - apiSearchUnavailable - ? "map.search.unavailable" - : "map.search.noResults", - ); - return; - } - - applyGroundSearchResult(result); - } catch (error) { - console.error("Ground code search failed:", error); - setGroundSearchError("map.search.error"); - } finally { - setIsGroundSearchLoading(false); - } - }, - [applyGroundSearchResult, body, center.lat, center.lng, locale], - ); - - const handleGroundSuggest = useCallback( - async (query: string) => { - const trimmedQuery = query.trim(); - if (!trimmedQuery) return []; - - const response = await searchGroundCodes({ - query: trimmedQuery, - language: getGroundCodeLanguage(locale), - body, - maxResults: 5, - biasLat: center.lat, - biasLng: center.lng, - }); - - return response.results; - }, - [body, center.lat, center.lng, 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) => { - setMap(mapInstance); - - // Set map options to allow rotation - mapInstance.setOptions({ - rotateControl: true, - tilt: 0, // Start without tilt - }); - - // Apply styles based on initial map type - if (body !== "earth") { - const planetaryMapType = createPlanetaryMapType(body, planetaryLayerId); - mapInstance.mapTypes.set(body, planetaryMapType); - mapInstance.setOptions({ - clickableIcons: false, - styles: [], - backgroundColor: "#050505", - mapTypeControlOptions: { mapTypeIds: [body] }, - }); - mapInstance.setMapTypeId(body); - } else if (mapType === "roadmap") { - mapInstance.setOptions({ - styles: googleMapDarkTheme, - mapTypeId: google.maps.MapTypeId.ROADMAP, - }); - } else { - mapInstance.setOptions({ - styles: [], - mapTypeControlOptions: { - mapTypeIds: [google.maps.MapTypeId.HYBRID], - }, - }); - mapInstance.setMapTypeId(google.maps.MapTypeId.HYBRID); - } - - // Set up grid and event handlers - drawGrid(mapInstance); - setupMapEventHandlers(mapInstance); - - // Intercept POI click event to prevent default InfoWindow display - mapInstance.addListener("click", (e: google.maps.MapMouseEvent) => { - if ((e as google.maps.IconMouseEvent).placeId) { - // Stop default POI click action - (e as google.maps.IconMouseEvent).stop(); - } - }); - - // Add heading changed listener - mapInstance.addListener("heading_changed", () => { - onHeadingChanged(); - }); - - // Add tilt changed listener - mapInstance.addListener("tilt_changed", () => { - onTiltChanged(); - }); - }, - [ - drawGrid, - setupMapEventHandlers, - mapType, - onHeadingChanged, - onTiltChanged, - body, - planetaryLayerId, - ], - ); - - // Map unload handler - const onUnmount = useCallback( - (mapInstance: google.maps.Map) => { - // Clean up grid - clearAllGridLines(); - removeMapEventHandlers(mapInstance); - - // Clean up search-related objects - if (searchMarker) { - searchMarker.setMap(null); - setSearchMarker(null); - } - - if (infoWindow) { - infoWindow.close(); - } - - // Stop location tracking - stopLocationTracking(); - - setMap(null); - }, - [ - clearAllGridLines, - removeMapEventHandlers, - searchMarker, - infoWindow, - stopLocationTracking, - ], - ); - - // Map zoom change handler - const onZoomChanged = useCallback(() => { - if (map) { - const newZoom = map.getZoom(); - if (newZoom) { - setZoom(newZoom); - userZoomRef.current = newZoom; - } - } - }, [map]); + setMap, + setSelectedAreaAddress, + setZoom, + setupMapEventHandlers, + showGrid, + stopLocationTracking, + userZoomRef, + }); const activePlanetaryLayer = body === "earth" ? null : getPlanetaryLayerConfig(body, planetaryLayerId); diff --git a/apps/web/components/google-map/hooks/use-map-controls.ts b/apps/web/components/google-map/hooks/use-map-controls.ts new file mode 100644 index 00000000..25f48eed --- /dev/null +++ b/apps/web/components/google-map/hooks/use-map-controls.ts @@ -0,0 +1,150 @@ +import { + type Dispatch, + type SetStateAction, + useCallback, + useEffect, + useState, +} from "react"; +import { googleMapDarkTheme } from "@/lib/map/google-map-theme"; +import type { EarthMapType } from "./map-container-initial-state"; + +type UseMapControlsOptions = { + map: google.maps.Map | null; + mapType: EarthMapType; + setMapType: Dispatch>; + setShowGrid: Dispatch>; + showGrid: boolean; +}; + +export const useMapControls = ({ + map, + mapType, + setMapType, + setShowGrid, + showGrid, +}: UseMapControlsOptions) => { + const [mapHeading, setMapHeading] = useState(0); + const [mapTilt, setMapTilt] = useState(0); + const [isFullscreen, setIsFullscreen] = useState(false); + const [gridWasVisible, setGridWasVisible] = useState(true); + + const selectMapType = useCallback( + (newType: EarthMapType) => { + if (newType === mapType) return; + try { + document.cookie = `MAP_TYPE=${newType};path=/;max-age=31536000`; + setMapType(newType); + if (!map || newType === "earth3d" || newType === "planetary3d") return; + + if (newType === "roadmap") { + map.setOptions({ + styles: googleMapDarkTheme, + mapTypeId: google.maps.MapTypeId.ROADMAP, + }); + } else { + map.setOptions({ + styles: [], + mapTypeControlOptions: { + mapTypeIds: [google.maps.MapTypeId.HYBRID], + }, + }); + map.setMapTypeId(google.maps.MapTypeId.HYBRID); + } + } catch (error) { + console.error("Failed to change map type:", error); + } + }, + [map, mapType, setMapType], + ); + + const resetMapHeading = useCallback(() => { + map?.setHeading(0); + setMapHeading(0); + }, [map]); + + const setMapHeadingValue = useCallback( + (heading: number) => { + if (map) { + map.setOptions({ rotateControl: true }); + map.setHeading(heading); + } + setMapHeading(heading); + }, + [map], + ); + + const onHeadingChanged = useCallback(() => { + const newHeading = map?.getHeading(); + if (newHeading === undefined) return; + setMapHeading(newHeading); + if (newHeading !== 0 && showGrid) { + setGridWasVisible(true); + setShowGrid(false); + } else if ( + newHeading === 0 && + !showGrid && + gridWasVisible && + mapTilt === 0 + ) { + setShowGrid(true); + setGridWasVisible(false); + } + }, [gridWasVisible, map, mapTilt, setShowGrid, showGrid]); + + const onTiltChanged = useCallback(() => { + const newTilt = map?.getTilt(); + if (newTilt === undefined) return; + setMapTilt(newTilt); + if (newTilt !== 0 && showGrid) { + setGridWasVisible(true); + setShowGrid(false); + } else if ( + newTilt === 0 && + !showGrid && + gridWasVisible && + mapHeading === 0 + ) { + setShowGrid(true); + setGridWasVisible(false); + } + }, [gridWasVisible, map, mapHeading, setShowGrid, showGrid]); + + const toggleFullscreen = useCallback(() => { + const mapContainer = document.querySelector(".map-container"); + if (!mapContainer) return; + + if (!document.fullscreenElement) { + mapContainer.requestFullscreen().catch((error) => { + console.error( + `Error attempting to enable fullscreen: ${error.message}`, + ); + }); + setIsFullscreen(true); + } else { + void document.exitFullscreen(); + setIsFullscreen(false); + } + }, []); + + useEffect(() => { + const handleFullscreenChange = () => { + setIsFullscreen(Boolean(document.fullscreenElement)); + }; + document.addEventListener("fullscreenchange", handleFullscreenChange); + return () => { + document.removeEventListener("fullscreenchange", handleFullscreenChange); + }; + }, []); + + return { + isFullscreen, + mapHeading, + mapTilt, + onHeadingChanged, + onTiltChanged, + resetMapHeading, + selectMapType, + setMapHeadingValue, + toggleFullscreen, + }; +}; 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 6908dcaf..bb3c94f7 100644 --- a/apps/web/components/google-map/hooks/use-map-coordinates.ts +++ b/apps/web/components/google-map/hooks/use-map-coordinates.ts @@ -11,7 +11,7 @@ interface Coordinates { export const useMapCoordinates = ( selectedArea: Coordinates | null, - body: CelestialBody = "earth" + body: CelestialBody = "earth", ) => { const { locale } = useI18n(); const [encodedCoordinates, setEncodedCoordinates] = useState(""); diff --git a/apps/web/components/google-map/hooks/use-map-lifecycle.ts b/apps/web/components/google-map/hooks/use-map-lifecycle.ts new file mode 100644 index 00000000..ab82680f --- /dev/null +++ b/apps/web/components/google-map/hooks/use-map-lifecycle.ts @@ -0,0 +1,213 @@ +import { + type Dispatch, + type MutableRefObject, + type SetStateAction, + useCallback, + useEffect, +} from "react"; +import { googleMapDarkTheme } from "@/lib/map/google-map-theme"; +import { + type CelestialBody, + createPlanetaryMapType, +} from "@/lib/map/celestial-bodies"; +import type { Coordinates } from "../types"; +import type { EarthMapType } from "./map-container-initial-state"; + +type UseMapLifecycleOptions = { + body: CelestialBody; + cleanupSearch: () => void; + clearAllGridLines: () => void; + drawGrid: (map: google.maps.Map) => void; + encodeSelectedAreaCoordinates: () => void; + handleMapInteraction: () => void; + hasGoogleMapsApiKey: boolean; + isEarth: boolean; + isGoogleMapsLoaded: boolean; + map: google.maps.Map | null; + mapType: EarthMapType; + onHeadingChanged: () => void; + onTiltChanged: () => void; + planetaryLayerId: string; + removeMapEventHandlers: (map: google.maps.Map) => void; + selectedArea: Coordinates | null; + setMap: Dispatch>; + setSelectedAreaAddress: Dispatch>; + setZoom: Dispatch>; + setupMapEventHandlers: (map: google.maps.Map) => void; + showGrid: boolean; + stopLocationTracking: () => void; + userZoomRef: MutableRefObject; +}; + +export const useMapLifecycle = ({ + body, + cleanupSearch, + clearAllGridLines, + drawGrid, + encodeSelectedAreaCoordinates, + handleMapInteraction, + hasGoogleMapsApiKey, + isEarth, + isGoogleMapsLoaded, + map, + mapType, + onHeadingChanged, + onTiltChanged, + planetaryLayerId, + removeMapEventHandlers, + selectedArea, + setMap, + setSelectedAreaAddress, + setZoom, + setupMapEventHandlers, + showGrid, + stopLocationTracking, + userZoomRef, +}: UseMapLifecycleOptions) => { + useEffect(() => { + if (!map) return; + removeMapEventHandlers(map); + setupMapEventHandlers(map); + if (showGrid) drawGrid(map); + else clearAllGridLines(); + + map.addListener("dragstart", handleMapInteraction); + map.addListener("heading_changed", onHeadingChanged); + map.addListener("tilt_changed", onTiltChanged); + return () => { + google.maps.event.clearListeners(map, "dragstart"); + google.maps.event.clearListeners(map, "heading_changed"); + google.maps.event.clearListeners(map, "tilt_changed"); + }; + }, [ + clearAllGridLines, + drawGrid, + handleMapInteraction, + map, + onHeadingChanged, + onTiltChanged, + removeMapEventHandlers, + selectedArea, + setupMapEventHandlers, + showGrid, + ]); + + useEffect(() => { + if (selectedArea) encodeSelectedAreaCoordinates(); + }, [encodeSelectedAreaCoordinates, selectedArea]); + + useEffect(() => { + if (!selectedArea) { + setSelectedAreaAddress(null); + return; + } + if ( + !isEarth || + !hasGoogleMapsApiKey || + !isGoogleMapsLoaded || + typeof google === "undefined" || + !google.maps?.Geocoder + ) { + setSelectedAreaAddress(null); + return; + } + + let isActive = true; + const geocoder = new google.maps.Geocoder(); + geocoder.geocode({ location: selectedArea }, (results, status) => { + if (!isActive) return; + if (status !== google.maps.GeocoderStatus.OK || !results?.length) { + setSelectedAreaAddress(null); + return; + } + setSelectedAreaAddress(results[0]?.formatted_address ?? null); + }); + return () => { + isActive = false; + }; + }, [ + hasGoogleMapsApiKey, + isEarth, + isGoogleMapsLoaded, + selectedArea, + setSelectedAreaAddress, + ]); + + const onLoad = useCallback( + (mapInstance: google.maps.Map) => { + setMap(mapInstance); + mapInstance.setOptions({ rotateControl: true, tilt: 0 }); + if (body !== "earth") { + const planetaryMapType = createPlanetaryMapType(body, planetaryLayerId); + mapInstance.mapTypes.set(body, planetaryMapType); + mapInstance.setOptions({ + clickableIcons: false, + styles: [], + backgroundColor: "#050505", + mapTypeControlOptions: { mapTypeIds: [body] }, + }); + mapInstance.setMapTypeId(body); + } else if (mapType === "roadmap") { + mapInstance.setOptions({ + styles: googleMapDarkTheme, + mapTypeId: google.maps.MapTypeId.ROADMAP, + }); + } else { + mapInstance.setOptions({ + styles: [], + mapTypeControlOptions: { + mapTypeIds: [google.maps.MapTypeId.HYBRID], + }, + }); + mapInstance.setMapTypeId(google.maps.MapTypeId.HYBRID); + } + + drawGrid(mapInstance); + setupMapEventHandlers(mapInstance); + mapInstance.addListener("click", (event: google.maps.MapMouseEvent) => { + if ((event as google.maps.IconMouseEvent).placeId) { + (event as google.maps.IconMouseEvent).stop(); + } + }); + mapInstance.addListener("heading_changed", onHeadingChanged); + mapInstance.addListener("tilt_changed", onTiltChanged); + }, + [ + body, + drawGrid, + mapType, + onHeadingChanged, + onTiltChanged, + planetaryLayerId, + setMap, + setupMapEventHandlers, + ], + ); + + const onUnmount = useCallback( + (mapInstance: google.maps.Map) => { + clearAllGridLines(); + removeMapEventHandlers(mapInstance); + cleanupSearch(); + stopLocationTracking(); + setMap(null); + }, + [ + cleanupSearch, + clearAllGridLines, + removeMapEventHandlers, + setMap, + stopLocationTracking, + ], + ); + + const onZoomChanged = useCallback(() => { + const newZoom = map?.getZoom(); + if (newZoom) { + setZoom(newZoom); + userZoomRef.current = newZoom; + } + }, [map, setZoom, userZoomRef]); + + return { onLoad, onUnmount, onZoomChanged }; +}; diff --git a/apps/web/components/google-map/hooks/use-map-presentation.ts b/apps/web/components/google-map/hooks/use-map-presentation.ts new file mode 100644 index 00000000..d79105ef --- /dev/null +++ b/apps/web/components/google-map/hooks/use-map-presentation.ts @@ -0,0 +1,116 @@ +import { useEffect } from "react"; +import { parseGroundCodeSharePath } from "@/lib/code/share-url"; +import { googleMapDarkTheme } from "@/lib/map/google-map-theme"; +import { + type CelestialBody, + createPlanetaryMapType, +} from "@/lib/map/celestial-bodies"; +import type { EarthMapType } from "./map-container-initial-state"; + +type UseMapPresentationOptions = { + body: CelestialBody; + center: google.maps.LatLngLiteral; + clearAllGridLines: () => void; + drawGrid: (map: google.maps.Map) => void; + map: google.maps.Map | null; + mapType: EarthMapType; + planetaryLayerId: string; + showGrid: boolean; + zoom: number; +}; + +export const useMapPresentation = ({ + body, + center, + clearAllGridLines, + drawGrid, + map, + mapType, + planetaryLayerId, + showGrid, + zoom, +}: UseMapPresentationOptions) => { + useEffect(() => { + if (!map) return; + + if (body === "earth") { + if (mapType === "earth3d" || mapType === "planetary3d") { + clearAllGridLines(); + return; + } + map.setOptions({ clickableIcons: true }); + if (mapType === "roadmap") { + map.setOptions({ + styles: googleMapDarkTheme, + mapTypeId: google.maps.MapTypeId.ROADMAP, + }); + } else { + map.setOptions({ + styles: [], + mapTypeControlOptions: { + mapTypeIds: [google.maps.MapTypeId.HYBRID], + }, + }); + map.setMapTypeId(google.maps.MapTypeId.HYBRID); + } + return; + } + + if (mapType === "planetary3d") { + clearAllGridLines(); + return; + } + + const planetaryMapType = createPlanetaryMapType(body, planetaryLayerId); + map.mapTypes.set(body, planetaryMapType); + map.setOptions({ + clickableIcons: false, + styles: [], + backgroundColor: "#050505", + mapTypeControlOptions: { mapTypeIds: [body] }, + }); + map.setMapTypeId(body); + if (showGrid) window.requestAnimationFrame(() => drawGrid(map)); + }, [ + body, + clearAllGridLines, + drawGrid, + map, + mapType, + planetaryLayerId, + showGrid, + ]); + + useEffect(() => { + if (typeof window === "undefined") return; + if (parseGroundCodeSharePath(window.location.pathname)) return; + + const params = new URLSearchParams(window.location.search); + if (body === "earth") { + params.delete("body"); + params.delete("layer"); + params.delete("lat"); + params.delete("lng"); + params.delete("zoom"); + if (mapType === "earth3d") { + params.delete("map"); + params.delete("view"); + } else { + params.delete("view"); + params.set("map", mapType); + } + } else { + params.set("body", body); + params.delete("map"); + params.delete("layer"); + params.set("lat", center.lat.toFixed(5)); + params.set("lng", center.lng.toFixed(5)); + params.set("zoom", String(zoom)); + params.set("view", mapType === "planetary3d" ? "3d" : "2d"); + } + + const query = params.toString(); + const nextUrl = `${window.location.pathname}${query ? `?${query}` : ""}`; + window.history.replaceState(null, "", nextUrl); + }, [body, center, mapType, zoom]); +}; diff --git a/apps/web/components/google-map/hooks/use-map-search.ts b/apps/web/components/google-map/hooks/use-map-search.ts new file mode 100644 index 00000000..97875f72 --- /dev/null +++ b/apps/web/components/google-map/hooks/use-map-search.ts @@ -0,0 +1,319 @@ +import { + type Dispatch, + type MutableRefObject, + type SetStateAction, + useCallback, + useEffect, + useState, +} from "react"; +import type { Locale } from "@/i18n"; +import { + type GroundCodeSearchResult, + searchGroundCodes, +} from "@/lib/code/ground-codes"; +import { parseGroundCodeSharePath } from "@/lib/code/share-url"; +import { getGroundCodeLanguage } from "@/lib/i18n/ground-code-language"; +import { + type CelestialBody, + parseCelestialBody, +} from "@/lib/map/celestial-bodies"; +import { canConstructGoogleMapsClass } from "@/lib/map/google-maps-availability"; +import { type Coordinates, LocationMode } from "../types"; + +type UseMapSearchOptions = { + body: CelestialBody; + center: Coordinates; + locale: Locale; + locationMode: LocationMode; + map: google.maps.Map | null; + selectBody: (body: CelestialBody) => void; + setCenter: Dispatch>; + setLocationMode: (mode: LocationMode) => void; + setPlaceDetailsVisible: Dispatch>; + setSelectedArea: Dispatch>; + setSelectedAreaAddress: Dispatch>; + setSelectedLocation: Dispatch>; + setSelectedPlaceId: Dispatch>; + setShowInfoWindow: Dispatch>; + setZoom: Dispatch>; + userZoomRef: MutableRefObject; +}; + +const geocodeEarthQuery = (query: string) => + new Promise((resolve) => { + if (typeof google === "undefined" || !google.maps?.Geocoder) { + resolve(null); + return; + } + + const geocoder = new google.maps.Geocoder(); + let settled = false; + const timeoutId = window.setTimeout(() => { + if (settled) return; + settled = true; + resolve(null); + }, 3_500); + const finish = (result: GroundCodeSearchResult | null) => { + if (settled) return; + settled = true; + window.clearTimeout(timeoutId); + resolve(result); + }; + + geocoder.geocode({ address: query }, (results, status) => { + if ( + status !== google.maps.GeocoderStatus.OK || + !results?.[0]?.geometry?.location + ) { + finish(null); + return; + } + + const result = results[0]; + const location = result.geometry.location; + finish({ + type: "region", + label: result.formatted_address ?? query, + lat: location.lat(), + lng: location.lng(), + body: "earth", + regionLevel: 2, + }); + }); + }); + +export const useMapSearch = ({ + body, + center, + locale, + locationMode, + map, + selectBody, + setCenter, + setLocationMode, + setPlaceDetailsVisible, + setSelectedArea, + setSelectedAreaAddress, + setSelectedLocation, + setSelectedPlaceId, + setShowInfoWindow, + setZoom, + userZoomRef, +}: UseMapSearchOptions) => { + 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 [searchMarker, setSearchMarker] = useState( + null, + ); + const [infoWindow, setInfoWindow] = useState( + null, + ); + + useEffect(() => { + if ( + typeof google !== "undefined" && + google.maps && + canConstructGoogleMapsClass(google.maps.InfoWindow) && + !infoWindow + ) { + setInfoWindow(new google.maps.InfoWindow()); + } + }, [infoWindow]); + + const handlePlaceSelect = useCallback( + (place: google.maps.places.PlaceResult) => { + if (!place.geometry?.location || !map) return; + if (locationMode === LocationMode.TRACKING) { + setLocationMode(LocationMode.OFF); + } + + setSearchedPlace(place); + setSelectedAreaAddress(place.formatted_address ?? place.name ?? null); + if (place.geometry.viewport) map.fitBounds(place.geometry.viewport); + else { + map.setCenter(place.geometry.location); + map.setZoom(17); + } + + let marker = searchMarker; + if (!marker) { + marker = new google.maps.Marker({ + map, + position: place.geometry.location, + animation: google.maps.Animation.DROP, + }); + setSearchMarker(marker); + } else { + marker.setPosition(place.geometry.location); + } + + if (infoWindow) { + infoWindow.setContent( + `
${place.name || ""}
${place.formatted_address || ""}
`, + ); + setShowInfoWindow(false); + infoWindow.open(map, marker); + } + setSelectedArea(place.geometry.location.toJSON()); + }, + [ + infoWindow, + locationMode, + map, + searchMarker, + setLocationMode, + setSelectedArea, + setSelectedAreaAddress, + setShowInfoWindow, + ], + ); + + 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); + setSelectedAreaAddress( + resultBody === "earth" && result.type !== "ground-code" + ? result.label + : null, + ); + setShowInfoWindow(true); + if (map && resultBody === body) { + map.setCenter(nextLocation); + map.setZoom(nextZoom); + } + }, + [ + body, + locationMode, + map, + selectBody, + setCenter, + setLocationMode, + setPlaceDetailsVisible, + setSelectedArea, + setSelectedAreaAddress, + setSelectedLocation, + setSelectedPlaceId, + setShowInfoWindow, + setZoom, + userZoomRef, + ], + ); + + 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); + let results: GroundCodeSearchResult[] = []; + let apiSearchUnavailable = false; + try { + const response = await searchGroundCodes({ + query: trimmedQuery, + language: getGroundCodeLanguage(locale), + body: searchBody, + maxResults: 5, + biasLat: center.lat, + biasLng: center.lng, + }); + results = response.results; + } catch (error) { + apiSearchUnavailable = true; + console.warn("Ground code API search unavailable:", error); + } + + if (results.length === 0 && searchBody === "earth") { + const geocodedResult = await geocodeEarthQuery(trimmedQuery); + if (geocodedResult) results = [geocodedResult]; + } + setGroundSearchResults(results); + const result = results[0]; + if (!result) { + setGroundSearchError( + apiSearchUnavailable + ? "map.search.unavailable" + : "map.search.noResults", + ); + return; + } + applyGroundSearchResult(result); + } catch (error) { + console.error("Ground code search failed:", error); + setGroundSearchError("map.search.error"); + } finally { + setIsGroundSearchLoading(false); + } + }, + [applyGroundSearchResult, body, center.lat, center.lng, locale], + ); + + const handleGroundSuggest = useCallback( + async (query: string) => { + const trimmedQuery = query.trim(); + if (!trimmedQuery) return []; + const response = await searchGroundCodes({ + query: trimmedQuery, + language: getGroundCodeLanguage(locale), + body, + maxResults: 5, + biasLat: center.lat, + biasLng: center.lng, + }); + return response.results; + }, + [body, center.lat, center.lng, locale], + ); + + useEffect(() => { + if (typeof window === "undefined") return; + const sharedCode = parseGroundCodeSharePath(window.location.pathname); + if (!sharedCode || initialGroundSearchQuery) return; + setInitialGroundSearchQuery(sharedCode.code); + void handleGroundSearch(sharedCode.code, { body: sharedCode.body }); + }, [handleGroundSearch, initialGroundSearchQuery]); + + const cleanupSearch = useCallback(() => { + searchMarker?.setMap(null); + infoWindow?.close(); + setSearchMarker(null); + }, [infoWindow, searchMarker]); + + return { + applyGroundSearchResult, + cleanupSearch, + groundSearchError, + groundSearchResults, + handleGroundSearch, + handleGroundSuggest, + handlePlaceSelect, + initialGroundSearchQuery, + isGroundSearchLoading, + searchedPlace, + }; +}; diff --git a/apps/web/components/google-map/map-search.tsx b/apps/web/components/google-map/map-search.tsx index 45569558..245286d5 100644 --- a/apps/web/components/google-map/map-search.tsx +++ b/apps/web/components/google-map/map-search.tsx @@ -74,7 +74,7 @@ const MapSearch: React.FC = ({ searchInputRef.current, { fields: ["address_components", "geometry", "name", "formatted_address"], - } + }, ); autocompleteRef.current.bindTo("bounds", map); @@ -87,14 +87,14 @@ const MapSearch: React.FC = ({ if (!place?.geometry?.location) { window.alert( - `No details available for: ${place?.name || "this place"}` + `No details available for: ${place?.name || "this place"}`, ); return; } setQuery(place.name ?? searchInputRef.current?.value ?? ""); onPlaceSelect(place); - } + }, ); return () => { @@ -162,7 +162,7 @@ const MapSearch: React.FC = ({ } setPlacePredictions(nextPredictions); - } + }, ); }, 300); @@ -204,8 +204,9 @@ const MapSearch: React.FC = ({ const nextSuggestions = results.slice(0, 5); groundSuggestionCacheRef.current.set(cacheKey, nextSuggestions); if (groundSuggestionCacheRef.current.size > 30) { - const oldestQuery = groundSuggestionCacheRef.current.keys().next() - .value; + const oldestQuery = groundSuggestionCacheRef.current + .keys() + .next().value; if (oldestQuery) { groundSuggestionCacheRef.current.delete(oldestQuery); } @@ -243,14 +244,15 @@ const MapSearch: React.FC = ({ }; const selectPlacePrediction = async ( - prediction: google.maps.places.AutocompletePrediction + prediction: google.maps.places.AutocompletePrediction, ) => { if (isGroundSearchLoading) return; predictionRequestIdRef.current += 1; groundSuggestionRequestIdRef.current += 1; - suppressedPredictionQueryRef.current = - prediction.description.trim().toLocaleLowerCase(); + suppressedPredictionQueryRef.current = prediction.description + .trim() + .toLocaleLowerCase(); setQuery(prediction.description); setPlacePredictions([]); setGroundSuggestions([]); @@ -411,7 +413,8 @@ const MapSearch: React.FC = ({ {result.label} - {result.code ?? `${result.lat.toFixed(4)}, ${result.lng.toFixed(4)}`} + {result.code ?? + `${result.lat.toFixed(4)}, ${result.lng.toFixed(4)}`} diff --git a/apps/web/components/google-map/place-details/helpers.ts b/apps/web/components/google-map/place-details/helpers.ts index 30646c59..216145c1 100644 --- a/apps/web/components/google-map/place-details/helpers.ts +++ b/apps/web/components/google-map/place-details/helpers.ts @@ -9,12 +9,38 @@ export const getCurrentDayIndex = (): number => new Date().getDay(); * Google Places API (e.g. "Monday: 9:00 AM – 5:00 PM", "월요일: …"). * Returns -1 when the day cannot be determined. */ -export const getDayIndexFromString = (dayString: string | undefined): number => { +export const getDayIndexFromString = ( + dayString: string | undefined, +): number => { if (!dayString) return -1; - const days = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; - const koDays = ["일요일", "월요일", "화요일", "수요일", "목요일", "금요일", "토요일"]; - const cnDays = ["星期日", "星期一", "星期二", "星期三", "星期四", "星期五", "星期六"]; + const days = [ + "Sunday", + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", + ]; + const koDays = [ + "일요일", + "월요일", + "화요일", + "수요일", + "목요일", + "금요일", + "토요일", + ]; + const cnDays = [ + "星期日", + "星期一", + "星期二", + "星期三", + "星期四", + "星期五", + "星期六", + ]; for (let i = 0; i < days.length; i++) { if (dayString.startsWith(days[i]!)) return i; @@ -34,7 +60,7 @@ export const getDayIndexFromString = (dayString: string | undefined): number => * Works with both `periods` and `weekday_text` data from the Places API. */ export const isOpenNow = ( - placeDetails: google.maps.places.PlaceResult | null + placeDetails: google.maps.places.PlaceResult | null, ): boolean => { try { if (placeDetails?.opening_hours?.periods) { @@ -53,7 +79,7 @@ export const isOpenNow = ( } const todayPeriods = placeDetails.opening_hours.periods.filter( - (period) => period.open && period.open.day === day + (period) => period.open && period.open.day === day, ); for (const period of todayPeriods) { @@ -114,17 +140,37 @@ export const isOpenNow = ( let closeHour24: number, closeMinute: string | undefined; if (pattern.source.includes("AM|PM")) { - const [, openHour, _openMinute, openAmPm, closeHour, _closeMinute, closeAmPm] = match; + const [ + , + openHour, + _openMinute, + openAmPm, + closeHour, + _closeMinute, + closeAmPm, + ] = match; openHour24 = parseInt(openHour!); - if (openAmPm!.toUpperCase() === "PM" && openHour24 < 12) openHour24 += 12; - if (openAmPm!.toUpperCase() === "AM" && openHour24 === 12) openHour24 = 0; + if (openAmPm!.toUpperCase() === "PM" && openHour24 < 12) + openHour24 += 12; + if (openAmPm!.toUpperCase() === "AM" && openHour24 === 12) + openHour24 = 0; closeHour24 = parseInt(closeHour!); - if (closeAmPm!.toUpperCase() === "PM" && closeHour24 < 12) closeHour24 += 12; - if (closeAmPm!.toUpperCase() === "AM" && closeHour24 === 12) closeHour24 = 0; + if (closeAmPm!.toUpperCase() === "PM" && closeHour24 < 12) + closeHour24 += 12; + if (closeAmPm!.toUpperCase() === "AM" && closeHour24 === 12) + closeHour24 = 0; openMinute = _openMinute; closeMinute = _closeMinute; } else if (pattern.source.includes("오전|오후")) { - const [, openAmPm, openHour, _openMinute, closeAmPm, closeHour, _closeMinute] = match; + const [ + , + openAmPm, + openHour, + _openMinute, + closeAmPm, + closeHour, + _closeMinute, + ] = match; openHour24 = parseInt(openHour!); if (openAmPm === "오후" && openHour24 < 12) openHour24 += 12; if (openAmPm === "오전" && openHour24 === 12) openHour24 = 0; @@ -142,8 +188,10 @@ export const isOpenNow = ( } const currentTime = currentHour * 60 + currentMinute; - const openTime = openHour24 * 60 + (openMinute ? parseInt(openMinute) : 0); - const closeTime = closeHour24 * 60 + (closeMinute ? parseInt(closeMinute) : 0); + const openTime = + openHour24 * 60 + (openMinute ? parseInt(openMinute) : 0); + const closeTime = + closeHour24 * 60 + (closeMinute ? parseInt(closeMinute) : 0); if (openTime < closeTime) { if (currentTime >= openTime && currentTime < closeTime) return true; @@ -164,7 +212,10 @@ export const isOpenNow = ( }; /** Returns the translated display name for a Google Places type key. */ -export const getPlaceTypeName = (type: string | undefined, locale: Locale): string => { +export const getPlaceTypeName = ( + type: string | undefined, + locale: Locale, +): string => { if (!type) return ""; const translatedType = (placeTypes[locale] as PlaceTypesRecord)[type]; return translatedType || type.replace(/_/g, " "); diff --git a/apps/web/components/google-map/place-details/index.tsx b/apps/web/components/google-map/place-details/index.tsx index 63457cd8..20884011 100644 --- a/apps/web/components/google-map/place-details/index.tsx +++ b/apps/web/components/google-map/place-details/index.tsx @@ -17,7 +17,12 @@ import { } from "react-icons/fa"; import { PlaceDetailsProps } from "./types"; -import { getCurrentDayIndex, getDayIndexFromString, isOpenNow, getPlaceTypeName } from "./helpers"; +import { + getCurrentDayIndex, + getDayIndexFromString, + isOpenNow, + getPlaceTypeName, +} from "./helpers"; import { usePlaceDetails } from "./use-place-details"; import { useGroundCode } from "./use-ground-code"; import { useCopy } from "./use-copy"; @@ -46,15 +51,19 @@ const PlaceDetails: React.FC = ({ map, placeId, visible, - resetPhotoState + resetPhotoState, ); const { groundCode, isLoadingGroundCode } = useGroundCode(location, visible); - const { address: addressCopy, groundCode: groundCodeCopy, phone: phoneCopy } = useCopy( + const { + address: addressCopy, + groundCode: groundCodeCopy, + phone: phoneCopy, + } = useCopy( useCallback(() => placeDetails?.formatted_address, [placeDetails]), useCallback(() => groundCode || undefined, [groundCode]), - useCallback(() => placeDetails?.formatted_phone_number, [placeDetails]) + useCallback(() => placeDetails?.formatted_phone_number, [placeDetails]), ); const handleImageError = (index: number) => { @@ -108,7 +117,10 @@ const PlaceDetails: React.FC = ({
- {getPlaceTypeName(placeDetails.types?.[0], locale as Locale)} + {getPlaceTypeName( + placeDetails.types?.[0], + locale as Locale, + )}
)} @@ -157,7 +169,10 @@ const PlaceDetails: React.FC = ({
)}