From a5c9ef587616c0da0ffa9187ab45b49d0724c5c2 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Tue, 1 Sep 2026 09:43:19 +0700 Subject: [PATCH] feat(maps): centralize tile provider with Geoapify support Introduces map-tiles.js module to centralize tile provider logic, supporting Geoapify (paid, high quality) with API key fallback to OSM public server. Updates LeafletMap, ResultMap, and CoverageMap to use the new abstraction, improves maintainability and consistency across the codebase. --- docs/development.md | 8 ++++ docs/project-structure.md | 3 ++ docs/tech-stack.md | 8 +++- src/app/components/LeafletMap.js | 7 ++-- src/app/components/ResultMap.js | 7 ++-- src/app/debug/coverage/CoverageMap.js | 7 ++-- src/lib/map-tiles.js | 38 +++++++++++++++++ tests/e2e/helpers.js | 7 +++- tests/map-tiles.test.js | 60 +++++++++++++++++++++++++++ 9 files changed, 130 insertions(+), 15 deletions(-) create mode 100644 src/lib/map-tiles.js create mode 100644 tests/map-tiles.test.js diff --git a/docs/development.md b/docs/development.md index 519385b..76f9ce2 100644 --- a/docs/development.md +++ b/docs/development.md @@ -53,6 +53,14 @@ **Mapillary (required)**: - `MAPILLARY_ACCESS_TOKEN` - Mapillary API token for image fetching +**Map tiles (optional)**: +- `NEXT_PUBLIC_GEOAPIFY_KEY` - Geoapify API key. When set (at build time), all + Leaflet maps serve tiles from Geoapify, whose free tier permits commercial + use; unset, they fall back to the OSM public tile server (fine for dev/e2e, + not for a commercial deployment). The key is public by design — restrict it + to the production domain in the Geoapify dashboard. Provider choice lives in + `src/lib/map-tiles.js` + For local Redis without an Upstash account, see *Running Upstash locally* below. ### Testing & Completion diff --git a/docs/project-structure.md b/docs/project-structure.md index 6dac332..1e2301b 100644 --- a/docs/project-structure.md +++ b/docs/project-structure.md @@ -27,6 +27,7 @@ Next.js 16 App Router structure: #### Game Pages - `game/page.js` - Main game interface +- `credits/page.js` - Data sources, licenses, and open-source credits - `debug/page.js` - Debug hub: lists every debug tool as a peer - `debug/layout.js` - Shared shell for all debug pages: app bar, DebugNav, theme - `debug/DebugNav.js` - Segmented peer navigation shown on every debug page @@ -93,6 +94,8 @@ Neon Postgres, which is what the app queries at runtime. - `utils.js` - Utility functions including `cn()` for class name merging - `regions.js` - Client-safe region tree traversal. Imports nothing from `pano-index.js` or `pano-db.js`; `tests/regions.test.js` enforces that +- `map-tiles.js` - Client-safe tile provider choice: Geoapify when + `NEXT_PUBLIC_GEOAPIFY_KEY` is set at build time, OSM public server otherwise - `pano-index.js` - **Server-side only.** Picks a panorama for a region from Postgres and reports which district it landed in - `pano-db.js` - **Server-side only.** Neon HTTP adapter behind pano-index.js diff --git a/docs/tech-stack.md b/docs/tech-stack.md index 05c77e9..310f24e 100644 --- a/docs/tech-stack.md +++ b/docs/tech-stack.md @@ -16,8 +16,12 @@ `api/debug/mapillary/route.js` keeps the search as a diagnostic, and will reproduce those 500s - **Leaflet**: Interactive mapping library for guess placement -- **OpenStreetMap**: Map tile provider for base maps; Nominatim supplies the - administrative boundaries via `scripts/build-region-boundaries.mjs` +- **Map tiles**: `src/lib/map-tiles.js` picks the provider at build time — + Geoapify (`NEXT_PUBLIC_GEOAPIFY_KEY` set; free tier permits commercial use) + or the OSM public server as the keyless fallback for dev, e2e, and forks +- **OpenStreetMap**: Map data (© OpenStreetMap contributors, ODbL); Nominatim + supplies the administrative boundaries via + `scripts/build-region-boundaries.mjs` - **@photo-sphere-viewer/core**: 360° panorama viewer ## Geographic Processing diff --git a/src/app/components/LeafletMap.js b/src/app/components/LeafletMap.js index 14c8116..e29dcb4 100644 --- a/src/app/components/LeafletMap.js +++ b/src/app/components/LeafletMap.js @@ -8,6 +8,7 @@ import 'leaflet/dist/leaflet.css'; import markerIcon2x from 'leaflet/dist/images/marker-icon-2x.png'; import markerIcon from 'leaflet/dist/images/marker-icon.png'; import markerShadow from 'leaflet/dist/images/marker-shadow.png'; +import { getTileConfig } from '../../lib/map-tiles'; // A static image import is a {src} object under webpack but a bare URL string // under Turbopack dev; Leaflet needs the string either way. @@ -65,10 +66,8 @@ export default function LeafletMap({ } // Add tile layer - L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { - maxZoom: 19, - attribution: '© OpenStreetMap contributors' - }).addTo(map); + const tiles = getTileConfig(); + L.tileLayer(tiles.url, tiles.options).addTo(map); // Handle click events map.on('click', (e) => { diff --git a/src/app/components/ResultMap.js b/src/app/components/ResultMap.js index 43c4c7f..b3d3d88 100644 --- a/src/app/components/ResultMap.js +++ b/src/app/components/ResultMap.js @@ -1,6 +1,7 @@ "use client"; import { useEffect, useRef } from 'react'; +import { getTileConfig } from '../../lib/map-tiles'; // The reveal map inside the result dialog: the guess pin, the actual location, // and the line between them. Imperative Leaflet rather than LeafletMap because @@ -33,10 +34,8 @@ export default function ResultMap({ guessCoordinates, exactLocation }) { attributionControl: true }); - L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { - maxZoom: 19, - attribution: '© OpenStreetMap contributors' - }).addTo(map); + const tiles = getTileConfig(); + L.tileLayer(tiles.url, tiles.options).addTo(map); const markers = []; diff --git a/src/app/debug/coverage/CoverageMap.js b/src/app/debug/coverage/CoverageMap.js index b20c063..a3dd440 100644 --- a/src/app/debug/coverage/CoverageMap.js +++ b/src/app/debug/coverage/CoverageMap.js @@ -3,6 +3,7 @@ import { useEffect, useRef } from 'react'; import L from 'leaflet'; import 'leaflet/dist/leaflet.css'; +import { getTileConfig } from '../../../lib/map-tiles'; // Panorama dots are drawn on a canvas layer, not as DOM markers. Twelve // thousand DOM elements would stall the browser; a canvas renderer draws the @@ -43,10 +44,8 @@ export default function CoverageMap({ if (!containerRef.current || mapRef.current) return; const map = L.map(containerRef.current, { preferCanvas: true }); - L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { - maxZoom: 19, - attribution: '© OpenStreetMap contributors', - }).addTo(map); + const tiles = getTileConfig(); + L.tileLayer(tiles.url, tiles.options).addTo(map); map.setView([16.0, 107.0], 6); // One renderer for the life of the map. Creating it per data refresh leaked diff --git a/src/lib/map-tiles.js b/src/lib/map-tiles.js new file mode 100644 index 0000000..096b299 --- /dev/null +++ b/src/lib/map-tiles.js @@ -0,0 +1,38 @@ +// The only place a tile URL may live: every Leaflet map (guess, result, +// debug coverage) renders whichever provider this module picks. +// +// Client-safe: imported by client components, so nothing server-only may be +// imported here (tests/regions.test.js walks the import graph to enforce it). +// +// NEXT_PUBLIC_* is inlined at build time, so the choice is a build-time +// constant: with a Geoapify key the maps use Geoapify (free tier explicitly +// allows commercial use); without one they fall back to the OSM public +// server, which tolerates low-traffic non-commercial use — fine for local +// dev, e2e, and forks, but production must set the key before ads ship. +const geoapifyKey = process.env.NEXT_PUBLIC_GEOAPIFY_KEY; + +/** + * Resolve the tile layer for every map in the app. + * + * @returns {{url: string, options: {maxZoom: number, attribution: string}}} + * Arguments for L.tileLayer(url, options). + */ +export function getTileConfig() { + if (geoapifyKey) { + return { + url: `https://maps.geoapify.com/v1/tile/osm-bright/{z}/{x}/{y}.png?apiKey=${geoapifyKey}`, + options: { + maxZoom: 19, + attribution: + 'Powered by Geoapify | © OpenStreetMap contributors', + }, + }; + } + return { + url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', + options: { + maxZoom: 19, + attribution: '© OpenStreetMap contributors', + }, + }; +} diff --git a/tests/e2e/helpers.js b/tests/e2e/helpers.js index 1a3d714..63722c1 100644 --- a/tests/e2e/helpers.js +++ b/tests/e2e/helpers.js @@ -131,10 +131,15 @@ export async function stubGameApis(page, username) { }); // Leaflet's base tiles are the one request the app makes to a third party. // Stub them too, or every run hammers openstreetmap.org and the suite is not - // actually offline. + // actually offline. Both providers from src/lib/map-tiles.js are stubbed: + // the dev server loads .env, so a filled-in NEXT_PUBLIC_GEOAPIFY_KEY would + // otherwise send every run to Geoapify and spend metered free-tier credits. await page.route('**/tile.openstreetmap.org/**', async (route) => { await route.fulfill({ contentType: 'image/png', body: readFileSync(PANO_FIXTURE) }); }); + await page.route('**/maps.geoapify.com/**', async (route) => { + await route.fulfill({ contentType: 'image/png', body: readFileSync(PANO_FIXTURE) }); + }); } /** diff --git a/tests/map-tiles.test.js b/tests/map-tiles.test.js new file mode 100644 index 0000000..8e83fb3 --- /dev/null +++ b/tests/map-tiles.test.js @@ -0,0 +1,60 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +// The module captures NEXT_PUBLIC_GEOAPIFY_KEY at import time (Next.js inlines +// it at build time, so runtime mutation is impossible in the app). Each test +// therefore stubs the env first and imports a fresh copy of the module. +const importFresh = async () => { + vi.resetModules(); + return import('../src/lib/map-tiles.js'); +}; + +beforeEach(() => { + vi.unstubAllEnvs(); +}); + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe('getTileConfig', () => { + it('falls back to the OSM public server, byte-identical to the pre-migration values', async () => { + vi.stubEnv('NEXT_PUBLIC_GEOAPIFY_KEY', ''); + const { getTileConfig } = await importFresh(); + + // These exact values were hardcoded in all three maps before the + // migration; the keyless mode must never drift from them. + expect(getTileConfig()).toEqual({ + url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', + options: { + maxZoom: 19, + attribution: '© OpenStreetMap contributors', + }, + }); + }); + + it('serves Geoapify with its required attribution when a key is set', async () => { + vi.stubEnv('NEXT_PUBLIC_GEOAPIFY_KEY', 'test-key'); + const { getTileConfig } = await importFresh(); + + const tiles = getTileConfig(); + expect(tiles.url).toBe( + 'https://maps.geoapify.com/v1/tile/osm-bright/{z}/{x}/{y}.png?apiKey=test-key' + ); + expect(tiles.options.maxZoom).toBe(19); + // Geoapify's free tier requires a visible "Powered by Geoapify" credit; + // OSM data attribution must survive the provider swap. + expect(tiles.options.attribution).toContain('Powered by'); + expect(tiles.options.attribution).toContain('https://www.geoapify.com/'); + expect(tiles.options.attribution).toContain('© OpenStreetMap contributors'); + }); + + it('returns a fresh object per call, so Leaflet cannot mutate shared state', async () => { + vi.stubEnv('NEXT_PUBLIC_GEOAPIFY_KEY', ''); + const { getTileConfig } = await importFresh(); + + const first = getTileConfig(); + const second = getTileConfig(); + expect(first).not.toBe(second); + expect(first.options).not.toBe(second.options); + }); +});